Coverage for /home/admin/Documents/AI/applications/lexigram-dev/lexigram/experimental/ai/lexigram-ai-llm/src/lexigram/ai/llm/di/provider.py: 51%
152 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 07:19 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 07:19 +0800
1"""LLM Provider for Lexigram Framework dependency injection.
3Registers all LLM-layer services (clients, cache, model manager) with
4the container so they can be injected throughout the application.
5"""
7from __future__ import annotations
9from typing import TYPE_CHECKING, cast
11from lexigram.ai.llm.exceptions import LLMError
12from lexigram.contracts.ai import LLMClientProtocol
13from lexigram.contracts.ai.providers import ProviderRegistryProtocol
14from lexigram.contracts.core.health import HealthCheckResult
15from lexigram.contracts.exceptions.base import LexigramError
16from lexigram.contracts.exceptions.container import UnresolvableDependencyError
17from lexigram.contracts.exceptions.provider import ModuleVisibilityError
18from lexigram.di.decorators import inject
19from lexigram.di.provider import Provider, ProviderPriority
20from lexigram.logging import get_logger
22if TYPE_CHECKING:
23 from lexigram.contracts.core.di import (
24 ContainerRegistrarProtocol,
25 ContainerResolverProtocol,
26 )
27 from lexigram.contracts.core.health import HealthCheckResult
28 from lexigram.contracts.infra.cache import CacheBackendProtocol
30from lexigram.ai.llm.config import ClientConfig
31from lexigram.ai.llm.di.factories import create_llm_cache, create_llm_client
32from lexigram.ai.llm.model_manager import LLMModelManager
33from lexigram.ai.llm.registry.core import ProviderRegistry
35logger = get_logger(__name__)
37__all__ = ["LLMProvider"]
40# Map provider name → pyproject.toml extra name for actionable install hints.
41# Most match the provider name, but some share an extra (e.g. deepseek, azure-openai
42# all bundle under the ``openai`` extra).
43_PROVIDER_EXTRAS: dict[str, str] = {
44 "ollama": "ollama",
45 "openai": "openai",
46 "anthropic": "anthropic",
47 "cohere": "cohere",
48 "groq": "groq",
49 "mistral": "mistral",
50 "deepseek": "openai",
51 "together": "openai",
52 "fireworks": "openai",
53 "openrouter": "openai",
54 "azure-openai": "openai",
55 "cloudflare": "openai",
56 "gemini": "openai",
57 "google-vertex": "openai",
58 "aws-bedrock": "openai",
59}
62@inject
63class LLMProvider(Provider):
64 """Provider that registers LLM services with the Lexigram DI container.
66 Registers an LLMClientProtocol, optional LLM response cache, and an LLMModelManager
67 so all three are injectable throughout the application.
69 Example:
70 >>> from lexigram.ai.llm.di.provider import LLMProvider
71 >>> from lexigram.ai.llm.config import ClientConfig
72 >>>
73 >>> app.use(LLMProvider(ClientConfig(provider="openai", model="gpt-4o")))
74 >>>
75 >>> # LLMClientProtocol is now injectable:
76 >>> class MyService:
77 ... def __init__(self, llm: LLMClientProtocol) -> None:
78 ... self.llm = llm
79 """
81 name = "llm"
82 priority = ProviderPriority.DOMAIN
83 config_key: str | None = "ai_llm"
84 config_model: type | None = ClientConfig
86 def __init__(
87 self,
88 config: ClientConfig | None = None,
89 enable_model_manager: bool = False,
90 enable_streaming: bool = True,
91 audit_calls: bool = False,
92 name: str = "llm",
93 cache_backend: CacheBackendProtocol | None = None,
94 stub_mode: bool = False,
95 ) -> None:
96 """Initialize the LLM Provider.
98 Args:
99 config: LLM client configuration; defaults to ClientConfig() (reads env).
100 enable_model_manager: Register LLMModelManager for local model control.
101 enable_streaming: Enable streaming response support.
102 audit_calls: Register :class:`~lexigram.ai.llm.audit_bridge.LLMAuditBridge`
103 to emit audit entries per completion.
104 name: Provider name used for identification.
105 cache_backend: Injected cache backend for optional response caching.
106 """
107 super().__init__(name=name)
108 self._requested_config = config
109 self.config = config or ClientConfig()
110 self.enable_model_manager = enable_model_manager
111 self.enable_streaming = enable_streaming
112 self.audit_calls = audit_calls
113 self.cache_backend = cache_backend
114 self.stub_mode = stub_mode
115 self._llm_client: LLMClientProtocol | None = None
117 @staticmethod
118 def _instructor_available() -> bool:
119 """Check if the instructor library is available."""
120 import importlib.util
122 try:
123 return importlib.util.find_spec("instructor") is not None
124 except (ValueError, AttributeError):
125 return False
127 async def register(self, container: ContainerRegistrarProtocol) -> None:
128 """Register LLM services with the DI container.
130 Args:
131 container: The Lexigram DI container registrar.
132 """
133 from lexigram.ai.llm.pricing.registry import TokenCounterRegistry
134 from lexigram.contracts.ai.llm import TokenCounterProtocol
136 self.config = self._requested_config or self.config
137 container.singleton(ClientConfig, self.config)
139 if not self.config.enabled:
140 logger.info("llm_disabled", reason="ClientConfig.enabled=False")
141 return
143 logger.info(
144 "Registering LLM services",
145 provider=self.config.provider,
146 model=self.config.model,
147 )
149 registry = ProviderRegistry()
150 container.singleton(ProviderRegistry, registry)
151 container.singleton(ProviderRegistryProtocol, registry)
153 # Register TokenCounterRegistry
154 token_registry = TokenCounterRegistry.with_defaults()
155 container.singleton(TokenCounterRegistry, token_registry)
157 # Register ParserRegistry with default parsers
158 from lexigram.ai.llm.parsers.registry import ParserRegistry
160 parser_registry = ParserRegistry.with_defaults()
161 container.singleton(ParserRegistry, parser_registry)
162 logger.info("Registered ParserRegistry with default parsers")
164 llm_client: LLMClientProtocol
165 try:
166 if self.stub_mode:
167 from lexigram.ai.llm.clients.noop import NoOpLLMClient
169 llm_client = cast("LLMClientProtocol", NoOpLLMClient(self.config))
170 else:
171 llm_client = await create_llm_client(self.config, registry)
172 except ImportError as exc:
173 extra = _PROVIDER_EXTRAS.get(
174 self.config.provider.value,
175 self.config.provider.value,
176 )
177 msg = (
178 f"Missing SDK for provider {self.config.provider!r}. "
179 f"The SDK package is an optional dependency.\n"
180 f" Install: uv sync --extra {extra}\n"
181 f" Or: pip install lexigram-ai-llm[{extra}]\n"
182 f" Error: {exc}"
183 )
184 raise LLMError(msg) from exc
186 # Always enrich completions with provenance (provider, model_revision,
187 # prompt_hash) — innermost wrap so downstream layers see fully-populated
188 # Completion objects. LXF-003 wiring.
189 from lexigram.ai.llm.wrappers import CompletionEnricher, LLMCacheWrapper
191 llm_client = CompletionEnricher.wrap(
192 llm_client,
193 provider=self.config.provider,
194 model=self.config.model,
195 model_revision=getattr(self.config, "model_revision", None),
196 )
198 container.singleton(LLMClientProtocol, llm_client)
199 container.singleton("llm", llm_client)
200 self._llm_client = llm_client
201 logger.info(
202 "Registered LLM client",
203 provider=self.config.provider,
204 model=self.config.model,
205 )
207 if self.config.enable_cache:
208 llm_cache = await create_llm_cache(self.config, self.cache_backend)
209 container.singleton("llm_cache", llm_cache)
210 logger.info("Registered LLM response cache")
212 if self.enable_model_manager:
213 container.singleton(LLMModelManager)
214 container.singleton("llm_model_manager", LLMModelManager)
215 logger.info("Registered LLMModelManager")
217 if self.audit_calls:
218 from lexigram.ai.llm.audit_bridge import LLMAuditBridge
220 container.singleton(LLMAuditBridge)
221 wrapped_client = LLMAuditBridge.wrap(llm_client, container)
222 container.singleton(LLMClientProtocol, wrapped_client)
223 container.singleton("llm", wrapped_client)
224 self._llm_client = wrapped_client
225 llm_client = wrapped_client # subsequent wraps build on this
226 logger.info("Registered LLMAuditBridge (audit_calls=True)")
228 # Cache wrap sits outermost: cache hits short-circuit before audit
229 # fires (no real LLM call happened, so no audit entry). LXF-003 wiring
230 # — actually invokes build_llm_cache_key in production paths.
231 if self.config.enable_cache:
232 cache_wrapped = LLMCacheWrapper.wrap(
233 llm_client,
234 cache=llm_cache,
235 provider=self.config.provider,
236 model=self.config.model,
237 model_revision=getattr(self.config, "model_revision", None),
238 ttl_seconds=getattr(self.config, "cache_ttl", None),
239 )
240 container.singleton(LLMClientProtocol, cache_wrapped)
241 container.singleton("llm", cache_wrapped)
242 self._llm_client = cache_wrapped
243 logger.info("Registered LLMCacheWrapper (cache hits skip audit)")
245 # Register default TokenCounterProtocol
246 default_counter = token_registry.for_model(self.config.model or "gpt-3.5-turbo")
247 container.singleton(TokenCounterProtocol, default_counter)
249 # Register pricing manager + cost estimator when pricing is configured
250 if self.config.pricing and self.config.pricing.enabled:
251 from lexigram.ai.llm.pricing.estimator import PricingCostEstimator
252 from lexigram.ai.llm.pricing.manager import PricingManager
253 from lexigram.contracts.ai.llm import CostEstimatorProtocol
255 pricing_manager = PricingManager(
256 sources=self.config.pricing.build_sources(),
257 cache_ttl=self.config.pricing.cache_ttl,
258 enable_fuzzy_match=self.config.pricing.enable_fuzzy_match,
259 )
260 container.singleton(PricingManager, pricing_manager)
261 container.singleton(
262 CostEstimatorProtocol,
263 PricingCostEstimator(
264 {},
265 enable_fuzzy_match=self.config.pricing.enable_fuzzy_match,
266 ),
267 )
268 logger.info(
269 "Registered pricing manager and cost estimator",
270 sources=[s.source_name for s in pricing_manager.sources],
271 )
273 # Register InstructorExtractor if instructor is available
274 if self._instructor_available():
275 from lexigram.ai.llm.extraction.extractor import InstructorExtractor
277 container.singleton(InstructorExtractor, InstructorExtractor)
278 logger.info("Registered InstructorExtractor")
280 logger.info("LLM services registered")
282 async def boot(self, container: ContainerResolverProtocol) -> None:
283 """Boot the LLM provider — validates API key presence and format.
285 Args:
286 container: The DI container resolver.
287 """
288 from lexigram.contracts.ai.types import ModelProvider
290 # Providers that require an API key
291 _REQUIRES_KEY: set[str] = {
292 ModelProvider.OPENAI,
293 ModelProvider.ANTHROPIC,
294 ModelProvider.COHERE,
295 ModelProvider.GROQ,
296 ModelProvider.MISTRAL,
297 ModelProvider.OPENROUTER,
298 ModelProvider.GEMINI,
299 ModelProvider.CLOUDFLARE,
300 ModelProvider.AZURE_OPENAI,
301 ModelProvider.DEEPSEEK,
302 ModelProvider.TOGETHER,
303 ModelProvider.FIREWORKS,
304 }
306 provider = str(self.config.provider)
307 if provider in _REQUIRES_KEY:
308 if not self.config.api_key:
309 logger.warning(
310 "llm_provider_boot_warning",
311 provider=provider,
312 reason="API key is not set; requests will likely fail with AuthenticationError",
313 )
314 else:
315 key_val = self.config.api_key.get_secret_value()
316 if len(key_val) < 8:
317 logger.warning(
318 "llm_provider_boot_warning",
319 provider=provider,
320 reason="API key appears too short; verify the key is correct",
321 )
323 # Warm the cost estimator pricing snapshot from configured sources
324 if self.config.pricing and self.config.pricing.enabled:
325 try:
326 from lexigram.ai.llm.pricing.estimator import PricingCostEstimator
327 from lexigram.ai.llm.pricing.manager import PricingManager
328 from lexigram.contracts.ai.llm import CostEstimatorProtocol
330 manager = await container.resolve(PricingManager)
331 estimator = await container.resolve(CostEstimatorProtocol)
332 await cast("PricingCostEstimator", estimator).warm(manager)
333 except (
334 OSError,
335 ValueError,
336 TypeError,
337 LookupError,
338 RuntimeError,
339 LexigramError,
340 ModuleVisibilityError,
341 UnresolvableDependencyError,
342 ) as e:
343 logger.warning(
344 "pricing_preload_failed",
345 error=str(e),
346 reason="cost estimates will return 0.0 until sources are reachable",
347 )
349 # Optional: register this client in the provider registry
350 if self._llm_client is not None:
351 try:
352 from lexigram.contracts.ai.providers import ProviderRegistryProtocol
354 registry = await container.resolve(ProviderRegistryProtocol)
355 await registry.register_provider(
356 name=str(self.config.provider),
357 client=self._llm_client,
358 models=[],
359 )
360 logger.debug(
361 "llm_registered_in_provider_registry",
362 provider=self.config.provider,
363 )
364 except (
365 LookupError,
366 RuntimeError,
367 AttributeError,
368 ImportError,
369 ModuleVisibilityError,
370 UnresolvableDependencyError,
371 ):
372 logger.debug("llm_provider_registry_not_available")
374 async def shutdown(self) -> None:
375 """Close client connections on application shutdown."""
376 if self._llm_client and hasattr(self._llm_client, "close"):
377 try:
378 await self._llm_client.close()
379 except (ConnectionError, TimeoutError, OSError) as exc:
380 logger.warning("Error closing LLM client", error=str(exc))
381 self._llm_client = None
382 logger.info("LLM provider shutdown complete")
384 async def health_check(self, timeout: float = 5.0) -> HealthCheckResult:
385 """Return basic health information for the registered LLM client."""
386 from lexigram.contracts.core.health import HealthCheckResult, HealthStatus
388 return HealthCheckResult(
389 component=self.name,
390 status=HealthStatus.HEALTHY
391 if self._llm_client is not None
392 else HealthStatus.DEGRADED,
393 details={
394 "provider": str(self.config.provider),
395 "model": self.config.model,
396 },
397 )