Coverage for /home/admin/Documents/AI/applications/lexigram-dev/lexigram/experimental/ai/lexigram-ai-llm/src/lexigram/ai/llm/config.py: 65%

75 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-25 07:19 +0800

1"""Configuration schema for the LLM package. 

2 

3Defines LLMConfig, the typed configuration object accepted by LLMProvider 

4and all LLM client implementations. 

5""" 

6 

7from __future__ import annotations 

8 

9from dataclasses import dataclass 

10from pathlib import Path 

11from typing import TYPE_CHECKING, Any, ClassVar 

12 

13from lexigram.ai.llm.pinning import ModelPinPolicy 

14from lexigram.config.base import BaseConfig 

15from lexigram.contracts.ai.thinking import ThinkingConfig 

16from lexigram.contracts.ai.types import ModelProvider 

17from lexigram.domain import DomainModel 

18from lexigram.validation import ConfigDict, Field, SecretStr 

19 

20if TYPE_CHECKING: 

21 from lexigram.ai.llm.pricing.sources import AbstractPricingSource 

22 

23 

24@dataclass(init=False) 

25class PricingSourceConfig(DomainModel): 

26 """A single pricing data source, configurable from YAML. 

27 

28 Attributes: 

29 type: Source kind. One of ``"litellm"``, ``"openrouter"``, ``"json"``, 

30 or ``"static"``. 

31 endpoint: API endpoint URL. Used by ``litellm`` (defaults to the 

32 LiteLLM model cost map on GitHub) and ``openrouter`` (defaults to 

33 ``https://openrouter.ai/api/v1/models``). 

34 file_path: Path to a local pricing JSON file (``json`` type only). 

35 timeout: HTTP timeout in seconds for API sources. 

36 models: Inline static prices (``static`` type only). Maps a model 

37 name to ``{prompt_per_1m, completion_per_1m, provider?}``. 

38 

39 Example: 

40 .. code-block:: yaml 

41 

42 ai_llm: 

43 pricing: 

44 enabled: true 

45 sources: 

46 - type: openrouter 

47 - type: litellm 

48 - type: json 

49 file_path: pricing/custom.json 

50 - type: static 

51 models: 

52 internal-model: 

53 prompt_per_1m: 0.5 

54 completion_per_1m: 1.5 

55 provider: custom 

56 """ 

57 

58 type: str = Field( 

59 ..., 

60 description='Source kind: "litellm", "openrouter", "json", or "static".', 

61 ) 

62 endpoint: str | None = Field( 

63 default=None, 

64 description="API endpoint URL (litellm/openrouter sources).", 

65 ) 

66 file_path: str | None = Field( 

67 default=None, 

68 description="Path to a pricing JSON file (json source).", 

69 ) 

70 timeout: float = Field( 

71 default=10.0, 

72 ge=1.0, 

73 description="HTTP timeout in seconds for API sources.", 

74 ) 

75 models: dict[str, dict[str, float]] = Field( 

76 default_factory=dict, 

77 description="Inline prices per model (static source).", 

78 ) 

79 

80 

81@dataclass(init=False) 

82class PricingConfig(DomainModel): 

83 """Pricing and cost-estimation configuration for the LLM subsystem. 

84 

85 When attached to :class:`ClientConfig` (``ai_llm.pricing`` section in 

86 YAML), the LLM provider registers a ``CostEstimatorProtocol`` backed by 

87 a ``PricingManager`` over the configured sources. Agents wired to the 

88 container then get real USD cost estimates per execution. 

89 

90 Sources are queried in the configured order; the first source that knows 

91 a model wins. When no sources are listed, defaults are used: 

92 OpenRouter (freshest prices for OpenAI/Anthropic/Google/Meta/Cohere/ 

93 DeepSeek/xAI/Qwen) then the LiteLLM model cost map (covers the long 

94 tail including Groq and Mistral). 

95 

96 Example: 

97 .. code-block:: yaml 

98 

99 ai_llm: 

100 pricing: 

101 enabled: true 

102 cache_ttl: 43200 

103 enable_fuzzy_match: true 

104 sources: 

105 - type: openrouter 

106 - type: litellm 

107 - type: json 

108 file_path: pricing/private.json 

109 - type: static 

110 models: 

111 my-internal-model: 

112 prompt_per_1m: 0.25 

113 completion_per_1m: 0.75 

114 """ 

115 

116 enabled: bool = Field( 

117 default=True, 

118 description="Register pricing manager and cost estimator.", 

119 ) 

120 cache_ttl: int = Field( 

121 default=86400, 

122 ge=60, 

123 description="Pricing cache TTL in seconds (default: 24 hours).", 

124 ) 

125 enable_fuzzy_match: bool = Field( 

126 default=True, 

127 description="Allow substring matching of model names to prices.", 

128 ) 

129 sources: list[PricingSourceConfig] = Field( 

130 default_factory=list, 

131 description="Pricing sources in priority order.", 

132 ) 

133 

134 def build_sources(self) -> list[AbstractPricingSource]: 

135 """Build pricing source instances from this config. 

136 

137 Returns: 

138 Configured ``AbstractPricingSource`` instances in priority 

139 order. Empty sources list yields the defaults (OpenRouter 

140 then LiteLLM). 

141 

142 Raises: 

143 ValueError: On unknown source type or a ``json`` source 

144 without ``file_path``. 

145 """ 

146 from lexigram.ai.llm.pricing.sources import ( 

147 APIPricingSource, 

148 JSONFilePricingSource, 

149 OpenRouterPricingSource, 

150 StaticPricingSource, 

151 ) 

