Coverage for /home/admin/Documents/AI/applications/lexigram-dev/lexigram/experimental/ai/lexigram-ai-llm/src/lexigram/ai/llm/clients/base.py: 29%

161 statements  

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

1"""Base class for Lexigram LLM clients. 

2 

3Provides common retry, exponential backoff, circuit breaker, and token limit 

4checks for all LLM providers. Subclasses implement provider-specific logic. 

5""" 

6 

7from __future__ import annotations 

8 

9from abc import ABC, abstractmethod 

10import asyncio 

11from collections.abc import AsyncIterator 

12import types 

13from typing import Any, Protocol, Self, cast 

14 

15from lexigram.ai.llm.config import ClientConfig 

16from lexigram.ai.llm.exceptions import ( 

17 LLMAuthenticationError, 

18 LLMError, 

19 LLMRateLimitError, 

20 LLMTimeoutError, 

21) 

22from lexigram.ai.llm.health import ProviderCircuitBreaker 

23from lexigram.ai.llm.thinking import normalize_thinking_text 

24from lexigram.ai.llm.types import ( 

25 ChatMessage, 

26 Completion, 

27 StreamChunk, 

28 ToolCall, 

29) 

30from lexigram.contracts.ai.thinking import ThinkingResult 

31from lexigram.contracts.core.health import HealthCheckResult 

32from lexigram.contracts.infra import AsyncStream 

33from lexigram.logging import ( 

34 get_logger, 

35) 

36from lexigram.result import Err, Ok, Result 

37 

38logger = get_logger(__name__) 

39 

40 

41class _ClientCircuitBreaker(Protocol): 

42 async def is_available(self) -> bool: ... 

43 async def record_success(self) -> None: ... 

44 async def record_failure(self, error: Exception) -> None: ... 

45 

46 

47class AbstractLLMClient(ABC): 

48 """Base class for all LLM client implementations. 

49 

50 Provides common retry, backoff, circuit breaker, and pre-flight token 

51 checking. Subclasses implement only provider-specific API calls and 

52 response parsing via `_do_complete` and `_do_stream_chat`. 

53 """ 

54 

55 def __init__( 

56 self, 

57 config: ClientConfig, 

58 max_retries: int = 3, 

59 circuit_breaker: _ClientCircuitBreaker | None = None, 

60 ): 

61 """Initialize the base client with config. 

62 

63 Args: 

64 config: LLM configuration object. 

65 max_retries: Maximum number of retries for transient errors. 

66 circuit_breaker: Optional circuit breaker for resilience. 

67 If not provided, creates a default ProviderCircuitBreaker. 

68 """ 

69 self.config = config 

70 self.max_retries = max_retries 

71 self._circuit_breaker = circuit_breaker or ProviderCircuitBreaker( 

72 provider_name=config.provider.value 

73 ) 

74 self._closed = False 

75 

76 async def complete( 

77 self, 

78 messages: list[ChatMessage], 

79 **kwargs: Any, 

80 ) -> Result[Completion, LLMError]: 

81 """Template method: retry + circuit breaker + pre-flight + subclass.""" 

82 

83 if not await self._circuit_breaker.is_available(): 

84 return Err(LLMError("Circuit breaker is open due to recent failures.")) 

85 

86 if await self._exceeds_token_limit(messages, **kwargs): 

87 return Err(LLMError("Request exceeds model maximum context window.")) 

88 

89 for attempt in range(self.max_retries + 1): 

90 try: 

91 result = await self._do_complete(messages, **kwargs) 

92 if result.is_ok(): 

93 await self._circuit_breaker.record_success() 

94 return Ok(self._normalize_thinking(result.unwrap())) 

95 

96 err = result.unwrap_err() 

97 if self._is_retryable_error(err): 

98 await self._circuit_breaker.record_failure(err) 

99 if attempt < self.max_retries: 

100 await self._backoff(attempt) 

101 continue 

102 

103 return result # Non-retryable or max retries reached 

104 

105 except Exception as e: 

106 await self._circuit_breaker.record_failure(e) 

107 if not self._is_retryable_exception(e) or attempt == self.max_retries: 

108 raise 

109 await self._backoff(attempt) 

110 

111 return Err(LLMError("Max retries exceeded")) 

112 

113 def stream_chat( 

114 self, 

115 messages: list[ChatMessage], 

116 **kwargs: Any, 

117 ) -> AsyncStream[StreamChunk, LLMError]: 

118 """Template method for streaming chat with retry + circuit breaker. 

119 

120 Returns an ``AsyncStream`` immediately. All setup, retry, and 

121 circuit-breaker logic is executed lazily inside the stream's async 

122 generator. Setup failures and mid-stream failures are both surfaced 

123 through ``AsyncStream``'s typed error channel. 

124 """ 

125 

126 async def _establish_stream() -> AsyncIterator[StreamChunk]: 

127 """Async generator that handles setup, retry, and streaming.""" 

