Coverage for /home/admin/Documents/AI/applications/lexigram-dev/lexigram/experimental/ai/lexigram-ai-llm/src/lexigram/ai/llm/routing/router.py: 17%

128 statements  

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

1"""Multi-provider LLM router. 

2 

3Cascades inference requests across configured providers in priority order. 

4Returns ``Result[InferenceLog, InferenceError]`` for explicit error handling. 

5 

6The routing *strategy* is pluggable — see :mod:`.strategies` for the 

7``RoutingStrategyProtocol`` protocol and built-in implementations. 

8""" 

9 

10from __future__ import annotations 

11 

12from typing import TYPE_CHECKING, Any 

13 

14from lexigram.ai.llm.routing.strategies import ( 

15 CostOptimizedStrategy, 

16 LatencyOptimizedStrategy, 

17 ParallelRaceStrategy, 

18 RoutingStrategyProtocol, 

19 SequentialCascadeStrategy, 

20) 

21from lexigram.ai.llm.routing.types import ( 

22 InferenceError, 

23 InferenceLog, 

24 InferenceResult, 

25) 

26from lexigram.contracts.web import HttpStatusError 

27from lexigram.logging import ( 

28 get_logger, 

29) 

30from lexigram.result import Err, Ok, Result 

31 

32if TYPE_CHECKING: 

33 from lexigram.ai.llm.routing.config import LLMConfig 

34 from lexigram.ai.llm.selection.core import ModelSelector 

35 from lexigram.contracts.ai import LLMClientProtocol 

36 from lexigram.contracts.ai.providers import ProviderRegistryProtocol 

37 from lexigram.contracts.ai.routing import ( 

38 InferenceLoggerProtocol, 

39 QuotaBackendProtocol, 

40 ) 

41 

42logger = get_logger(__name__) 

43 

44__all__ = ["LLMRouter"] 

45 

46 

47class LLMRouter: 

48 """Multi-provider LLM router with pluggable strategy. 

49 

50 Delegates to a ``RoutingStrategyProtocol`` for provider selection. 

51 Returns ``Result[InferenceLog, InferenceError]``. 

52 

53 Example: 

54 >>> config = LLMConfig.from_env() 

55 >>> router = LLMRouter(clients=clients, quota_backend=backend, ...) 

56 >>> result = await router.route(messages=[{"role": "user", "content": "Hi"}]) 

57 >>> if result.is_ok(): 

58 ... print(result.unwrap().result.content) 

59 """ 

60 

61 def __init__( 

62 self, 

63 clients: dict[str, LLMClientProtocol], 

64 quota_backend: QuotaBackendProtocol, 

65 inference_logger: InferenceLoggerProtocol, 

66 config: LLMConfig, 

67 strategy: RoutingStrategyProtocol | None = None, 

68 model_selector: ModelSelector | None = None, 

69 provider_registry: ProviderRegistryProtocol | None = None, 

70 ) -> None: 

71 """Initialise the router. 

72 

73 Args: 

74 clients: Mapping of provider name to its ``LLMClientProtocol``. 

75 quota_backend: Quota tracking backend. 

76 inference_logger: Inference log persistence backend. 

77 config: Full routing configuration. 

78 strategy: Routing strategy; defaults to 

79 :class:`SequentialCascadeStrategy`. 

80 model_selector: Optional model selector for capability-based 

81 filtering. When set, ``required_capabilities`` in kwargs 

82 are used to exclude providers whose models lack the 

83 requested capabilities. 

84 provider_registry: Optional provider registry from 

85 ``lexigram-ai-providers``. When set, additional clients 

86 registered in the provider registry are merged into the 

87 routing pool. 

88 """ 

89 self._clients = clients 

90 self._quota = quota_backend 

91 self._logger = inference_logger 

92 self._config = config 

93 self._strategy: RoutingStrategyProtocol = ( 

94 strategy or self._strategy_from_config(config) 

95 ) 

96 self._model_selector = model_selector 

97 self._provider_registry = provider_registry 

98 self._closed = False 

99 

100 # Merge provider registry clients into routing pool 

101 if self._provider_registry: 

102 self._merge_registry_clients() 

103 

104 @property 

105 def strategy(self) -> RoutingStrategyProtocol: 

106 """Return the active routing strategy.""" 

107 return self._strategy 

108 

109 @strategy.setter 

110 def strategy(self, value: RoutingStrategyProtocol) -> None: 

111 """Hot-swap the routing strategy at runtime.""" 

