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

143 statements  

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

1"""Mistral AI provider for European-based LLM inference. 

2 

3Mistral AI is a European AI company providing high-performance LLMs with GDPR compliance 

4and data sovereignty guarantees. Their models excel at multilingual tasks and reasoning. 

5 

6Supported Models: 

7- mistral-large-latest: Most capable model for complex tasks 

8- mistral-medium-latest: Balanced performance and cost 

9- mistral-small-latest: Fast, cost-effective model 

10- open-mixtral-8x22b: Large open-source MoE model 

11- open-mixtral-8x7b: Mid-size open-source MoE model 

12- open-mistral-7b: Compact open-source model 

13 

14Features: 

15- GDPR compliant (EU-based) 

16- Strong multilingual support 

17- Function calling (select models) 

18- JSON mode for structured output 

19- Embeddings (mistral-embed) 

20 

21Example: 

22 >>> from lexigram.ai.llm import MistralClient 

23 >>> 

24 >>> async with MistralClient(api_key="...") as client: 

25 ... response = await client.complete( 

26 ... model="mistral-large-latest", 

27 ... messages=[{"role": "user", "content": "Bonjour!"}] 

28 ... ) 

29 ... print(response.content) 

30 

31API Documentation: https://docs.mistral.ai 

32 

33""" 

34 

35from __future__ import annotations 

36 

37from collections.abc import AsyncIterator 

38from typing import Any, cast 

39 

40import aiohttp 

41 

42from lexigram.ai.llm.clients._message_utils import serialize_content_for_openai 

43from lexigram.ai.llm.clients._tools_utils import ( 

44 parse_openai_tool_calls, 

45 serialize_message_for_openai, 

46 tool_to_openai_format, 

47) 

48from lexigram.ai.llm.clients.base import AbstractLLMClient 

49from lexigram.ai.llm.config import ClientConfig 

50from lexigram.ai.llm.exceptions import ( 

51 LLMAuthenticationError, 

52 LLMError, 

53 LLMModelNotFoundError, 

54 LLMQuotaExceededError, 

55 LLMRateLimitError, 

56) 

57from lexigram.ai.llm.http.client import ResilientHTTPClient 

58from lexigram.ai.llm.types import ( 

59 AIError, 

60 ChatMessage, 

61 Completion, 

62 StreamChunk, 

63 TokenUsage, 

64 ToolCall, 

65) 

66from lexigram.contracts.core.health import HealthCheckResult, HealthStatus 

67from lexigram.contracts.web.http_models import HttpStatusError 

68from lexigram.logging import ( 

69 get_logger, 

70) 

71from lexigram.result import Err, Ok, Result 

72from lexigram.serialization import loads 

73from lexigram.validation import SecretStr 

74 

75logger = get_logger(__name__) 

76 

77 

78class MistralClient(AbstractLLMClient): 

79 """Client for Mistral AI's LLM API. 

80 

81 Conforms to: :class:`~lexigram.contracts.ai.LLMClientProtocol` protocol via structural typing. 

82 

83 Supports Chat, Stream, and Embeddings with: 

84 - High-performance European LLMs 

85 - GDPR compliance and data sovereignty 

86 - Function calling and JSON mode 

87 """ 

88 

89 def __init__(self, config: ClientConfig): 

90 """Initialize Mistral client. 

91 

92 Args: 

93 config: LLM configuration 

94 """ 

95 super().__init__(config=config, max_retries=config.extra.get("max_retries", 3)) 

96 self._client: ResilientHTTPClient | None = None 

97 

98 @property 

99 def api_key(self) -> SecretStr: 

100 """Get API key from config.""" 

101 return self.config.api_key or SecretStr("") 

102 

103 @property 

104 def base_url(self) -> str: 

105 """Get base URL from config.""" 

106 return self.config.api_base or "https://api.mistral.ai/v1" 

107 

108 def _get_client(self) -> ResilientHTTPClient: 