128 # Check circuit breaker 

129 if not await self._circuit_breaker.is_available(): 

130 raise LLMError("Circuit breaker is open due to recent failures.") 

131 

132 # Check token limit 

133 if await self._exceeds_token_limit(messages, **kwargs): 

134 raise LLMError("Request exceeds model maximum context window.") 

135 

136 # Retry loop for setup 

137 for attempt in range(self.max_retries + 1): 

138 try: 

139 result = await self._do_stream_chat(messages, **kwargs) 

140 if result.is_ok(): 

141 await self._circuit_breaker.record_success() 

142 stream_iter = result.unwrap() 

143 # Stream the chunks with timeout 

144 async for chunk in self._timeout_stream( 

145 stream_iter, self.config.timeout / 2 

146 ): 

147 yield chunk 

148 return 

149 

150 err = result.unwrap_err() 

151 if self._is_retryable_error(err): 

152 await self._circuit_breaker.record_failure(err) 

153 if attempt < self.max_retries: 

154 await self._backoff(attempt) 

155 continue 

156 

157 raise err 

158 

159 except Exception as e: 

160 await self._circuit_breaker.record_failure(e) 

161 if ( 

162 not self._is_retryable_exception(e) 

163 or attempt == self.max_retries 

164 ): 

165 raise 

166 await self._backoff(attempt) 

167 

168 raise LLMError("Max retries exceeded") 

169 

170 return AsyncStream( 

171 _establish_stream(), 

172 error_adapter=self._coerce_stream_error, 

173 ) 

174 

175 async def chat( 

176 self, 

177 messages: list[ChatMessage], 

178 tools: list[ToolCall] | None = None, 

179 **kwargs: Any, 

180 ) -> Result[Completion, LLMError]: 

181 """Template method: completion with tools.""" 

182 

183 if not await self._circuit_breaker.is_available(): 

184 return Err(LLMError("Circuit breaker is open due to recent failures.")) 

185 

186 if await self._exceeds_token_limit(messages, **kwargs): 

187 return Err(LLMError("Request exceeds model maximum context window.")) 

188 

189 for attempt in range(self.max_retries + 1): 

190 try: 

191 result = await self._do_chat(messages, tools, **kwargs) 

192 if result.is_ok(): 

193 await self._circuit_breaker.record_success() 

194 return Ok(self._normalize_thinking(result.unwrap())) 

195 

196 err = result.unwrap_err() 

197 if self._is_retryable_error(err): 

198 await self._circuit_breaker.record_failure(err) 

199 if attempt < self.max_retries: 

200 await self._backoff(attempt) 

201 continue 

202 

203 return result 

204 

205 except Exception as e: 

206 await self._circuit_breaker.record_failure(e) 

207 if not self._is_retryable_exception(e) or attempt == self.max_retries: 

208 raise 

209 await self._backoff(attempt) 

210 

211 return Err(LLMError("Max retries exceeded")) 

212 

213 async def _timeout_stream( 

214 self, stream: AsyncIterator[StreamChunk], chunk_timeout: float 

215 ) -> AsyncIterator[StreamChunk]: 

216 """Wrap an async iterator with a per-chunk timeout.""" 

217 try: 

218 while True: 

219 chunk = await asyncio.wait_for(anext(stream), timeout=chunk_timeout) 

220 yield chunk 

221 except StopAsyncIteration: 

222 return 

223 except TimeoutError: 

224 logger.warning("Stream chunk timed out after %s seconds", chunk_timeout) 

225 raise LLMError("Streaming timed out between chunks") 

226 

227 def _coerce_stream_error(self, exc: Exception) -> LLMError: 

228 """Convert iterator failures into typed stream errors.""" 

229 if isinstance(exc, LLMError): 

230 return exc 

231 return LLMError(str(exc)) 

232 

233 def _is_retryable_error(self, err: LLMError) -> bool: 

234 """Determine if a returned error should trigger a retry. 

235 

236 Rate limits and request timeouts are NOT retried: the router's 

237 cascade advances to the next entry instead of hammering a throttled 

238 or overloaded model in place (a timeout retry costs another full 

239 timeout window). 

240 """ 

241 if isinstance(err, (LLMRateLimitError, LLMTimeoutError)): 

242 return False 

243 if "connection" in str(err).lower() or "timeout" in str(err).lower(): 

244 return True 

245 return "503" in str(err) or "502" in str(err) or "504" in str(err) 

246 

247 def _is_retryable_exception(self, e: Exception) -> bool: 

248 """Determine if a raised exception should trigger a retry. 

249 

250 Rate limits and request timeouts fail fast (cascade advancement 

251 handles them); authentication and validation errors are permanent. 

252 """ 

253 if isinstance(e, (LLMRateLimitError, LLMTimeoutError)): 

254 return False 

255 if isinstance(e, LLMAuthenticationError): 

256 return False 

257 return not ("invalid" in str(e).lower() or "not found" in str(e).lower()) 