152 from lexigram.ai.llm.pricing.types import ModelPricing 

153 

154 litellm_url = ( 

155 "https://raw.githubusercontent.com/BerriAI/litellm/main/" 

156 "model_prices_and_context_window.json" 

157 ) 

158 

159 if not self.sources: 

160 return [ 

161 OpenRouterPricingSource(), 

162 APIPricingSource(litellm_url), 

163 ] 

164 

165 sources: list[AbstractPricingSource] = [] 

166 for cfg in self.sources: 

167 source_type = cfg.type.strip().lower() 

168 if source_type == "litellm": 

169 sources.append( 

170 APIPricingSource(cfg.endpoint or litellm_url, cfg.timeout) 

171 ) 

172 elif source_type == "openrouter": 

173 sources.append(OpenRouterPricingSource(cfg.endpoint, cfg.timeout)) 

174 elif source_type == "json": 

175 if not cfg.file_path: 

176 msg = "pricing source of type 'json' requires 'file_path'" 

177 raise ValueError(msg) 

178 sources.append(JSONFilePricingSource(Path(cfg.file_path))) 

179 elif source_type == "static": 

180 static: dict[str, ModelPricing] = {} 

181 for model_name, prices in cfg.models.items(): 

182 static[model_name] = ModelPricing( 

183 model=model_name, 

184 prompt_per_1m=float(prices.get("prompt_per_1m", 0.0)), 

185 completion_per_1m=float(prices.get("completion_per_1m", 0.0)), 

186 provider=str(prices.get("provider", "custom")), 

187 source="static:config", 

188 ) 

189 sources.append(StaticPricingSource(static)) 

190 else: 

191 msg = ( 

192 f"Unknown pricing source type {cfg.type!r}. " 

193 "Supported types: litellm, openrouter, json, static" 

194 ) 

195 raise ValueError(msg) 

196 return sources 

197 

198 

199@dataclass(init=False) 

200class ClientConfig(BaseConfig): 

201 """Configuration for LLM clients. 

202 

203 Example: 

204 >>> config = ClientConfig( 

205 ... provider="openai", 

206 ... model="gpt-4-turbo", 

207 ... api_key="sk-...", 

208 ... temperature=0.7, 

209 ... max_tokens=2000, 

210 ... ) 

211 """ 

212 

213 config_section: ClassVar[str] = "ai_llm" 

214 

215 model_config: ClassVar[ConfigDict] = ConfigDict( 

216 extra="ignore", 

217 arbitrary_types_allowed=True, 

218 ) 

219 

220 enabled: bool = Field(default=True, description="Enable the LLM subsystem") 

221 

222 provider: ModelProvider = Field( 

223 default=ModelProvider.OPENAI, 

224 description="LLM provider name.", 

225 ) 

226 model: str = Field(default="gpt-4-turbo", description="Model name or identifier.") 

227 model_revision: str | None = Field( 

228 default=None, 

229 description="Pinned model revision (provider-specific, e.g. date or version string).", 

230 ) 

231 pin_policy: ModelPinPolicy = Field( 

232 default=ModelPinPolicy.LATEST, 

233 description="Policy for enforcing the model revision pin.", 

234 ) 

235 api_key: SecretStr | None = Field( 

236 default=None, 

237 description="API key for the chosen provider.", 

238 ) 

239 api_base: str | None = Field( 

240 default=None, 

241 description="Custom API base URL (for Azure, local, or proxied endpoints).", 

242 ) 

243 temperature: float = Field( 

244 default=0.7, 

245 ge=0.0, 

246 le=2.0, 

247 description="Sampling temperature.", 

248 ) 

249 max_tokens: int | None = Field( 

250 default=None, 

251 ge=1, 

252 description="Maximum tokens in response.", 

253 ) 

254 timeout: float = Field( 

255 default=60.0, 

256 ge=1.0, 

257 description="Request timeout in seconds.", 

258 ) 

259 enable_cache: bool = Field( 

260 default=False, 

261 description="Enable response caching (requires CacheBackendProtocol in container).", 

262 ) 

263 cache_ttl: int = Field( 

264 default=3600, 

265 description="Cache TTL in seconds.", 

266 ) 

267 thinking: ThinkingConfig | None = Field( 

268 default=None, 

269 description=( 

270 "Thinking/reasoning configuration. ``None`` disables thinking. " 

271 "Set to a ``ThinkingConfig`` instance to enable provider-appropriate " 

272 "reasoning output (Anthropic extended thinking, Gemini thinking, " 

273 "OpenAI reasoning effort, Bedrock Claude reasoning, OpenRouter reasoning)." 

274 ), 

275 ) 

276 extra: dict[str, Any] = Field( 

277 default_factory=dict, 

278 description="Provider-specific extra parameters passed verbatim.", 

279 ) 

280 pricing: PricingConfig | None = Field( 

281 default=None, 

282 description=( 

283 "Pricing source configuration. When set, registers a pricing " 

284 "manager and a CostEstimatorProtocol in the container so agents " 

285 "get USD cost estimates. See PricingConfig for the YAML schema." 

286 ), 

287 ) 

288 

289 def __post_init__(self) -> None: 

290 """Coerce provider string to ModelProvider enum and api_key to SecretStr.""" 

291 if isinstance(self.provider, str): 

292 self.provider = ModelProvider(self.provider) 

293 api_key: Any = self.api_key 

294 if api_key is not None and not isinstance(api_key, SecretStr): 

295 self.api_key = SecretStr(api_key) 

296 

297 

298__all__ = ["ClientConfig", "PricingConfig", "PricingSourceConfig"]