112 self._strategy = value 

113 

114 def _merge_registry_clients(self) -> None: 

115 """Merge clients from the provider registry into the routing pool. 

116 

117 Only adds clients for providers not already in the pool, avoiding 

118 duplicates. This is called once at construction time. 

119 """ 

120 if self._provider_registry is None: 

121 return 

122 try: 

123 for name in self._provider_registry.list_providers(): 

124 if name not in self._clients: 

125 # get_client is async but we can't await here; defer to route() 

126 logger.debug( 

127 "router: provider_registry has additional provider %s", 

128 name, 

129 ) 

130 except (RuntimeError, TypeError, AttributeError) as e: 

131 logger.warning("router: failed to merge registry clients", error=str(e)) 

132 

133 async def route( 

134 self, 

135 messages: list[Any], 

136 **kwargs: Any, 

137 ) -> Result[InferenceLog, InferenceError]: 

138 """Route a completion request across configured providers. 

139 

140 Args: 

141 messages: OpenAI-compatible message list. 

142 **kwargs: Generation overrides forwarded to client (temperature, 

143 max_tokens, model, etc.). 

144 

145 Returns: 

146 ``Ok(InferenceLog)`` on success, ``Err(InferenceError)`` when 

147 every provider (including paid fallback) is exhausted or fails. 

148 """ 

149 result, providers_tried, total_attempts = await self._strategy.execute( 

150 providers=self._filter_by_capabilities( 

151 self._config.providers, 

152 kwargs, 

153 ), 

154 clients=self._clients, 

155 quota=self._quota, 

156 config=self._config, 

157 messages=messages, 

158 kwargs=kwargs, 

159 ) 

160 

161 if result is not None: 

162 log = InferenceLog( 

163 result=result, 

164 providers_tried=providers_tried, 

165 total_attempts=total_attempts, 

166 ) 

167 await self._logger.log(log) 

168 return Ok(log) 

169 

170 terminal_error = InferenceError( 

171 message="All LLM providers exhausted or unavailable", 

172 providers_tried=providers_tried, 

173 ) 

174 log = InferenceLog( 

175 error=terminal_error, 

176 providers_tried=providers_tried, 

177 total_attempts=total_attempts, 

178 ) 

179 await self._logger.log(log) 

180 return Err(terminal_error) 

181 

182 async def health_probe(self) -> Result[InferenceLog, InferenceError]: 

183 """Probe configured providers without performing normal inference. 

184 

185 Returns: 

186 ``Ok(InferenceLog)`` for the first healthy enabled provider. 

187 The returned log is synthetic and is **not** persisted to the 

188 inference logger. ``Err(InferenceError)`` is returned when no 

189 healthy provider can be found. 

190 """ 

191 providers_tried: list[str] = [] 

192 total_attempts = 0 

193 

194 for provider_config in self._config.providers: 

195 if not provider_config.enabled: 

196 continue 

197 

198 client = self._clients.get(provider_config.key) 

199 if client is None: 

200 continue 

201 

202 health_check = getattr(client, "health_check", None) 

203 if not callable(health_check): 

204 continue 

205 

206 providers_tried.append(provider_config.name) 

207 total_attempts += 1 

208 try: 

209 health_result = await health_check(timeout=5.0) 

210 except ( 

211 OSError, 

212 ConnectionError, 

213 TimeoutError, 

214 RuntimeError, 

215 HttpStatusError, 

216 ) as exc: 

217 logger.debug( 

218 "llm.router: health probe provider check failed", 

219 provider=provider_config.name, 

220 error=str(exc), 

221 ) 

222 continue 

223 

224 try: 

225 is_healthy = health_result.is_healthy() 

226 except (AttributeError, TypeError) as exc: 

227 logger.debug( 

228 "llm.router: health probe provider check failed", 

229 provider=provider_config.name, 

230 error=str(exc), 

231 ) 

232 continue 

233 

234 if is_healthy: 

235 try: 

236 duration_ms = health_result.duration_ms 

237 except (AttributeError, TypeError) as exc: 

238 logger.debug( 

239 "llm.router: health probe provider check failed", 

240 provider=provider_config.name, 

241 error=str(exc), 

242 ) 

243 continue 

244 

245 return Ok( 

246 InferenceLog( 

247 result=InferenceResult( 

248 provider=provider_config.name, 

249 model=provider_config.model, 

250 content="health_probe", 

251 attempt=total_attempts, 

252 prompt_tokens=0, 

253 completion_tokens=0, 

254 latency_ms=duration_ms, 

255 ), 

256 providers_tried=providers_tried, 

257 total_attempts=total_attempts, 

258 context={"health_probe": True}, 

259 ) 

260 ) 