109 """Get or create HTTP client. 

110 

111 Returns: 

112 HTTP client instance. 

113 """ 

114 if self._client is None: 

115 self._client = ResilientHTTPClient( 

116 base_url=self.base_url, 

117 headers={ 

118 "Authorization": f"Bearer {self.api_key.get_secret_value()}", 

119 "Content-Type": "application/json", 

120 }, 

121 timeout=self.config.timeout, 

122 name="mistral-client", 

123 ) 

124 return self._client 

125 

126 async def _do_complete( 

127 self, 

128 messages: list[ChatMessage], 

129 **kwargs: Any, 

130 ) -> Result[Completion, LLMError]: 

131 """Provider-specific non-streaming completion. 

132 

133 Args: 

134 messages: Chat messages (ChatMessage or dict). 

135 **kwargs: Forwarded kwargs (model, temperature, max_tokens, tools, response_format, etc.). 

136 

137 Returns: 

138 ``Ok(Completion)`` on success or ``Err(LLMError)`` for recoverable failures. 

139 """ 

140 try: 

141 client = self._get_client() 

142 model = kwargs.pop("model", self.config.model) 

143 temperature = kwargs.pop("temperature", self.config.temperature) 

144 max_tokens = kwargs.pop("max_tokens", self.config.max_tokens) 

145 tools = kwargs.pop("tools", None) 

146 response_format = kwargs.pop("response_format", None) 

147 

148 message_dicts: list[dict[str, Any]] = [] 

149 for msg in cast("list[ChatMessage | dict[str, Any]]", messages): 

150 if isinstance(msg, ChatMessage): 

151 message_dicts.append(serialize_message_for_openai(msg)) 

152 else: 

153 message_dicts.append(msg) 

154 

155 payload: dict[str, Any] = { 

156 "model": model, 

157 "messages": message_dicts, 

158 "temperature": temperature, 

159 "stream": False, 

160 **kwargs, 

161 } 

162 if max_tokens: 

163 payload["max_tokens"] = max_tokens 

164 if tools: 

165 payload["tools"] = [ 

166 converted 

167 for tool in tools 

168 if (converted := tool_to_openai_format(tool)) is not None 

169 ] 

170 if response_format: 

171 payload["response_format"] = response_format 

172 

173 response = await client.post("/chat/completions", json=payload) 

174 response.raise_for_status() 

175 data: Any = response.json() 

176 

177 choice = data["choices"][0] 

178 message = choice["message"] 

179 

180 tool_calls = parse_openai_tool_calls(message.get("tool_calls")) 

181 

182 return Ok( 

183 Completion( 

184 content=message.get("content", ""), 

185 model=model, 

186 finish_reason=choice.get("finish_reason", "stop"), 

187 usage=TokenUsage( 

188 prompt_tokens=data["usage"]["prompt_tokens"], 

189 completion_tokens=data["usage"]["completion_tokens"], 

190 total_tokens=data["usage"]["total_tokens"], 

191 ), 

192 tool_calls=tool_calls, 

193 ) 

194 ) 

195 except ( 

196 HttpStatusError, 

197 aiohttp.ClientError, 

198 OSError, 

199 KeyError, 

200 ValueError, 

201 ) as e: 

202 return self._handle_error_as_result(e) 

203 

