Spaces:
Running
Running
Claude
fix(llm): compatibilité import mistralai — namespace package et mocks tests
c5cdf1e unverified | """Adaptateur LLM — Mistral AI (Mistral Large, Pixtral).""" | |
| from __future__ import annotations | |
| import logging | |
| import os | |
| from typing import Optional | |
| from picarones.llm.base import BaseLLMAdapter | |
| logger = logging.getLogger(__name__) | |
| # Modèles Mistral qui NE supportent PAS l'API chat/completions multimodale. | |
| # Ces petits modèles sont text-only; le passer avec une image provoque une erreur. | |
| _TEXT_ONLY_MODELS = frozenset({ | |
| "ministral-3b-latest", | |
| "ministral-8b-latest", | |
| "mistral-tiny", | |
| "mistral-tiny-latest", | |
| "open-mistral-7b", | |
| "open-mixtral-8x7b", | |
| }) | |
| class MistralAdapter(BaseLLMAdapter): | |
| """Adaptateur pour les modèles Mistral AI. | |
| Clé API via la variable d'environnement ``MISTRAL_API_KEY``. | |
| Modes supportés : text_only (tous modèles), text_and_image et zero_shot | |
| avec les modèles multimodaux (pixtral-12b, pixtral-large). | |
| Note | |
| ---- | |
| Les modèles ``ministral-3b-latest`` et ``ministral-8b-latest`` ne supportent | |
| pas le mode multimodal — utiliser ``PipelineMode.TEXT_ONLY`` avec ces modèles. | |
| """ | |
| def name(self) -> str: | |
| return "mistral" | |
| def default_model(self) -> str: | |
| return "mistral-large-latest" | |
| def __init__( | |
| self, | |
| model: Optional[str] = None, | |
| config: Optional[dict] = None, | |
| ) -> None: | |
| super().__init__(model, config) | |
| self._api_key = os.environ.get("MISTRAL_API_KEY") | |
| if self.model in _TEXT_ONLY_MODELS: | |
| logger.info( | |
| "[MistralAdapter] modèle '%s' : text-only (pas de support multimodal).", | |
| self.model, | |
| ) | |
| def _call(self, prompt: str, image_b64: Optional[str] = None) -> str: | |
| if not self._api_key: | |
| raise RuntimeError( | |
| "Clé API Mistral manquante — définissez la variable d'environnement MISTRAL_API_KEY" | |
| ) | |
| try: | |
| try: | |
| from mistralai.client import Mistral | |
| except ImportError: | |
| from mistralai import Mistral # type: ignore[no-redef] | |
| except ImportError as exc: | |
| raise RuntimeError( | |
| "Le package 'mistralai' n'est pas installé. Lancez : pip install mistralai" | |
| ) from exc | |
| client = Mistral(api_key=self._api_key) | |
| temperature = float(self.config.get("temperature", 0.0)) | |
| max_tokens = int(self.config.get("max_tokens", 4096)) | |
| # Les modèles text-only ne supportent pas les images | |
| if image_b64 and self.model in _TEXT_ONLY_MODELS: | |
| logger.warning( | |
| "[MistralAdapter] modèle '%s' ne supporte pas les images — " | |
| "image ignorée, appel en mode texte seul.", | |
| self.model, | |
| ) | |
| image_b64 = None | |
| if image_b64: | |
| content: list | str = [ | |
| {"type": "text", "text": prompt}, | |
| { | |
| "type": "image_url", | |
| "image_url": f"data:image/png;base64,{image_b64}", | |
| }, | |
| ] | |
| else: | |
| content = prompt | |
| logger.info( | |
| "[MistralAdapter] appel %s — prompt=%d chars, image=%s", | |
| self.model, len(prompt), "oui" if image_b64 else "non", | |
| ) | |
| try: | |
| response = client.chat.complete( | |
| model=self.model, | |
| messages=[{"role": "user", "content": content}], | |
| temperature=temperature, | |
| max_tokens=max_tokens, | |
| ) | |
| except Exception as exc: | |
| status_code = getattr(exc, "status_code", None) or getattr(exc, "http_status", None) | |
| if status_code == 401: | |
| logger.warning( | |
| "[MistralAdapter] erreur HTTP 401 — clé API invalide ou expirée " | |
| "(modèle=%s). Vérifier MISTRAL_API_KEY.", | |
| self.model, | |
| ) | |
| elif status_code == 429: | |
| logger.warning( | |
| "[MistralAdapter] erreur HTTP 429 — quota dépassé ou rate-limit " | |
| "(modèle=%s). Réessayer plus tard.", | |
| self.model, | |
| ) | |
| elif status_code is not None and status_code >= 500: | |
| logger.warning( | |
| "[MistralAdapter] erreur HTTP %d — problème serveur Mistral " | |
| "(modèle=%s) : %s", | |
| status_code, self.model, exc, | |
| ) | |
| else: | |
| logger.warning( | |
| "[MistralAdapter] erreur lors de l'appel API (modèle=%s) : %s", | |
| self.model, exc, | |
| ) | |
| raise | |
| if not response.choices: | |
| logger.warning( | |
| "[MistralAdapter] response.choices vide (modèle=%s).", | |
| self.model, | |
| ) | |
| return "" | |
| _choice = response.choices[0] | |
| raw = _choice.message.content | |
| _finish_reason = _choice.finish_reason | |
| # Le SDK mistralai peut retourner une liste de ContentChunk au lieu | |
| # d'une chaîne pour certains modèles/versions. Normaliser en str. | |
| if isinstance(raw, list): | |
| raw = "".join( | |
| chunk.text if hasattr(chunk, "text") else str(chunk) | |
| for chunk in raw | |
| ) | |
| text = raw or "" | |
| _completion_tokens = None | |
| if hasattr(response, "usage") and response.usage: | |
| _completion_tokens = getattr(response.usage, "completion_tokens", None) | |
| logger.info( | |
| "[MistralAdapter] réponse %s — finish_reason=%s, len=%d, tokens=%s", | |
| self.model, _finish_reason, len(text), _completion_tokens, | |
| ) | |
| if not text.strip(): | |
| logger.warning( | |
| "[MistralAdapter] réponse vide du modèle '%s' " | |
| "(finish_reason=%s, completion_tokens=%s). " | |
| "Vérifier le prompt et la compatibilité du modèle.", | |
| self.model, _finish_reason, _completion_tokens, | |
| ) | |
| return text | |