261 

262 try: 

263 status = str(health_result.status) 

264 message = health_result.message 

265 error = health_result.error 

266 except (AttributeError, TypeError) as exc: 

267 logger.debug( 

268 "llm.router: health probe provider check failed", 

269 provider=provider_config.name, 

270 error=str(exc), 

271 ) 

272 continue 

273 

274 logger.debug( 

275 "llm.router: health probe provider unhealthy", 

276 provider=provider_config.name, 

277 status=status, 

278 message=message, 

279 error=error, 

280 ) 

281 

282 return Err( 

283 InferenceError( 

284 message="No healthy LLM providers available for health probe", 

285 providers_tried=providers_tried, 

286 ) 

287 ) 

288 

289 async def close(self) -> None: 

290 """Close all registered LLM clients and release connections. 

291 

292 Iterates over every client in the provider pool and calls ``close()`` 

293 if the method exists. Safe to call multiple times. 

294 """ 

295 if self._closed: 

296 return 

297 self._closed = True 

298 for name, client in self._clients.items(): 

299 closer = getattr(client, "close", None) 

300 if closer is not None: 

301 try: 

302 await closer() 

303 logger.debug("llm.router: closed client %s", name) 

304 except Exception as e: 

305 logger.warning( 

306 "llm.router: error closing client", 

307 client=name, 

308 error=str(e), 

309 ) 

310 

311 def _filter_by_capabilities( 

312 self, 

313 providers: list[Any], 

314 kwargs: dict[str, Any], 

315 ) -> list[Any]: 

316 """Filter providers whose models lack required capabilities. 

317 

318 When ``required_capabilities`` is present in *kwargs* and a 

319 ``ModelSelector`` is configured, only providers whose primary 

320 model satisfies **all** requested capabilities are retained. 

321 Providers whose model is unknown to the selector are kept 

322 (fail-open) so the strategy can still attempt them. 

323 

324 Returns: 

325 Filtered provider list (original list when no filtering applies). 

326 """ 

327 required: list[str] | None = kwargs.get("required_capabilities") 

328 if not required or self._model_selector is None: 

329 return providers 

330 

331 capable_models = set( 

332 self._model_selector._filter_by_capabilities(required), 

333 ) 

334 

335 filtered = [] 

336 for pcfg in providers: 

337 model = pcfg.model 

338 # Keep providers whose model is in the capable set or unknown 

339 # to the selector (fail-open avoids silently dropping custom models). 

340 if ( 

341 model in capable_models 

342 or model not in self._model_selector.model_capabilities 

343 ): 

344 filtered.append(pcfg) 

345 else: 

346 logger.debug( 

347 "router: skipping provider %s (model=%s) — lacks capabilities %s", 

348 pcfg.name, 

349 model, 

350 required, 

351 ) 

352 

353 # If filtering removed everything, fall back to original list to 

354 # avoid a guaranteed "all providers exhausted" error. 

355 if not filtered: 

356 logger.warning( 

357 "router: no providers match required_capabilities %s; " 

358 "falling back to full provider list", 

359 required, 

360 ) 

361 return providers 

362 

363 return filtered 

364 

365 @staticmethod 

366 def _strategy_from_config(config: LLMConfig) -> RoutingStrategyProtocol: 

367 """Build a :class:`RoutingStrategyProtocol` from the ``strategy`` config field. 

368 

369 Falls back to :class:`SequentialCascadeStrategy` for unknown values. 

370 ``CostOptimizedStrategy`` requires a ``PricingManager``; when none 

371 is available it falls back to sequential with a warning. 

372 """ 

373 name = getattr(config, "strategy", "sequential") 

374 if name == "parallel_race": 

375 return ParallelRaceStrategy() 

376 if name == "cost_optimized": 

377 try: 

378 from lexigram.ai.llm.pricing import PricingManager 

379 

380 return CostOptimizedStrategy(PricingManager.from_defaults()) 

381 except (ImportError, RuntimeError, AttributeError, ValueError): 

382 logger.warning( 

383 "cost_optimized strategy requested but PricingManager " 

384 "unavailable; falling back to sequential", 

385 ) 

386 return SequentialCascadeStrategy() 

387 if name == "latency_optimized": 

388 return LatencyOptimizedStrategy() 

389 return SequentialCascadeStrategy()