204 async def _do_stream_chat( 

205 self, 

206 messages: list[ChatMessage], 

207 **kwargs: Any, 

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

209 """Provider-specific streaming implementation. 

210 

211 Args: 

212 messages: Chat messages (ChatMessage or dict). 

213 **kwargs: Forwarded kwargs (model, temperature, max_tokens, tools, etc.). 

214 

215 Returns: 

216 ``Ok(AsyncIterator[StreamChunk])`` on success or ``Err(LLMError)`` on failure. 

217 """ 

218 try: 

219 client = self._get_client() 

220 model = kwargs.pop("model", self.config.model) 

221 temperature = kwargs.pop("temperature", self.config.temperature) 

222 max_tokens = kwargs.pop("max_tokens", self.config.max_tokens) 

223 tools = kwargs.pop("tools", None) 

224 response_format = kwargs.pop("response_format", None) 

225 

226 message_dicts: list[dict[str, Any]] = [] 

227 for msg in cast("list[ChatMessage | dict[str, Any]]", messages): 

228 if isinstance(msg, ChatMessage): 

229 message_dicts.append( 

230 { 

231 "role": msg.role.value, 

232 "content": serialize_content_for_openai(msg.content), 

233 } 

234 ) 

235 else: 

236 message_dicts.append(msg) 

237 

238 payload: dict[str, Any] = { 

239 "model": model, 

240 "messages": message_dicts, 

241 "temperature": temperature, 

242 "stream": True, 

243 **kwargs, 

244 } 

245 if max_tokens: 

246 payload["max_tokens"] = max_tokens 

247 if tools: 

248 payload["tools"] = tools 

249 if response_format: 

250 payload["response_format"] = response_format 

251 

252 return Ok(self._stream_completion(client, payload)) 

253 except (HttpStatusError, aiohttp.ClientError, OSError, ValueError) as e: 

254 return self._handle_error_as_result(e) 

255 

256 async def _do_chat( 

257 self, 

258 messages: list[ChatMessage], 

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

260 **kwargs: Any, 

261 ) -> Result[Completion, LLMError]: 

262 """Provider-specific chat with tool support. 

263 

264 Args: 

265 messages: Chat messages. 

266 tools: Optional tool definitions. 

267 **kwargs: Additional parameters. 

268 

269 Returns: 

270 ``Ok(Completion)`` on success or ``Err(LLMError)`` on failure. 

271 """ 

272 return await self._do_complete(messages, tools=tools, **kwargs) 

273 

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

275 """Perform a lightweight health check against the Mistral API. 

276 

277 Calls the models endpoint to verify the API key is valid and the 

278 service is reachable. 

279 

280 Args: 

281 timeout: Maximum seconds to wait for the response. 

282 

283 Returns: 

284 :class:`~lexigram.contracts.core.health.HealthCheckResult`. 

285 """ 

286 try: 

287 client = self._get_client() 

288 response = await client.get("/models") 

289 response.raise_for_status() 

290 return HealthCheckResult( 

291 component="llm.mistral", 

292 status=HealthStatus.HEALTHY, 

293 details={"provider": "mistral", "model": self.config.model}, 

294 ) 

295 except (HttpStatusError, aiohttp.ClientError, OSError, RuntimeError) as exc: 

296 return HealthCheckResult( 

297 component="llm.mistral", 

298 status=HealthStatus.UNHEALTHY, 

299 error=str(exc), 

300 ) 

301 

302 async def _stream_completion( 

303 self, 

304 client: ResilientHTTPClient, 

305 payload: dict[str, Any], 

306 ) -> AsyncIterator[StreamChunk]: 

307 """Stream chat completion. 

308 

309 Args: 

310 client: HTTP client. 

311 payload: Request payload. 

312 

313 Yields: 

314 StreamChunk objects. 

315 

316 """ 

317 try: 

318 async with client.stream( 

319 "POST", 

320 "/chat/completions", 

321 json=payload, 

322 ) as response: 

323 response.raise_for_status() 

324 

325 async for line in response.aiter_lines(): 

326 if not line or line == "data: [DONE]": 

327 continue 

328 

329 if line.startswith("data: "): 

330 try: 

331 data = loads(line[6:].encode("utf-8")) 

332 choice = data["choices"][0] 

333 delta = choice.get("delta", {}) 

334 

335 content = delta.get("content", "") 

336 if content: 

337 yield StreamChunk( 

338 delta=content, 

339 model=data["model"], 

340 finish_reason=choice.get("finish_reason"), 

341 ) 

342 

343 except (ValueError, TypeError) as e: 

344 logger.debug("Error parsing stream chunk: %s", e) 

345 continue 

346 except (aiohttp.ClientError, ValueError, TypeError, OSError) as e: 

347 raise AIError(f"Mistral streaming error: {e}") from e 

348 

349 async def embed( 

350 self, 

351 model: str = "mistral-embed", 

352 input_texts: list[str] | str | None = None, 

353 **kwargs: Any, 

354 ) -> list[list[float]]: 

355 """Generate embeddings. 

356 

357 Args: 

358 model: Model ID (default: "mistral-embed"). 

359 input_texts: Text or list of texts to embed. 

360 **kwargs: Additional parameters. 

361 

362 Returns: 

363 List of embedding vectors. 

364 

365 Example: 

366 >>> embeddings = await client.embed( 

367 ... input_texts=["Hello world", "Bonjour monde"] 

368 ... ) 

369 >>> print(f"Embedding dimension: {len(embeddings[0])}") 

370 

371 """ 

372 try: 

373 client = self._get_client() 

374 

375 # Ensure input is list 

376 if isinstance(input_texts, str): 

377 input_texts = [input_texts] 

378 

379 payload = { 

380 "model": model, 

381 "input": input_texts, 

382 **kwargs, 

383 } 

384 

385 response = await client.post("/embeddings", json=payload) 

386 response.raise_for_status() 

387 data: Any = response.json() 

388 

389 # Extract embeddings 

390 return [item["embedding"] for item in data["data"]] 

391 except (aiohttp.ClientError, ValueError, TypeError, KeyError) as e: 

392 raise AIError(f"Mistral error: {e}") from e 

393 

394 def _handle_error_as_result(self, error: Exception) -> Result[Any, LLMError]: 

395 """Map a caught exception to ``Err`` (recoverable) or re-raise (infra).""" 

396 if isinstance(error, aiohttp.ClientResponseError): 

397 if error.status == 401: 

398 raise LLMAuthenticationError( 

399 f"Mistral authentication failed: {error}" 

400 ) from error 

401 if error.status == 429: 

402 return Err(LLMRateLimitError(f"Mistral rate limit exceeded: {error}")) 

403 if error.status == 402: 

404 return Err(LLMQuotaExceededError(f"Mistral quota exceeded: {error}")) 

405 if error.status == 404: 

406 return Err(LLMModelNotFoundError(f"Mistral model not found: {error}")) 

407 raise AIError(f"Mistral infrastructure error: {error}") from error 

408 

409 async def close(self) -> None: 

410 """Close the HTTP client. 

411 

412 Example: 

413 >>> await client.close() 

414 

415 """ 

416 if self._client: 

417 await self._client.close() 

418 self._client = None 

419 await super().close() 

420 

421 

422# Common model configurations for convenience 

423MISTRAL_MODELS = { 

424 "mistral-large-latest": { 

425 "context_window": 32000, 

426 "supports_tools": True, 

427 "description": "Most capable - complex reasoning and coding", 

428 }, 

429 "mistral-medium-latest": { 

430 "context_window": 32000, 

431 "supports_tools": True, 

432 "description": "Balanced - good performance/cost ratio", 

433 }, 

434 "mistral-small-latest": { 

435 "context_window": 32000, 

436 "supports_tools": True, 

437 "description": "Fast - cost-effective for simpler tasks", 

438 }, 

439 "open-mixtral-8x22b": { 

440 "context_window": 64000, 

441 "supports_tools": True, 

442 "description": "Large open MoE - 64k context window", 

443 }, 

444 "open-mixtral-8x7b": { 

445 "context_window": 32000, 

446 "supports_tools": False, 

447 "description": "Mid-size open MoE - good balance", 

448 }, 

449 "open-mistral-7b": { 

450 "context_window": 32000, 

451 "supports_tools": False, 

452 "description": "Compact open model - fast inference", 

453 }, 

454 "mistral-embed": { 

455 "context_window": None, 

456 "supports_tools": False, 

457 "description": "Embeddings model - 1024 dimensions", 

458 }, 

459}