Claude commited on
Commit
2ad4a3a
·
unverified ·
1 Parent(s): af70a64

fix(mistral): refactoring structurel complet du provider Mistral

Browse files

- is_configured() vérifie maintenant que mistralai>=1.0 est importable
(from mistralai import Mistral) ; retourne False si v0.x est installé
ou si le package est absent, rendant le provider invisible dans l'UI

- list_models() devient dynamique via client.models.list() (SDK v1.x) :
retourne tous les modèles génératifs sauf embeddings/modération ;
fallback statique (Pixtral Large + 12B) si l'API est inaccessible

- _model_supports_vision() nouveau helper : utilise capabilities.vision
en priorité, se rabat sur la présence de "pixtral"/"vision" dans l'ID

- generate_content() bifurque selon le support vision du modèle :
content multimodal image_url+text pour Pixtral,
string prompt seul pour Mistral Large/Small/Codestral

- Dockerfile : layer dédié `pip install 'mistralai>=1.0,<2.0'` pour
invalider le cache HuggingFace Spaces qui conservait v0.x

- pyproject.toml : version 0.2.0 + contrainte mistralai>=1.0 explicite

- Tests : réécriture complète avec faux SDK (FakeCaps, FakeModel,
FakeModelsAPI, _make_fake_mistralai), 20+ tests couvrant tous les
scénarios : v0.x détecté, API dynamique, fallback statique, bifurcation
vision/texte, backward-compat alias _MISTRAL_VISION_MODELS

https://claude.ai/code/session_018woyEHc8HG2th7V4ewJ4Kg

backend/app/services/ai/provider_mistral.py CHANGED
@@ -1,18 +1,26 @@
1
  """
2
  Provider Mistral — authentification via MISTRAL_API_KEY.
3
 
4
- Modèles multimodaux supportés : pixtral-large-latest, pixtral-12b-2409.
5
- L'API Mistral n'expose pas d'endpoint list_models public stable ;
6
- la liste des modèles est donc statique et maintenue ici.
7
-
8
- Les appels image utilisent le format image_url (base64) dans le message user.
 
 
 
 
 
 
 
 
9
  """
10
  # 1. stdlib
11
  import base64
12
  import logging
13
  import os
14
 
15
- # 3. local (mistralai importé localement pour éviter l'import top-level à froid)
16
  from app.schemas.model_config import ModelInfo, ProviderType
17
  from app.services.ai.base import AIProvider
18
 
@@ -20,7 +28,11 @@ logger = logging.getLogger(__name__)
20
 
21
  _ENV_KEY = "MISTRAL_API_KEY"
22
 