258 

259 async def _backoff(self, attempt: int) -> None: 

260 """Apply exponential backoff before the next retry.""" 

261 delay = (2**attempt) + (0.1 * attempt) # basic jitter 

262 logger.info("Applying backoff of %.2fs before retry %d", delay, attempt + 1) 

263 await asyncio.sleep(delay) 

264 

265 async def _exceeds_token_limit( 

266 self, messages: list[ChatMessage], **kwargs: Any 

267 ) -> bool: 

268 """Pre-flight check: return True if messages exceed the configured context window. 

269 

270 Uses :class:`~lexigram.ai.llm.pricing.tokens.TiktokenCounter` for accurate 

271 model-specific counting (falls back to character estimation when tiktoken is 

272 unavailable). The check is skipped if ``context_window`` is not set in 

273 ``config.extra``. 

274 

275 Args: 

276 messages: Chat messages to count. 

277 **kwargs: Ignored; accepted for call-site compatibility. 

278 

279 Returns: 

280 ``True`` when the token count exceeds the context window limit, 

281 ``False`` otherwise (including when no limit is configured). 

282 """ 

283 context_window: int | None = self.config.extra.get("context_window") 

284 if context_window is None: 

285 return False 

286 

287 from lexigram.ai.llm.pricing.tokens import TiktokenCounter 

288 

289 counter = TiktokenCounter(model=self.config.model) 

290 token_count = counter.count_messages(messages) 

291 return token_count > context_window 

292 

293 async def close(self) -> None: 

294 """Close the client and cleanup resources.""" 

295 self._closed = True 

296 

297 def _apply_thinking(self, params: dict[str, Any]) -> None: 

298 """Inject provider-specific thinking parameters into an API payload dict. 

299 

300 Called from ``_do_complete``, ``_do_stream_chat``, and ``_do_chat`` in 

301 each provider immediately after the base payload is assembled and before 

302 the API call is made. The default implementation is a no-op so providers 

303 without thinking support require no change. 

304 

305 Override in providers that support extended thinking/reasoning to avoid 

306 repeating the injection logic across all three call-path methods. 

307 

308 Args: 

309 params: Mutable API payload dict that will be sent to the provider. 

310 """ 

311 

312 def _normalize_thinking(self, completion: Completion) -> Completion: 

313 """Strip inline thinking tags from completion content and populate ThinkingResult. 

314 

315 This method is a universal post-processing step applied in the 

316 ``complete()`` and ``chat()`` template methods to ALL clients. It handles 

317 models that embed reasoning tokens directly in their text output using 

318 known tag formats (Qwen3 ``<think>``, Gemma-4 ``<|channel>thought``, etc.). 

319 

320 Guard: if ``completion.thinking`` is already set (Anthropic, Gemini, Bedrock, 

321 OpenRouter all extract thinking natively from structured API fields), this 

322 method returns the completion unchanged — normalization is a no-op for them. 

323 

324 Args: 

325 completion: Raw completion from ``_do_complete`` or ``_do_chat``. 

326 

327 Returns: 

328 A new Completion with clean ``content`` and populated ``thinking``, 

329 or the original unchanged if no inline thinking was detected. 

330 """ 

331 if completion.thinking is not None: 

332 return completion 

333 

334 clean_content, thinking_text = normalize_thinking_text(completion.content) 

335 if thinking_text is None: 

336 return completion 

337 

338 return cast( 

339 "Completion", 

340 completion.model_copy( 

341 update={ 

342 "content": clean_content, 

343 "thinking": ThinkingResult(content=thinking_text), 

344 } 

345 ), 

346 ) 

347 

348 @abstractmethod 

349 async def _do_complete( 

350 self, 

351 messages: list[ChatMessage], 

352 **kwargs: Any, 

353 ) -> Result[Completion, LLMError]: 

354 """Provider-specific complete implementation.""" 

355 

356 @abstractmethod 

357 async def _do_stream_chat( 

358 self, 

359 messages: list[ChatMessage], 

360 **kwargs: Any, 

361 ) -> Result[AsyncIterator[StreamChunk], LLMError]: 

362 """Provider-specific streaming implementation.""" 

363 

364 @abstractmethod 

365 async def _do_chat( 

366 self, 

367 messages: list[ChatMessage], 

368 tools: list[ToolCall] | None = None, 

369 **kwargs: Any, 

370 ) -> Result[Completion, LLMError]: 

371 """Provider-specific chat (with tools) implementation.""" 

372 

373 @abstractmethod 

374 async def health_check(self, timeout: float = 5.0) -> HealthCheckResult: 

375 """Perform health check against the provider.""" 

376 

377 async def __aenter__(self) -> Self: 

378 return self 

379 

380 async def __aexit__( 

381 self, 

382 exc_type: type[BaseException] | None, 

383 exc_val: BaseException | None, 

384 exc_tb: types.TracebackType | None, 

385 ) -> None: 

386 await self.close()