Coverage for /home/admin/Documents/AI/applications/lexigram-dev/lexigram/experimental/ai/lexigram-ai-llm/src/lexigram/ai/llm/routing/config.py: 43%
107 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"""Configuration schema for LLM multi-provider routing.
3Defines all typed configuration objects accepted by ``LLMRoutingProvider``
4and ``LLMRouter``. Use ``LLMConfig.from_env()`` to build from environment
5variables following the ``LEX_AI_LLM__`` prefix convention.
6"""
8from __future__ import annotations
10from dataclasses import dataclass
11import os
12from typing import ClassVar, Literal
14from lexigram.config.base import BaseConfig
15from lexigram.validation import ConfigDict, Field, SecretStr
17__all__ = [
18 "GenerationDefaults",
19 "LLMConfig",
20 "LogConfig",
21 "ProviderConfig",
22 "QuotaConfig",
23]
25_ENV = "LEX_AI_LLM__"
28@dataclass(init=False)
29class GenerationDefaults(BaseConfig):
30 """Default generation parameters applied to every routing attempt.
32 Example:
33 >>> defaults = GenerationDefaults(temperature=0.3, max_tokens=2048)
34 """
36 model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore")
38 temperature: float = Field(
39 default=0.2,
40 ge=0.0,
41 le=2.0,
42 description="Default sampling temperature.",
43 )
44 max_tokens: int | None = Field(
45 default=None,
46 ge=1,
47 description="Default maximum output tokens (None = provider default).",
48 )
51@dataclass(init=False)
52class QuotaConfig(BaseConfig):
53 """Configuration for the quota tracking backend.
55 Example:
56 >>> cfg = QuotaConfig(backend="database")
57 """
59 model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore")
61 backend: Literal["memory", "database"] = Field(
62 default="memory",
63 description="Backend type: ``memory`` or ``database``.",
64 )
65 cooldown_seconds: int = Field(
66 default=300,
67 ge=1,
68 description=(
69 "Cooldown applied to a cascade entry after an HTTP 429. "
70 "Env var: LEX_AI_LLM__QUOTA__COOLDOWN_SECONDS."
71 ),
72 )
75@dataclass(init=False)
76class LogConfig(BaseConfig):
77 """Configuration for inference attempt logging.
79 Example:
80 >>> cfg = LogConfig(backend="database", max_entries=5000)
81 """
83 model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore")
85 backend: Literal["memory", "database"] = Field(
86 default="memory",
87 description="Backend type: ``memory`` or ``database``.",
88 )
89 max_entries: int = Field(
90 default=1000,
91 ge=1,
92 description="Maximum in-memory log entries before FIFO eviction.",
93 )
96@dataclass(init=False)
97class ProviderConfig(BaseConfig):
98 """Configuration for a single provider in the routing cascade.
100 Every provider in the cascade has the same shape regardless of type.
101 Provider-specific fields (Azure deployment, Cloudflare account ID,
102 Bedrock region, Vertex project) go in ``extras``.
104 Example:
105 >>> cfg = ProviderConfig(
106 ... name="groq",
107 ... model="llama-3.3-70b-versatile",
108 ... api_key="gsk_...",
109 ... )
110 """
112 model_config: ClassVar[ConfigDict] = ConfigDict(
113 arbitrary_types_allowed=True, extra="ignore"
114 )
116 name: str = Field(description="Provider name (must match ProviderRegistry key).")
117 model: str = Field(description="Primary model identifier.")
118 api_key: SecretStr | None = Field(
119 default=None,
120 description="API key for key-authenticated providers.",
121 )
122 base_url: str | None = Field(
123 default=None,
124 description="Base URL override (local providers, Azure, custom endpoints).",
125 )
126 timeout: int = Field(
127 default=30,
128 ge=5,
129 description="Per-request timeout in seconds.",
130 )
131 enabled: bool = Field(
132 default=True,
133 description="Whether this provider participates in routing.",
134 )
135 suppress_thinking: bool = Field(
136 default=False,
137 description=(
138 "When True, the LLM client actively disables thinking/chain-of-thought "
139 "token generation by injecting ``enable_thinking: false`` into the request "
140 "payload. Use for models that think by default (Qwen3, Gemma-4 via LM Studio "
141 "/ vLLM / SGLang) to eliminate the 20-30 s latency overhead. "
142 "Env var: ``LEX_AI_LLM__PROVIDERS__{NAME}__SUPPRESS_THINKING=true``."
143 ),
144 )
145 extras: dict[str, str] = Field(
146 default_factory=dict,
147 description=(
148 "Provider-specific credential fields: "
149 "azure_resource, azure_deployment, azure_api_version; "
150 "cf_account_id, cf_api_token; "
151 "aws_region, aws_access_key_id, aws_secret_access_key; "
152 "vertex_project, vertex_location, vertex_credentials_file."
153 ),
154 )
156 @property
157 def key(self) -> str:
158 """Unique cascade-entry identity: provider name + model.
160 Multiple entries may share ``name`` (e.g. several OpenRouter models);
161 quota, clients, and routing state must key on this, never on ``name``.
162 """
163 return f"{self.name}:{self.model}"
166@dataclass(init=False)
167class LLMConfig(BaseConfig):
168 """Root configuration object for the LLM routing system.
170 All providers are opt-in: a provider joins the cascade only when its
171 credential environment variable is set. Use :meth:`from_env` to build
172 from ``LEX_AI_LLM__`` environment variables.
174 Example::
176 config = LLMConfig(
177 providers=[
178 ProviderConfig(name="groq", model="llama-3.3-70b-versatile", api_key="gsk_..."),
179 ProviderConfig(name="gemini", model="gemini-2.5-flash", api_key="AIza..."),
180 ],
181 defaults=GenerationDefaults(temperature=0.3),
182 )
184 Environment variables (prefix ``LEX_AI_LLM__``)::
186 Global:
188 LEX_AI_LLM__STRATEGY sequential | parallel_race |
189 cost_optimized | latency_optimized
190 LEX_AI_LLM__DEFAULTS__TEMPERATURE float (default 0.2)
191 LEX_AI_LLM__DEFAULTS__MAX_TOKENS int (default: provider default)
192 LEX_AI_LLM__QUOTA__BACKEND memory | database (default memory)
193 LEX_AI_LLM__LOG__BACKEND memory | database (default memory)
194 LEX_AI_LLM__LOG__MAX_ENTRIES int (default 1000)
196 Per-provider (pattern: LEX_AI_LLM__PROVIDERS__{NAME}__{FIELD}):
198 __{NAME}__API_KEY str API key -- activates key-auth providers
199 __{NAME}__BASE_URL str Endpoint -- activates local/custom providers
200 __{NAME}__MODEL str Model override (has per-provider defaults)
201 __{NAME}__TIMEOUT int Request timeout in seconds (default 30)
202 __{NAME}__ENABLED bool Explicit enable/disable (default true)
204 Supported provider names and their activation:
206 OPENAI API_KEY required default model: gpt-4o
207 ANTHROPIC API_KEY required default model: claude-3-5-sonnet-20241022
208 GROQ API_KEY required default model: llama-3.3-70b-versatile
209 GEMINI API_KEY required default model: gemini-2.5-flash
210 MISTRAL API_KEY required default model: mistral-large-latest
211 COHERE API_KEY required default model: command-r-plus
212 OPENROUTER API_KEY required default model: openai/gpt-4o-mini
213 DEEPSEEK API_KEY required default model: deepseek-chat
214 TOGETHER API_KEY required default model: meta-llama/Llama-3-8b-chat-hf
215 FIREWORKS API_KEY required default model: accounts/fireworks/models/llama-v3-70b-instruct
216 OLLAMA BASE_URL required default model: llama3.2 (default base: http://localhost:11434)
217 OPENAI_COMPATIBLE BASE_URL + MODEL required (generic OpenAI-compatible: LM Studio, VLLM, etc.)
219 Azure-specific extras (activated by AZURE__API_KEY + AZURE__BASE_URL):
221 LEX_AI_LLM__PROVIDERS__AZURE__EXTRAS__AZURE_RESOURCE
222 LEX_AI_LLM__PROVIDERS__AZURE__EXTRAS__AZURE_DEPLOYMENT
223 LEX_AI_LLM__PROVIDERS__AZURE__EXTRAS__AZURE_API_VERSION
225 Cloudflare-specific extras (activated by CLOUDFLARE__EXTRAS__CF_ACCOUNT_ID):
227 LEX_AI_LLM__PROVIDERS__CLOUDFLARE__EXTRAS__CF_ACCOUNT_ID <- activates
228 LEX_AI_LLM__PROVIDERS__CLOUDFLARE__EXTRAS__CF_API_TOKEN
229 LEX_AI_LLM__PROVIDERS__CLOUDFLARE__MODEL
231 AWS Bedrock extras (activated by BEDROCK__EXTRAS__AWS_REGION):
233 LEX_AI_LLM__PROVIDERS__BEDROCK__EXTRAS__AWS_REGION <- activates
234 LEX_AI_LLM__PROVIDERS__BEDROCK__MODEL
235 LEX_AI_LLM__PROVIDERS__BEDROCK__EXTRAS__AWS_ACCESS_KEY_ID
236 LEX_AI_LLM__PROVIDERS__BEDROCK__EXTRAS__AWS_SECRET_ACCESS_KEY
238 Google Vertex AI extras (activated by VERTEX__EXTRAS__VERTEX_PROJECT):
240 LEX_AI_LLM__PROVIDERS__VERTEX__EXTRAS__VERTEX_PROJECT <- activates
241 LEX_AI_LLM__PROVIDERS__VERTEX__EXTRAS__VERTEX_LOCATION
242 LEX_AI_LLM__PROVIDERS__VERTEX__MODEL
243 LEX_AI_LLM__PROVIDERS__VERTEX__EXTRAS__VERTEX_CREDENTIALS_FILE
244 """
246 model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore")
248 providers: list[ProviderConfig] = Field(
249 default_factory=list,
250 description="Ordered list of providers (cascade order). First = highest priority.",
251 )
252 defaults: GenerationDefaults = Field(
253 default_factory=GenerationDefaults,
254 description="Default generation parameters.",
255 )
256 quota: QuotaConfig = Field(
257 default_factory=QuotaConfig,
258 description="Quota backend configuration.",
259 )
260 logging: LogConfig = Field(
261 default_factory=LogConfig,
262 description="Inference logger configuration.",
263 )
264 strategy: Literal[
265 "sequential",
266 "parallel_race",
267 "cost_optimized",
268 "latency_optimized",
269 ] = Field(
270 default="sequential",
271 description=(
272 "Routing strategy. "
273 "``sequential``: try providers in cascade order. "
274 "``parallel_race``: fire all in parallel, take the first success. "
275 "``cost_optimized``: cheapest providers first. "
276 "``latency_optimized``: fastest recent provider first."
277 ),
278 )
280 @classmethod
281 def from_env(cls) -> LLMConfig:
282 """Build a routing config from ``LEX_AI_LLM__`` environment variables.
284 Returns:
285 Populated :class:`LLMConfig`.
286 """
288 def _str(key: str, default: str = "") -> str:
289 return os.environ.get(key, default).strip()
291 def _int(key: str, default: int) -> int:
292 raw = _str(key)
293 return int(raw) if raw else default
295 def _bool(key: str, default: bool = True) -> bool:
296 raw = _str(key).lower()
297 if raw in ("false", "0", "no"):
298 return False
299 if raw in ("true", "1", "yes"):
300 return True
301 return default
303 def _secret(key: str) -> SecretStr | None:
304 val = _str(key)
305 return SecretStr(val) if val else None
307 def _p(name: str, fld: str) -> str:
308 """Build provider env var key."""
309 return f"{_ENV}PROVIDERS__{name}__{fld}"
311 def _extras(name: str, key: str) -> str:
312 return _str(f"{_ENV}PROVIDERS__{name}__EXTRAS__{key}")
314 providers: list[ProviderConfig] = []
316 # Key-auth cloud providers
317 _KEY_PROVIDERS: list[tuple[str, str]] = [
318 ("OPENAI", "gpt-4o"),
319 ("ANTHROPIC", "claude-3-5-sonnet-20241022"),
320 ("GROQ", "llama-3.3-70b-versatile"),
321 ("GEMINI", "gemini-2.5-flash"),
322 ("MISTRAL", "mistral-large-latest"),
323 ("COHERE", "command-r-plus"),
324 ("OPENROUTER", "openai/gpt-4o-mini"),
325 ("DEEPSEEK", "deepseek-chat"),
326 ("TOGETHER", "meta-llama/Llama-3-8b-chat-hf"),
327 ("FIREWORKS", "accounts/fireworks/models/llama-v3-70b-instruct"),
328 ]
329 for name, default_model in _KEY_PROVIDERS:
330 key = _secret(_p(name, "API_KEY"))
331 if not key:
332 continue
333 providers.append(
334 ProviderConfig(
335 name=name.lower(),
336 model=_str(_p(name, "MODEL"), default_model),
337 api_key=key,
338 base_url=_str(_p(name, "BASE_URL")) or None,
339 timeout=_int(_p(name, "TIMEOUT"), 30),
340 enabled=_bool(_p(name, "ENABLED")),
341 suppress_thinking=_bool(
342 _p(name, "SUPPRESS_THINKING"), default=False
343 ),
344 )
345 )
347 # Azure OpenAI (key-auth + extras)
348 azure_key = _secret(_p("AZURE", "API_KEY"))
349 azure_base = _str(_p("AZURE", "BASE_URL"))
350 if azure_key and azure_base:
351 providers.append(
352 ProviderConfig(
353 name="azure",
354 model=_str(_p("AZURE", "MODEL"), "gpt-4o"),
355 api_key=azure_key,
356 base_url=azure_base,
357 timeout=_int(_p("AZURE", "TIMEOUT"), 30),
358 enabled=_bool(_p("AZURE", "ENABLED")),
359 extras={
360 k: v
361 for k, v in {
362 "azure_resource": _extras("AZURE", "AZURE_RESOURCE"),
363 "azure_deployment": _extras("AZURE", "AZURE_DEPLOYMENT"),
364 "azure_api_version": _extras("AZURE", "AZURE_API_VERSION"),
365 }.items()
366 if v
367 },
368 )
369 )
371 # Cloudflare Workers AI
372 cf_account = _extras("CLOUDFLARE", "CF_ACCOUNT_ID")
373 cf_token = _extras("CLOUDFLARE", "CF_API_TOKEN")
374 if cf_account and cf_token:
375 providers.append(
376 ProviderConfig(
377 name="cloudflare",
378 model=_str(
379 _p("CLOUDFLARE", "MODEL"), "@cf/meta/llama-3.1-8b-instruct"
380 ),
381 timeout=_int(_p("CLOUDFLARE", "TIMEOUT"), 30),
382 enabled=_bool(_p("CLOUDFLARE", "ENABLED")),
383 extras={"cf_account_id": cf_account, "cf_api_token": cf_token},
384 )
385 )
387 # AWS Bedrock
388 bedrock_region = _extras("BEDROCK", "AWS_REGION")
389 if bedrock_region:
390 extras: dict[str, str] = {"aws_region": bedrock_region}
391 for k in (
392 "AWS_ACCESS_KEY_ID",
393 "AWS_SECRET_ACCESS_KEY",
394 "AWS_SESSION_TOKEN",
395 "AWS_PROFILE",
396 ):
397 val = _extras("BEDROCK", k)
398 if val:
399 extras[k.lower()] = val
400 providers.append(
401 ProviderConfig(
402 name="bedrock",
403 model=_str(
404 _p("BEDROCK", "MODEL"), "anthropic.claude-3-haiku-20240307-v1:0"
405 ),
406 timeout=_int(_p("BEDROCK", "TIMEOUT"), 60),
407 enabled=_bool(_p("BEDROCK", "ENABLED")),
408 extras=extras,
409 )
410 )
412 # Google Vertex AI
413 vertex_project = _extras("VERTEX", "VERTEX_PROJECT")
414 if vertex_project:
415 vextras: dict[str, str] = {"vertex_project": vertex_project}
416 for k in ("VERTEX_LOCATION", "VERTEX_CREDENTIALS_FILE"):
417 val = _extras("VERTEX", k)
418 if val:
419 vextras[k.lower()] = val
420 providers.append(
421 ProviderConfig(
422 name="vertex",
423 model=_str(_p("VERTEX", "MODEL"), "gemini-1.5-pro"),
424 timeout=_int(_p("VERTEX", "TIMEOUT"), 60),
425 enabled=_bool(_p("VERTEX", "ENABLED")),
426 extras=vextras,
427 )
428 )
430 # Ollama
431 ollama_base = _str(_p("OLLAMA", "BASE_URL"))
432 if ollama_base:
433 providers.append(
434 ProviderConfig(
435 name="ollama",
436 model=_str(_p("OLLAMA", "MODEL"), "llama3.2"),
437 base_url=ollama_base,
438 timeout=_int(_p("OLLAMA", "TIMEOUT"), 120),
439 enabled=_bool(_p("OLLAMA", "ENABLED")),
440 suppress_thinking=_bool(
441 _p("OLLAMA", "SUPPRESS_THINKING"), default=False
442 ),
443 )
444 )
446 # Generic OpenAI-compatible server (LM Studio, vLLM, etc.)
447 openai_compatible_base = _str(_p("OPENAI_COMPATIBLE", "BASE_URL"))
448 openai_compatible_model = _str(_p("OPENAI_COMPATIBLE", "MODEL"))
449 if openai_compatible_base and openai_compatible_model:
450 providers.append(
451 ProviderConfig(
452 name="openai_compatible",
453 model=openai_compatible_model,
454 api_key=_secret(_p("OPENAI_COMPATIBLE", "API_KEY")),
455 base_url=openai_compatible_base,
456 timeout=_int(_p("OPENAI_COMPATIBLE", "TIMEOUT"), 120),
457 enabled=_bool(_p("OPENAI_COMPATIBLE", "ENABLED")),
458 suppress_thinking=_bool(
459 _p("OPENAI_COMPATIBLE", "SUPPRESS_THINKING"), default=False
460 ),
461 )
462 )
464 # Global settings
465 temperature_raw = _str(f"{_ENV}DEFAULTS__TEMPERATURE", "0.2")
466 max_tokens_raw = _str(f"{_ENV}DEFAULTS__MAX_TOKENS")
468 return cls(
469 providers=providers,
470 defaults=GenerationDefaults(
471 temperature=float(temperature_raw),
472 max_tokens=int(max_tokens_raw) if max_tokens_raw else None,
473 ),
474 quota=QuotaConfig(
475 backend=_str(f"{_ENV}QUOTA__BACKEND", "memory"),
476 ),
477 logging=LogConfig(
478 backend=_str(f"{_ENV}LOG__BACKEND", "memory"),
479 max_entries=_int(f"{_ENV}LOG__MAX_ENTRIES", 1000),
480 ),
481 strategy=_str(f"{_ENV}STRATEGY", "sequential") or "sequential",
482 )