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
« 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.
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.
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
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)
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)
31API Documentation: https://docs.mistral.ai
33"""
35from __future__ import annotations
37from collections.abc import AsyncIterator
38from typing import Any, cast
40import aiohttp
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
75logger = get_logger(__name__)
78class MistralClient(AbstractLLMClient):
79 """Client for Mistral AI's LLM API.
81 Conforms to: :class:`~lexigram.contracts.ai.LLMClientProtocol` protocol via structural typing.
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 """
89 def __init__(self, config: ClientConfig):
90 """Initialize Mistral client.
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
98 @property
99 def api_key(self) -> SecretStr:
100 """Get API key from config."""
101 return self.config.api_key or SecretStr("")
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"
108 def _get_client(self) -> ResilientHTTPClient:
109 """Get or create HTTP client.
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
126 async def _do_complete(
127 self,
128 messages: list[ChatMessage],
129 **kwargs: Any,
130 ) -> Result[Completion, LLMError]:
131 """Provider-specific non-streaming completion.
133 Args:
134 messages: Chat messages (ChatMessage or dict).
135 **kwargs: Forwarded kwargs (model, temperature, max_tokens, tools, response_format, etc.).
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)
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)
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
173 response = await client.post("/chat/completions", json=payload)
174 response.raise_for_status()
175 data: Any = response.json()
177 choice = data["choices"][0]
178 message = choice["message"]
180 tool_calls = parse_openai_tool_calls(message.get("tool_calls"))
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)
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.
211 Args:
212 messages: Chat messages (ChatMessage or dict).
213 **kwargs: Forwarded kwargs (model, temperature, max_tokens, tools, etc.).
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)
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)
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
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)
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.
264 Args:
265 messages: Chat messages.
266 tools: Optional tool definitions.
267 **kwargs: Additional parameters.
269 Returns:
270 ``Ok(Completion)`` on success or ``Err(LLMError)`` on failure.
271 """
272 return await self._do_complete(messages, tools=tools, **kwargs)
274 async def health_check(self, timeout: float = 5.0) -> HealthCheckResult:
275 """Perform a lightweight health check against the Mistral API.
277 Calls the models endpoint to verify the API key is valid and the
278 service is reachable.
280 Args:
281 timeout: Maximum seconds to wait for the response.
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 )
302 async def _stream_completion(
303 self,
304 client: ResilientHTTPClient,
305 payload: dict[str, Any],
306 ) -> AsyncIterator[StreamChunk]:
307 """Stream chat completion.
309 Args:
310 client: HTTP client.
311 payload: Request payload.
313 Yields:
314 StreamChunk objects.
316 """
317 try:
318 async with client.stream(
319 "POST",
320 "/chat/completions",
321 json=payload,
322 ) as response:
323 response.raise_for_status()
325 async for line in response.aiter_lines():
326 if not line or line == "data: [DONE]":
327 continue
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", {})
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 )
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
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.
357 Args:
358 model: Model ID (default: "mistral-embed").
359 input_texts: Text or list of texts to embed.
360 **kwargs: Additional parameters.
362 Returns:
363 List of embedding vectors.
365 Example:
366 >>> embeddings = await client.embed(
367 ... input_texts=["Hello world", "Bonjour monde"]
368 ... )
369 >>> print(f"Embedding dimension: {len(embeddings[0])}")
371 """
372 try:
373 client = self._get_client()
375 # Ensure input is list
376 if isinstance(input_texts, str):
377 input_texts = [input_texts]
379 payload = {
380 "model": model,
381 "input": input_texts,
382 **kwargs,
383 }
385 response = await client.post("/embeddings", json=payload)
386 response.raise_for_status()
387 data: Any = response.json()
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
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
409 async def close(self) -> None:
410 """Close the HTTP client.
412 Example:
413 >>> await client.close()
415 """
416 if self._client:
417 await self._client.close()
418 self._client = None
419 await super().close()
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}