Picarones / picarones /llm /mistral_adapter.py
Claude
fix(llm): compatibilité import mistralai — namespace package et mocks tests
c5cdf1e unverified
Raw
History Blame
6.12 kB
"""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.
"""
@property
def name(self) -> str:
return "mistral"
@property
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