23
- _MISTRAL_VISION_MODELS: list[ModelInfo] = [
 
 
 
 
24
  ModelInfo(
25
  model_id="pixtral-large-latest",
26
  display_name="Pixtral Large",
@@ -39,12 +51,30 @@ _MISTRAL_VISION_MODELS: list[ModelInfo] = [
39
  ),
40
  ]
41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
 
43
  class MistralProvider(AIProvider):
44
  """Provider Mistral AI (clé API MISTRAL_API_KEY).
45
 
46
- Liste de modèles statique (Pixtral Large + Pixtral 12B).
47
- Les appels image encodent le JPEG en base64 et l'envoient comme image_url.
 
48
  """
49
 
50
  @property
@@ -52,58 +82,109 @@ class MistralProvider(AIProvider):
52
  return ProviderType.MISTRAL
53
 
54
  def is_configured(self) -> bool:
55
- return bool(os.environ.get(_ENV_KEY))
 
 
 
 
 
 
 
 
 
 
 
 
 
56
 
57
  def list_models(self) -> list[ModelInfo]:
 
 
 
 
 
 
 
58
  if not self.is_configured():
59
- raise RuntimeError(f"Variable d'environnement manquante : {_ENV_KEY}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  logger.info(
61
- "Mistral models listed (static)",
62
- extra={"provider": self.provider_type, "count": len(_MISTRAL_VISION_MODELS)},
63
  )
64
- return list(_MISTRAL_VISION_MODELS)
65
 
66
  def generate_content(self, image_bytes: bytes, prompt: str, model_id: str) -> str:
67
- if not self.is_configured():
68
- raise RuntimeError(f"Variable d'environnement manquante : {_ENV_KEY}")
69
 
70
- try:
71
- from mistralai import Mistral # v1.x — import local
72
- except ImportError:
73
- # Détecter si c'est mistralai v0.x (ne supporte pas la vision / Pixtral)
74
- try:
75
- import mistralai as _ms_pkg # noqa: F401
76
- _v0_present = True
77
- except ImportError:
78
- _v0_present = False
79
- if _v0_present:
80
- raise RuntimeError(
81
- "Le package mistralai est installé en version 0.x qui ne supporte pas "
82
- "la vision (Pixtral). Reconstruisez le container Docker pour obtenir "
83
- "mistralai>=1.0 : `docker build --no-cache ...`"
84
- )
85
  raise RuntimeError(
86
- "Le package mistralai n'est pas installé. "
87
- "Ajoutez 'mistralai>=1.0' aux dépendances et reconstruisez le container."
88
  )
89
 
90
- api_key = os.environ[_ENV_KEY]
91
- client = Mistral(api_key=api_key)
92
-
93
- image_b64 = base64.b64encode(image_bytes).decode("utf-8")
94
- data_url = f"data:image/jpeg;base64,{image_b64}"
 
 
 
 
 
 
 
 
 
 
 
 
 
95
 
96
  response = client.chat.complete(
97
  model=model_id,
98
- messages=[
99
- {
100
- "role": "user",
101
- "content": [
102
- {"type": "image_url", "image_url": {"url": data_url}},
103
- {"type": "text", "text": prompt},
104
- ],
105
- }
106
- ],
107
  )
108
  choices = response.choices or []
109
  if not choices:
 
1
  """
2
  Provider Mistral — authentification via MISTRAL_API_KEY.
3
 
4
+ Découverte dynamique des modèles via client.models.list() (SDK v1.x).
5
+ Fallback statique sur Pixtral Large + 12B si l'API est inaccessible.
6
+
7
+ is_configured() vérifie AUSSI que `from mistralai import Mistral` fonctionne.
8
+ Si seule la version 0.x est installée, le provider est marqué indisponible :
9
+ l'utilisateur ne peut pas sélectionner de modèle Mistral, et aucun job
10
+ ne partira avec une clé incompatible.
11
+
12
+ Bifurcation d'appel selon la capacité vision du modèle :
13
+ - Pixtral (capabilities.vision = True, ou "pixtral" dans l'id) :
14
+ content multimodal — image base64 + texte.
15
+ - Modèles texte (Mistral Large, Small, Codestral…) :
16
+ content texte seul — l'image n'est pas transmise.
17
  """
18
  # 1. stdlib
19
  import base64
20
  import logging
21
  import os
22
 
23
+ # 3. local
24
  from app.schemas.model_config import ModelInfo, ProviderType
25
  from app.services.ai.base import AIProvider
26
 
 
28
 
29
  _ENV_KEY = "MISTRAL_API_KEY"
30
 
31
+ # Sous-chaînes d'IDs de modèles non génératifs à exclure de la liste
32
+ _SKIP_MODEL_KINDS = ("embed", "moderation")
33
+
34
+ # Liste statique de secours — utilisée si client.models.list() échoue
35
+ _MISTRAL_FALLBACK_MODELS: list[ModelInfo] = [
36
  ModelInfo(
37
  model_id="pixtral-large-latest",
38
  display_name="Pixtral Large",
 
51
  ),
52
  ]
53
 
54
+ # Alias backward-compat (utilisé dans certains tests)
55
+ _MISTRAL_VISION_MODELS = _MISTRAL_FALLBACK_MODELS
56
+
57
+
58
+ def _model_supports_vision(model_id: str, model_obj: object = None) -> bool:
59
+ """Détecte si un modèle Mistral supporte les entrées image.
60
+
61
+ Utilise capabilities.vision si disponible (objet SDK v1.x),
62
+ sinon se rabat sur la présence de 'pixtral' ou 'vision' dans l'ID.
63
+ """
64
+ if model_obj is not None:
65
+ caps = getattr(model_obj, "capabilities", None)
66
+ if caps is not None:
67
+ return bool(getattr(caps, "vision", False))
68
+ mid = model_id.lower()
69
+ return "pixtral" in mid or "vision" in mid
70
+
71
 
72
  class MistralProvider(AIProvider):
73
  """Provider Mistral AI (clé API MISTRAL_API_KEY).
74
 
75
+ is_configured() valide à la fois la présence de MISTRAL_API_KEY ET
76
+ que mistralai>=1.0 (classe Mistral) est importable. Si v0.x est installée,
77
+ le provider est marqué indisponible pour éviter des jobs voués à l'échec.
78
  """
79
 
80
  @property
 
82
  return ProviderType.MISTRAL
83
 
84
  def is_configured(self) -> bool:
85
+ """Retourne True si MISTRAL_API_KEY est définie ET mistralai>=1.0 est importable."""
86
+ if not os.environ.get(_ENV_KEY):
87
+ return False
88
+ try:
89
+ from mistralai import Mistral # noqa: F401
90
+ return True
91
+ except ImportError:
92
+ logger.warning(
93
+ "MISTRAL_API_KEY est définie mais mistralai>=1.0 n'est pas disponible "
94
+ "(version 0.x détectée ou package absent). "
95
+ "Provider Mistral marqué indisponible. "
96
+ "Reconstruisez le container : docker build --no-cache ..."
97
+ )
98
+ return False
99
 
100
  def list_models(self) -> list[ModelInfo]:
101
+ """Liste les modèles Mistral disponibles via l'API (dynamique).
102
+
103
+ Appelle client.models.list() pour récupérer la liste réelle.
104
+ Filtre les modèles non génératifs (embeddings, modération).
105
+ Utilise capabilities.vision pour déterminer le support image.
106
+ Fallback sur la liste statique Pixtral si l'API est inaccessible.
107
+ """
108
  if not self.is_configured():
109
+ raise RuntimeError(
110
+ f"Provider Mistral non configuré : vérifiez {_ENV_KEY} "
111
+ "et que mistralai>=1.0 est installé."
112
+ )
113
+
114
+ from mistralai import Mistral
115
+
116
+ client = Mistral(api_key=os.environ[_ENV_KEY])
117
+ result: list[ModelInfo] = []
118
+
119
+ try:
120
+ models_resp = client.models.list()
121
+ for m in models_resp.data or []:
122
+ mid: str = m.id
123
+ if any(skip in mid for skip in _SKIP_MODEL_KINDS):
124
+ continue
125
+ vision = _model_supports_vision(mid, m)
126
+ display: str = getattr(m, "display_name", None) or mid
127
+ result.append(ModelInfo(
128
+ model_id=mid,
129
+ display_name=display,
130
+ provider=ProviderType.MISTRAL,
131
+ supports_vision=vision,
132
+ input_token_limit=None,
133
+ output_token_limit=None,
134
+ ))
135
+ if result:
136
+ logger.info(
137
+ "Mistral models fetched from API",
138
+ extra={"count": len(result)},
139
+ )
140
+ return result
141
+ except Exception as exc:
142
+ logger.warning(
143
+ "Mistral API list_models échoué : %s — fallback liste statique", exc
144
+ )
145
+
146
  logger.info(
147
+ "Mistral models : liste statique (fallback)",
148
+ extra={"count": len(_MISTRAL_FALLBACK_MODELS)},
149
  )
150
+ return list(_MISTRAL_FALLBACK_MODELS)
151
 
152
  def generate_content(self, image_bytes: bytes, prompt: str, model_id: str) -> str:
153
+ """Envoie image + prompt à Mistral et retourne le texte brut.
 
154
 
155
+ Bifurcation selon le support vision du modèle :
156
+ - Vision (Pixtral) : content multimodal avec image base64 + texte.
157
+ - Texte seul (Mistral Large, Small, Codestral…) : prompt texte uniquement,
158
+ l'image n'est pas transmise (avertissement loggé).
159
+ """
160
+ if not self.is_configured():
 
 
 
 
 
 
 
 
 
161
  raise RuntimeError(
162
+ f"Provider Mistral non disponible : vérifiez {_ENV_KEY} "
163
+ "et que mistralai>=1.0 est installé."
164
  )
165
 
166
+ from mistralai import Mistral
167
+
168
+ client = Mistral(api_key=os.environ[_ENV_KEY])
169
+
170
+ if _model_supports_vision(model_id):
171
+ image_b64 = base64.b64encode(image_bytes).decode("utf-8")
172
+ data_url = f"data:image/jpeg;base64,{image_b64}"
173
+ content: object = [
174
+ {"type": "image_url", "image_url": {"url": data_url}},
175
+ {"type": "text", "text": prompt},
176
+ ]
177
+ else:
178
+ logger.warning(
179
+ "Modèle texte seul sélectionné pour une analyse image : %s. "
180
+ "L'image ne sera pas transmise à l'API.",
181
+ model_id,
182
+ )
183
+ content = prompt
184
 
185
  response = client.chat.complete(
186
  model=model_id,
187
+ messages=[{"role": "user", "content": content}],
 
 
 
 
 
 
 
 
188
  )
189
  choices = response.choices or []
190
  if not choices:
backend/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
 
5
  [project]
6
  name = "scriptorium-ai-backend"
7
- version = "0.1.0"
8
  description = "Backend Scriptorium AI — plateforme générique d'éditions savantes augmentées"
9
  requires-python = ">=3.11"
10
  dependencies = [
 
4
 
5
  [project]
6
  name = "scriptorium-ai-backend"
7
+ version = "0.2.0"
8
  description = "Backend Scriptorium AI — plateforme générique d'éditions savantes augmentées"
9
  requires-python = ">=3.11"
10
  dependencies = [
backend/tests/test_provider_mistral.py CHANGED
@@ -2,20 +2,117 @@
2
  Tests du provider Mistral AI (MistralProvider).
3
 
4
  Stratégie :
5
- - Pas d'appel réseau réel : les appels SDK sont mockés via monkeypatch.
6
- - is_configured() vérifié via variables d'env simulées.
7
- - list_models() : vérification de la liste statique.
8
- - generate_content() : mock du client Mistral.
 
9
  """
10
  # 1. stdlib
11
- import os
 
12
 
13
  # 2. third-party
14
  import pytest
15
 
16
  # 3. local
17
  from app.schemas.model_config import ProviderType
18
- from app.services.ai.provider_mistral import MistralProvider, _MISTRAL_VISION_MODELS
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
 
21
  # ---------------------------------------------------------------------------
@@ -23,17 +120,42 @@ from app.services.ai.provider_mistral import MistralProvider, _MISTRAL_VISION_MO
23
  # ---------------------------------------------------------------------------
24
 
25
  def test_is_configured_true(monkeypatch):
 
26
  monkeypatch.setenv("MISTRAL_API_KEY", "test-key-abc")
 
 
27
  assert MistralProvider().is_configured() is True
28
 
29
 
30
- def test_is_configured_false(monkeypatch):
 
31
  monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
 
 
32
  assert MistralProvider().is_configured() is False
33
 
34
 
35
- def test_is_configured_empty_key(monkeypatch):
36
  monkeypatch.setenv("MISTRAL_API_KEY", "")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  assert MistralProvider().is_configured() is False
38
 
39
 
@@ -41,38 +163,85 @@ def test_is_configured_empty_key(monkeypatch):
41
  # provider_type
42
  # ---------------------------------------------------------------------------
43
 
44
- def test_provider_type():
 
 
45
  assert MistralProvider().provider_type == ProviderType.MISTRAL
46
 
47
 
48
  # ---------------------------------------------------------------------------
49
- # list_models()
50
  # ---------------------------------------------------------------------------
51
 
52
- def test_list_models_returns_two(monkeypatch):
53
- monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
54
- models = MistralProvider().list_models()
55
- assert len(models) == 2
56
-
57
-
58
- def test_list_models_ids(monkeypatch):
59
  monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  models = MistralProvider().list_models()
61
  ids = {m.model_id for m in models}
62
  assert "pixtral-large-latest" in ids
63
  assert "pixtral-12b-2409" in ids
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
 
65
 
66
- def test_list_models_all_vision(monkeypatch):
67
- monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
 
 
 
68
  models = MistralProvider().list_models()
69
- assert all(m.supports_vision for m in models)
70
 
71
 
72
- def test_list_models_all_mistral_provider(monkeypatch):
 
73
  monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  models = MistralProvider().list_models()
75
- assert all(m.provider == ProviderType.MISTRAL for m in models)
 
 
 
 
76
 
77
 
78
  def test_list_models_raises_if_not_configured(monkeypatch):
@@ -81,58 +250,91 @@ def test_list_models_raises_if_not_configured(monkeypatch):
81
  MistralProvider().list_models()
82
 
83
 
84
- def test_list_models_display_names(monkeypatch):
85
- monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
86
- models = MistralProvider().list_models()
87
- display_names = {m.display_name for m in models}
88
- assert "Pixtral Large" in display_names
89
- assert "Pixtral 12B" in display_names
90
 
91
 
92
  # ---------------------------------------------------------------------------
93
- # generate_content() — SDK mocké
94
  # ---------------------------------------------------------------------------
95
 
96
- class _MockMessage:
97
- content = "Voici le JSON de la page."
 
 
 
 
 
 
 
 
 
98
 
 
 
 
 
 
 
 
 
 
 
99
 
100
- class _MockChoice:
101
- message = _MockMessage()
102
 
 
 
 
 
103
 
104
- class _MockResponse:
105
- choices = [_MockChoice()]
 
 
106
 
 
 
 
 
107
 
108
- class _MockChat:
109
- def complete(self, *, model, messages):
110
- return _MockResponse()
111
 
 
112
 
113
- class _MockMistralClient:
114
- chat = _MockChat()
 
 
 
 
115
 
116
 
117
- def test_generate_content_returns_text(monkeypatch):
 
118
  monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
 
119
 
120
- def _mock_mistral_cls(api_key): # noqa: ARG001
121
- return _MockMistralClient()
 
 
122
 
123
- import app.services.ai.provider_mistral as mod
124
- monkeypatch.setattr(mod, "MistralProvider", MistralProvider)
125
- # On patch l'import interne dans generate_content()
126
- import sys
127
- import types as _types
128
 
129
- fake_mistralai = _types.ModuleType("mistralai")
130
- fake_mistralai.Mistral = _mock_mistral_cls
131
- monkeypatch.setitem(sys.modules, "mistralai", fake_mistralai)
132
 
133
- provider = MistralProvider()
134
- result = provider.generate_content(b"fake-jpeg", "Analyse ce folio.", "pixtral-large-latest")
135
- assert result == "Voici le JSON de la page."
 
136
 
137
 
138
  def test_generate_content_raises_if_not_configured(monkeypatch):
@@ -141,42 +343,34 @@ def test_generate_content_raises_if_not_configured(monkeypatch):
141
  MistralProvider().generate_content(b"img", "prompt", "pixtral-large-latest")
142
 
143
 
144
- def test_generate_content_empty_response(monkeypatch):
145
- """Si choices est vide, retourne une chaîne vide sans lever d'exception."""
146
  monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
 
 
 
 
 
147
 
148
- class _EmptyResp:
149
- choices = []
 
 
150
 
151
  class _EmptyChat:
152
  def complete(self, *, model, messages):
 
 
153
  return _EmptyResp()
154
 
155
- class _EmptyClient:
156
- chat = _EmptyChat()
 
 
157
 
158
- import sys
159
- import types as _types
160
-
161
- fake_mistralai = _types.ModuleType("mistralai")
162
- fake_mistralai.Mistral = lambda api_key: _EmptyClient()
163
- monkeypatch.setitem(sys.modules, "mistralai", fake_mistralai)
164
 
165
  result = MistralProvider().generate_content(b"img", "prompt", "pixtral-large-latest")
166
  assert result == ""
167
-
168
-
169
- def test_generate_content_v0_package_raises_runtime_error(monkeypatch):
170
- """Si mistralai est installé en v0.x (pas de classe Mistral), lève RuntimeError avec un message clair."""
171
- monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
172
-
173
- import sys
174
- import types as _types
175
-
176
- # Simuler mistralai v0.x : le module existe mais n'a pas la classe Mistral
177
- fake_mistralai_v0 = _types.ModuleType("mistralai")
178
- # Pas d'attribut Mistral → from mistralai import Mistral lèvera ImportError
179
- monkeypatch.setitem(sys.modules, "mistralai", fake_mistralai_v0)
180
-
181
- with pytest.raises(RuntimeError, match="version 0.x"):
182
- MistralProvider().generate_content(b"img", "prompt", "pixtral-large-latest")
 
2
  Tests du provider Mistral AI (MistralProvider).
3
 
4
  Stratégie :
5
+ - Pas d'appel réseau réel : SDK mocké via sys.modules.
6
+ - is_configured() : vérifié via variables d'env ET import mock.
7
+ - list_models() : mock de client.models.list() → comportement dynamique
8
+ et fallback statique quand l'API échoue.
9
+ - generate_content() : bifurcation vision / texte seul.
10
  """
11
  # 1. stdlib
12
+ import sys
13
+ import types as _types
14
 
15
  # 2. third-party
16
  import pytest
17
 
18
  # 3. local
19
  from app.schemas.model_config import ProviderType
20
+ from app.services.ai.provider_mistral import (
21
+ MistralProvider,
22
+ _MISTRAL_FALLBACK_MODELS,
23
+ _MISTRAL_VISION_MODELS, # alias backward-compat
24
+ _model_supports_vision,
25
+ )
26
+
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Helpers — faux SDK Mistral
30
+ # ---------------------------------------------------------------------------
31
+
32
+ class _FakeCaps:
33
+ """Capabilities d'un modèle Mistral (SDK v1.x)."""
34
+ def __init__(self, vision: bool = False):
35
+ self.vision = vision
36
+
37
+
38
+ class _FakeModel:
39
+ def __init__(self, id_: str, vision: bool = False, display_name: str | None = None):
40
+ self.id = id_
41
+ self.display_name = display_name or id_
42
+ self.capabilities = _FakeCaps(vision=vision)
43
+
44
+
45
+ class _FakeModelsListResponse:
46
+ def __init__(self, models: list[_FakeModel]):
47
+ self.data = models
48
+
49
+
50
+ class _FakeModelsAPI:
51
+ def __init__(self, models: list[_FakeModel]):
52
+ self._models = models
53
+
54
+ def list(self) -> _FakeModelsListResponse:
55
+ return _FakeModelsListResponse(self._models)
56
+
57
+
58
+ class _FakeMessage:
59
+ content = "Voici le JSON de la page."
60
+
61
+
62
+ class _FakeChoice:
63
+ message = _FakeMessage()
64
+
65
+
66
+ class _FakeChatResponse:
67
+ choices = [_FakeChoice()]
68
+
69
+
70
+ class _FakeChat:
71
+ def complete(self, *, model, messages):
72
+ return _FakeChatResponse()
73
+
74
+
75
+ def _make_fake_mistralai(models: list[_FakeModel] | None = None) -> _types.ModuleType:
76
+ """Crée un faux module mistralai avec Mistral class et modèles mockés."""
77
+ fake = _types.ModuleType("mistralai")
78
+ chat = _FakeChat()
79
+ models_api = _FakeModelsAPI(models or [])
80
+
81
+ class _FakeMistral:
82
+ def __init__(self, api_key):
83
+ self.chat = chat
84
+ self.models = models_api
85
+
86
+ fake.Mistral = _FakeMistral
87
+ return fake
88
+
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # _model_supports_vision() — helper pur
92
+ # ---------------------------------------------------------------------------
93
+
94
+ def test_vision_detection_pixtral_by_name():
95
+ assert _model_supports_vision("pixtral-large-latest") is True
96
+ assert _model_supports_vision("pixtral-12b-2409") is True
97
+
98
+
99
+ def test_vision_detection_text_models_by_name():
100
+ assert _model_supports_vision("mistral-large-latest") is False
101
+ assert _model_supports_vision("mistral-small-latest") is False
102
+ assert _model_supports_vision("codestral-latest") is False
103
+
104
+
105
+ def test_vision_detection_uses_capabilities_when_available():
106
+ m_vision = _FakeModel("some-model", vision=True)
107
+ m_text = _FakeModel("some-model", vision=False)
108
+ assert _model_supports_vision("some-model", m_vision) is True
109
+ assert _model_supports_vision("some-model", m_text) is False
110
+
111
+
112
+ def test_vision_detection_capabilities_override_name():
113
+ """capabilities.vision=False surpasse un nom contenant 'pixtral'."""
114
+ m = _FakeModel("pixtral-test", vision=False)
115
+ assert _model_supports_vision("pixtral-test", m) is False
116
 
117
 
118
  # ---------------------------------------------------------------------------
 
120
  # ---------------------------------------------------------------------------
121
 
122
  def test_is_configured_true(monkeypatch):
123
+ """Clé présente + mistralai v1.x importable → True."""
124
  monkeypatch.setenv("MISTRAL_API_KEY", "test-key-abc")
125
+ fake = _make_fake_mistralai()
126
+ monkeypatch.setitem(sys.modules, "mistralai", fake)
127
  assert MistralProvider().is_configured() is True
128
 
129
 
130
+ def test_is_configured_false_no_key(monkeypatch):
131
+ """Pas de clé → False, même si mistralai est installé."""
132
  monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
133
+ fake = _make_fake_mistralai()
134
+ monkeypatch.setitem(sys.modules, "mistralai", fake)
135
  assert MistralProvider().is_configured() is False
136
 
137
 
138
+ def test_is_configured_false_empty_key(monkeypatch):
139
  monkeypatch.setenv("MISTRAL_API_KEY", "")
140
+ fake = _make_fake_mistralai()
141
+ monkeypatch.setitem(sys.modules, "mistralai", fake)
142
+ assert MistralProvider().is_configured() is False
143
+
144
+
145
+ def test_is_configured_false_v0x_installed(monkeypatch):
146
+ """Clé présente mais mistralai v0.x (pas de classe Mistral) → False."""
147
+ monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
148
+ fake_v0 = _types.ModuleType("mistralai")
149
+ # Pas d'attribut Mistral → from mistralai import Mistral lèvera ImportError
150
+ monkeypatch.setitem(sys.modules, "mistralai", fake_v0)
151
+ assert MistralProvider().is_configured() is False
152
+
153
+
154
+ def test_is_configured_false_mistralai_not_installed(monkeypatch):
155
+ """Clé présente mais mistralai pas du tout installé → False."""
156
+ monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
157
+ # Supprimer mistralai du chemin d'import
158
+ monkeypatch.setitem(sys.modules, "mistralai", None) # type: ignore[arg-type]
159
  assert MistralProvider().is_configured() is False
160
 
161
 
 
163
  # provider_type
164
  # ---------------------------------------------------------------------------
165
 
166
+ def test_provider_type(monkeypatch):
167
+ fake = _make_fake_mistralai()
168
+ monkeypatch.setitem(sys.modules, "mistralai", fake)
169
  assert MistralProvider().provider_type == ProviderType.MISTRAL
170
 
171
 
172
  # ---------------------------------------------------------------------------
173
+ # list_models() — comportement dynamique
174
  # ---------------------------------------------------------------------------
175
 
176
+ def _setup_list_models(monkeypatch, models: list[_FakeModel]) -> None:
177
+ """Configure le monkeypatch pour list_models()."""
 
 
 
 
 
178
  monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
179
+ fake = _make_fake_mistralai(models)
180
+ monkeypatch.setitem(sys.modules, "mistralai", fake)
181
+
182
+
183
+ def test_list_models_dynamic_returns_all_non_embed(monkeypatch):
184
+ """list_models() retourne tous les modèles sauf embeddings/modération."""
185
+ _setup_list_models(monkeypatch, [
186
+ _FakeModel("pixtral-large-latest", vision=True),
187
+ _FakeModel("pixtral-12b-2409", vision=True),
188
+ _FakeModel("mistral-large-latest", vision=False),
189
+ _FakeModel("mistral-embed", vision=False), # exclut
190
+ _FakeModel("mistral-moderation", vision=False), # exclut
191
+ ])
192
  models = MistralProvider().list_models()
193
  ids = {m.model_id for m in models}
194
  assert "pixtral-large-latest" in ids
195
  assert "pixtral-12b-2409" in ids
196
+ assert "mistral-large-latest" in ids
197
+ assert "mistral-embed" not in ids
198
+ assert "mistral-moderation" not in ids
199
+ assert len(models) == 3
200
+
201
+
202
+ def test_list_models_vision_flag_from_capabilities(monkeypatch):
203
+ """supports_vision reflète capabilities.vision du SDK."""
204
+ _setup_list_models(monkeypatch, [
205
+ _FakeModel("pixtral-large-latest", vision=True),
206
+ _FakeModel("mistral-large-latest", vision=False),
207
+ ])
208
+ models = MistralProvider().list_models()
209
+ by_id = {m.model_id: m for m in models}
210
+ assert by_id["pixtral-large-latest"].supports_vision is True
211
+ assert by_id["mistral-large-latest"].supports_vision is False
212
 
213
 
214
+ def test_list_models_all_mistral_provider(monkeypatch):
215
+ _setup_list_models(monkeypatch, [
216
+ _FakeModel("pixtral-large-latest", vision=True),
217
+ _FakeModel("mistral-large-latest", vision=False),
218
+ ])
219
  models = MistralProvider().list_models()
220
+ assert all(m.provider == ProviderType.MISTRAL for m in models)
221
 
222
 
223
+ def test_list_models_fallback_when_api_fails(monkeypatch):
224
+ """Si client.models.list() lève une exception, retourne la liste statique."""
225
  monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
226
+ fake = _types.ModuleType("mistralai")
227
+
228
+ class _FailingModels:
229
+ def list(self):
230
+ raise RuntimeError("API timeout")
231
+
232
+ class _FakeMistral:
233
+ def __init__(self, api_key):
234
+ self.models = _FailingModels()
235
+
236
+ fake.Mistral = _FakeMistral
237
+ monkeypatch.setitem(sys.modules, "mistralai", fake)
238
+
239
  models = MistralProvider().list_models()
240
+ # Fallback = _MISTRAL_FALLBACK_MODELS = 2 modèles Pixtral
241
+ assert len(models) == 2
242
+ ids = {m.model_id for m in models}
243
+ assert "pixtral-large-latest" in ids
244
+ assert "pixtral-12b-2409" in ids
245
 
246
 
247
  def test_list_models_raises_if_not_configured(monkeypatch):
 
250
  MistralProvider().list_models()
251
 
252
 
253
+ def test_list_models_fallback_backward_compat():
254
+ """_MISTRAL_VISION_MODELS est un alias de _MISTRAL_FALLBACK_MODELS."""
255
+ assert _MISTRAL_VISION_MODELS is _MISTRAL_FALLBACK_MODELS
 
 
 
256
 
257
 
258
  # ---------------------------------------------------------------------------
259
+ # generate_content() — bifurcation vision / texte
260
  # ---------------------------------------------------------------------------
261
 
262
+ def test_generate_content_vision_model_returns_text(monkeypatch):
263
+ """Modèle vision (Pixtral) : envoie l'image et retourne la réponse."""
264
+ monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
265
+ fake = _make_fake_mistralai()
266
+ monkeypatch.setitem(sys.modules, "mistralai", fake)
267
+
268
+ result = MistralProvider().generate_content(
269
+ b"fake-jpeg", "Analyse ce folio.", "pixtral-large-latest"
270
+ )
271
+ assert result == "Voici le JSON de la page."
272
+
273
 
274
+ def test_generate_content_text_model_returns_text(monkeypatch):
275
+ """Modèle texte (Mistral Large) : envoie seulement le prompt, retourne la réponse."""
276
+ monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
277
+ fake = _make_fake_mistralai()
278
+ monkeypatch.setitem(sys.modules, "mistralai", fake)
279
+
280
+ result = MistralProvider().generate_content(
281
+ b"fake-jpeg", "Analyse ce folio.", "mistral-large-latest"
282
+ )
283
+ assert result == "Voici le JSON de la page."
284
 
 
 
285
 
286
+ def test_generate_content_vision_sends_image_url(monkeypatch):
287
+ """Modèle vision : le message content contient image_url + text."""
288
+ monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
289
+ captured: list[dict] = []
290
 
291
+ class _CapturingChat:
292
+ def complete(self, *, model, messages):
293
+ captured.extend(messages)
294
+ return _FakeChatResponse()
295
 
296
+ class _FakeMistral:
297
+ def __init__(self, api_key):
298
+ self.chat = _CapturingChat()
299
+ self.models = _FakeModelsAPI([])
300
 
301
+ fake = _types.ModuleType("mistralai")
302
+ fake.Mistral = _FakeMistral
303
+ monkeypatch.setitem(sys.modules, "mistralai", fake)
304
 
305
+ MistralProvider().generate_content(b"jpeg", "prompt", "pixtral-large-latest")
306
 
307
+ assert len(captured) == 1
308
+ content = captured[0]["content"]
309
+ assert isinstance(content, list)
310
+ types_sent = {item["type"] for item in content}
311
+ assert "image_url" in types_sent
312
+ assert "text" in types_sent
313
 
314
 
315
+ def test_generate_content_text_sends_string_content(monkeypatch):
316
+ """Modèle texte : le message content est une chaîne (pas d'image)."""
317
  monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
318
+ captured: list[dict] = []
319
 
320
+ class _CapturingChat:
321
+ def complete(self, *, model, messages):
322
+ captured.extend(messages)
323
+ return _FakeChatResponse()
324
 
325
+ class _FakeMistral:
326
+ def __init__(self, api_key):
327
+ self.chat = _CapturingChat()
328
+ self.models = _FakeModelsAPI([])
 
329
 
330
+ fake = _types.ModuleType("mistralai")
331
+ fake.Mistral = _FakeMistral
332
+ monkeypatch.setitem(sys.modules, "mistralai", fake)
333
 
334
+ MistralProvider().generate_content(b"jpeg", "mon prompt", "mistral-large-latest")
335
+
336
+ assert len(captured) == 1
337
+ assert captured[0]["content"] == "mon prompt"
338
 
339
 
340
  def test_generate_content_raises_if_not_configured(monkeypatch):
 
343
  MistralProvider().generate_content(b"img", "prompt", "pixtral-large-latest")
344
 
345
 
346
+ def test_generate_content_raises_if_v0x_installed(monkeypatch):
347
+ """Si mistralai v0.x est installé (is_configured() → False), RuntimeError clair."""
348
  monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
349
+ fake_v0 = _types.ModuleType("mistralai")
350
+ monkeypatch.setitem(sys.modules, "mistralai", fake_v0)
351
+
352
+ with pytest.raises(RuntimeError, match="mistralai>=1.0"):
353
+ MistralProvider().generate_content(b"img", "prompt", "pixtral-large-latest")
354
 
355
+
356
+ def test_generate_content_empty_response(monkeypatch):
357
+ """Si choices est vide, retourne une chaîne vide sans exception."""
358
+ monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
359
 
360
  class _EmptyChat:
361
  def complete(self, *, model, messages):
362
+ class _EmptyResp:
363
+ choices = []
364
  return _EmptyResp()
365
 
366
+ class _FakeMistral:
367
+ def __init__(self, api_key):
368
+ self.chat = _EmptyChat()
369
+ self.models = _FakeModelsAPI([])
370
 
371
+ fake = _types.ModuleType("mistralai")
372
+ fake.Mistral = _FakeMistral
373
+ monkeypatch.setitem(sys.modules, "mistralai", fake)
 
 
 
374
 
375
  result = MistralProvider().generate_content(b"img", "prompt", "pixtral-large-latest")
376
  assert result == ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
infra/Dockerfile CHANGED
@@ -38,6 +38,11 @@ RUN mkdir -p /tmp/build/app \
38
  && pip install --no-cache-dir --upgrade /tmp/build/ \
39
  && rm -rf /tmp/build
40
 
 
 
 
 
 
41
  # ── Code source backend ────────────────────────────────────────────────────
42
  COPY backend/app ./backend/app
43
  COPY profiles/ ./profiles/
 
38
  && pip install --no-cache-dir --upgrade /tmp/build/ \
39
  && rm -rf /tmp/build
40
 
41
+ # ── Layer dédié mistralai — invalide le cache HF si v0.x est présent ─────
42
+ # Layer séparé de l'install principal pour forcer la mise à jour même si
43
+ # HuggingFace réutilise le layer pyproject.toml depuis un build antérieur.
44
+ RUN pip install --no-cache-dir 'mistralai>=1.0,<2.0'
45
+
46
  # ── Code source backend ────────────────────────────────────────────────────
47
  COPY backend/app ./backend/app
48
  COPY profiles/ ./profiles/