Claude commited on
Commit
af0c4ba
·
unverified ·
1 Parent(s): b7fee66

fix(providers): désactiver vertex_api_key + ajouter OCR Mistral dédié

Browse files

─── vertex_api_key : désactivé (is_configured() → toujours False) ───────────
Cause confirmée par les logs production :
- Sans vertexai=True → 403 sur generativelanguage.googleapis.com
- Avec vertexai=True → 401 UNAUTHENTICATED sur aiplatform.googleapis.com
"API keys are not supported by this API. Expected OAuth2 access token."

aiplatform.googleapis.com n'accepte pas les clés API, uniquement OAuth2.
Le provider reste déclaré mais is_configured() retourne False immédiatement
avec un message expliquant les alternatives (GOOGLE_AI_STUDIO_API_KEY ou
VERTEX_SERVICE_ACCOUNT_JSON). Plus d'appels réseau voués à l'échec.

─── Mistral OCR : endpoint dédié client.ocr.process() ───────────────────────
Le SDK Mistral v1.x expose client.ocr.process() (OCRResponse.pages[].markdown)
qui est fondamentalement différent de client.chat.complete() :
- Endpoint dédié, pas de chat completions
- Retourne du markdown structuré page par page (pas un message)
- Document = ImageURLChunk (base64 data URI)

generate_content() bifurque maintenant sur 3 chemins :
1. "ocr" dans l'ID → client.ocr.process(), pages concaténées en markdown
2. Vision (Pixtral) → client.chat.complete() multimodal image_url+text
3. Texte seul → client.chat.complete() prompt texte uniquement

mistral-ocr-latest ajouté dans la liste statique de fallback et injecté dans
list_models() si absent de la liste dynamique (endpoint séparé de models.list).

Tests : +6 tests OCR, tests vertex_api_key mis à jour (provider indisponible),
assertions list_all_models refactorisées pour utiliser SA provider à la place.

https://claude.ai/code/session_018woyEHc8HG2th7V4ewJ4Kg

backend/app/services/ai/provider_mistral.py CHANGED
@@ -2,18 +2,19 @@
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
@@ -31,6 +32,9 @@ _ENV_KEY = "MISTRAL_API_KEY"
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(
@@ -49,24 +53,37 @@ _MISTRAL_FALLBACK_MODELS: list[ModelInfo] = [
49
  input_token_limit=128_000,
50
  output_token_limit=None,
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):
@@ -102,8 +119,8 @@ class MistralProvider(AIProvider):
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(
@@ -132,12 +149,26 @@ class MistralProvider(AIProvider):
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
@@ -152,10 +183,15 @@ class MistralProvider(AIProvider):
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(
@@ -166,14 +202,29 @@ class MistralProvider(AIProvider):
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. "
 
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 + mistral-ocr-latest 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
+
10
+ Trois types d'appel selon le modèle sélectionné :
11
+ 1. OCR (mistral-ocr-latest, "ocr" dans l'ID) :
12
+ client.ocr.process() retourne du markdown structuré par page.
13
+ Endpoint dédié, différent de chat completions.
14
+ 2. Vision (Pixtral, capabilities.vision=True, "pixtral" ou "vision" dans l'ID) :
15
+ client.chat.complete() avec content multimodal (image base64 + texte).
16
+ 3. Texte seul (Mistral Large, Small, Codestral…) :
17
+ client.chat.complete() avec content texte uniquement (image non transmise).
18
  """
19
  # 1. stdlib
20
  import base64
 
32
  # Sous-chaînes d'IDs de modèles non génératifs à exclure de la liste
33
  _SKIP_MODEL_KINDS = ("embed", "moderation")
34
 
35
+ # Modèle OCR dédié — endpoint client.ocr.process(), pas chat completions
36
+ _OCR_MODEL_ID = "mistral-ocr-latest"
37
+
38
  # Liste statique de secours — utilisée si client.models.list() échoue
39
  _MISTRAL_FALLBACK_MODELS: list[ModelInfo] = [
40
  ModelInfo(
 
53
  input_token_limit=128_000,
54
  output_token_limit=None,
55
  ),
56
+ ModelInfo(
57
+ model_id=_OCR_MODEL_ID,
58
+ display_name="Mistral OCR",
59
+ provider=ProviderType.MISTRAL,
60
+ supports_vision=True,
61
+ input_token_limit=None,
62
+ output_token_limit=None,
63
+ ),
64
  ]
65
 
66
  # Alias backward-compat (utilisé dans certains tests)
67
  _MISTRAL_VISION_MODELS = _MISTRAL_FALLBACK_MODELS
68
 
69
 
70
+ def _is_ocr_model(model_id: str) -> bool:
71
+ """Retourne True si le modèle utilise l'endpoint OCR dédié (pas chat completions)."""
72
+ return "ocr" in model_id.lower()
73
+
74
+
75
  def _model_supports_vision(model_id: str, model_obj: object = None) -> bool:
76
  """Détecte si un modèle Mistral supporte les entrées image.
77
 
78
  Utilise capabilities.vision si disponible (objet SDK v1.x),
79
+ sinon se rabat sur la présence de 'pixtral', 'vision' ou 'ocr' dans l'ID.
80
  """
81
  if model_obj is not None:
82
  caps = getattr(model_obj, "capabilities", None)
83
  if caps is not None:
84
  return bool(getattr(caps, "vision", False))
85
  mid = model_id.lower()
86
+ return "pixtral" in mid or "vision" in mid or "ocr" in mid
87
 
88
 
89
  class MistralProvider(AIProvider):
 
119
 
120
  Appelle client.models.list() pour récupérer la liste réelle.
121
  Filtre les modèles non génératifs (embeddings, modération).
122
+ Ajoute mistral-ocr-latest s'il n'est pas déjà dans la liste (endpoint dédié).
123
+ Fallback sur la liste statique si l'API est inaccessible.
124
  """
125
  if not self.is_configured():
126
  raise RuntimeError(
 
149
  input_token_limit=None,
150
  output_token_limit=None,
151
  ))
152
+
153
  if result:
154
+ # Ajouter mistral-ocr-latest s'il n'est pas dans la liste dynamique
155
+ # (endpoint OCR dédié, pas toujours dans models.list())
156
+ ids_in_result = {m.model_id for m in result}
157
+ if _OCR_MODEL_ID not in ids_in_result:
158
+ result.append(ModelInfo(
159
+ model_id=_OCR_MODEL_ID,
160
+ display_name="Mistral OCR",
161
+ provider=ProviderType.MISTRAL,
162
+ supports_vision=True,
163
+ input_token_limit=None,
164
+ output_token_limit=None,
165
+ ))
166
  logger.info(
167
  "Mistral models fetched from API",
168
  extra={"count": len(result)},
169
  )
170
  return result
171
+
172
  except Exception as exc:
173
  logger.warning(
174
  "Mistral API list_models échoué : %s — fallback liste statique", exc
 
183
  def generate_content(self, image_bytes: bytes, prompt: str, model_id: str) -> str:
184
  """Envoie image + prompt à Mistral et retourne le texte brut.
185
 
186
+ Trois chemins selon le modèle :
187
+ 1. OCR (mistral-ocr-latest) :
188
+ client.ocr.process() markdown de toutes les pages concaténées.
189
+ L'endpoint OCR retourne du texte structuré, pas des messages chat.
190
+ 2. Vision (Pixtral) :
191
+ client.chat.complete() avec content multimodal (image base64 + texte).
192
+ 3. Texte seul (Mistral Large, Small, Codestral) :
193
+ client.chat.complete() avec prompt texte uniquement.
194
+ L'image n'est pas transmise (avertissement loggé).
195
  """
196
  if not self.is_configured():
197
  raise RuntimeError(
 
202
  from mistralai import Mistral
203
 
204
  client = Mistral(api_key=os.environ[_ENV_KEY])
205
+ image_b64 = base64.b64encode(image_bytes).decode("utf-8")
206
+ data_url = f"data:image/jpeg;base64,{image_b64}"
207
+
208
+ # ── Chemin 1 : OCR dédié ─────────────────────────────────────────────
209
+ if _is_ocr_model(model_id):
210
+ logger.info("Mistral OCR : endpoint dédié client.ocr.process()", extra={"model": model_id})
211
+ response = client.ocr.process(
212
+ model=model_id,
213
+ document={"type": "image_url", "image_url": {"url": data_url}},
214
+ )
215
+ # OCRResponse.pages : list[OCRPageObject], chacun avec .markdown
216
+ pages = getattr(response, "pages", []) or []
217
+ return "\n\n".join(
218
+ getattr(page, "markdown", "") for page in pages
219
+ )
220
 
221
+ # ── Chemin 2 : Vision multimodale (Pixtral) ──────────────────────────
222
  if _model_supports_vision(model_id):
 
 
223
  content: object = [
224
  {"type": "image_url", "image_url": {"url": data_url}},
225
  {"type": "text", "text": prompt},
226
  ]
227
+ # ── Chemin 3 : Texte seul ─────────────────────────────────────────────
228
  else:
229
  logger.warning(
230
  "Modèle texte seul sélectionné pour une analyse image : %s. "
backend/app/services/ai/provider_vertex_key.py CHANGED
@@ -1,39 +1,51 @@
1
  """
2
  Provider Vertex AI — authentification via clé API Express Vertex (VERTEX_API_KEY).
3
 
4
- La clé Vertex Express (format AQ.Ab...) encode le projet GCP ; elle est utilisée
5
- avec vertexai=True pour router vers aiplatform.googleapis.com (et non vers
6
- generativelanguage.googleapis.com qui est l'endpoint Google AI Studio).
7
-
8
- Référence SDK google-genai :
9
- api_key seul → Gemini Developer API (generativelanguage)
10
- vertexai=True + api_key → Vertex AI Express mode (aiplatform)
11
- project/location + api_key ValueError (mutually exclusive dans le constructeur)
 
 
 
 
 
 
 
 
12
  """
13
  # 1. stdlib
14
  import logging
15
  import os
16
 
17
  # 2. third-party
18
- from google import genai
19
- from google.genai import types
20
 
21
  # 3. local
22
  from app.schemas.model_config import ModelInfo, ProviderType
23
- from app.services.ai.base import AIProvider, is_vision_model
24
 
25
  logger = logging.getLogger(__name__)
26
 
27
  _ENV_KEY = "VERTEX_API_KEY"
28
 
 
 
 
 
 
 
29
 
30
  class VertexAPIKeyProvider(AIProvider):
31
- """Provider Vertex AI via clé API Express (VERTEX_API_KEY).
32
 
33
- Utilise genai.Client(vertexai=True, api_key=...) pour router vers
34
- l'endpoint Vertex AI (aiplatform.googleapis.com). La clé Express encode
35
- le projet GCP ; project/location explicites sont omis car ils sont
36
- mutually exclusive avec api_key dans le constructeur SDK.
37
  """
38
 
39
  @property
@@ -41,62 +53,12 @@ class VertexAPIKeyProvider(AIProvider):
41
  return ProviderType.VERTEX_API_KEY
42
 
43
  def is_configured(self) -> bool:
44
- return bool(os.environ.get(_ENV_KEY))
45
-
46
- def _build_client(self) -> genai.Client:
47
- """Construit un client Vertex AI en mode Express API key.
48
-
49
- vertexai=True route vers aiplatform.googleapis.com.
50
- project/location sont omis : mutually exclusive avec api_key
51
- dans le SDK (la clé Express encode le projet).
52
- """
53
- return genai.Client(
54
- vertexai=True,
55
- api_key=os.environ[_ENV_KEY],
56
- )
57
 
58
  def list_models(self) -> list[ModelInfo]:
59
- if not self.is_configured():
60
- raise RuntimeError(f"Variable d'environnement manquante : {_ENV_KEY}")
61
-
62
- client = self._build_client()
63
- result: list[ModelInfo] = []
64
-
65
- for model in client.models.list():
66
- methods = getattr(model, "supported_generation_methods", []) or []
67
- # Pour Vertex, certains modèles peuvent ne pas avoir
68
- # supported_generation_methods renseigné ; on les inclut
69
- # s'ils contiennent "gemini" dans le nom (modèles génératifs Vertex).
70
- name_lower = (getattr(model, "name", "") or "").lower()
71
- is_generative = (
72
- "generateContent" in methods
73
- or (not methods and "gemini" in name_lower)
74
- )
75
- if not is_generative:
76
- continue
77
-
78
- result.append(ModelInfo(
79
- model_id=model.name,
80
- display_name=getattr(model, "display_name", model.name),
81
- provider=self.provider_type,
82
- supports_vision=is_vision_model(model),
83
- input_token_limit=getattr(model, "input_token_limit", None),
84
- output_token_limit=getattr(model, "output_token_limit", None),
85
- ))
86
-
87
- logger.info(
88
- "Vertex API key (Express) models fetched",
89
- extra={"provider": self.provider_type.value, "count": len(result)},
90
- )
91
- return result
92
 
93
  def generate_content(self, image_bytes: bytes, prompt: str, model_id: str) -> str:
94
- if not self.is_configured():
95
- raise RuntimeError(f"Variable d'environnement manquante : {_ENV_KEY}")
96
- client = self._build_client()
97
- image_part = types.Part.from_bytes(data=image_bytes, mime_type="image/jpeg")
98
- response = client.models.generate_content(
99
- model=model_id,
100
- contents=[image_part, prompt],
101
- )
102
- return response.text or ""
 
1
  """
2
  Provider Vertex AI — authentification via clé API Express Vertex (VERTEX_API_KEY).
3
 
4
+ ÉTAT : NON FONCTIONNEL aiplatform.googleapis.com n'accepte pas les clés API.
5
+
6
+ Diagnostic :
7
+ - Sans vertexai=True → generativelanguage.googleapis.com → 403 (clé Vertex rejetée)
8
+ - Avec vertexai=True → aiplatform.googleapis.com → 401 UNAUTHENTICATED
9
+ "API keys are not supported by this API. Expected OAuth2 access token."
10
+
11
+ Cause : Vertex AI (aiplatform) n'accepte que OAuth2 / service account / ADC.
12
+ Les clés API (format AQ.Ab...) ne sont pas prises en charge par cette API.
13
+
14
+ Alternatives fonctionnelles :
15
+ 1. Google AI Studio : GOOGLE_AI_STUDIO_API_KEY (clé AIza...) → fonctionne
16
+ 2. Vertex AI Service Account : VERTEX_SERVICE_ACCOUNT_JSON → fonctionne
17
+
18
+ Ce provider est conservé pour la cohérence de l'interface mais is_configured()
19
+ retourne toujours False afin d'éviter des appels réseau voués à l'échec.
20
  """
21
  # 1. stdlib
22
  import logging
23
  import os
24
 
25
  # 2. third-party
26
+ from google.genai import types # noqa: F401 (conservé pour import cohérence)
 
27
 
28
  # 3. local
29
  from app.schemas.model_config import ModelInfo, ProviderType
30
+ from app.services.ai.base import AIProvider
31
 
32
  logger = logging.getLogger(__name__)
33
 
34
  _ENV_KEY = "VERTEX_API_KEY"
35
 
36
+ _UNAVAILABLE_MSG = (
37
+ "VERTEX_API_KEY définie mais aiplatform.googleapis.com n'accepte pas les "
38
+ "clés API (OAuth2 requis). Utilisez GOOGLE_AI_STUDIO_API_KEY pour le "
39
+ "Gemini Developer API, ou VERTEX_SERVICE_ACCOUNT_JSON pour Vertex AI."
40
+ )
41
+
42
 
43
  class VertexAPIKeyProvider(AIProvider):
44
+ """Provider Vertex AI via clé API Express — NON FONCTIONNEL.
45
 
46
+ aiplatform.googleapis.com exige OAuth2/service account ; les clés API
47
+ sont systématiquement rejetées avec 401 UNAUTHENTICATED.
48
+ Ce provider reste présent mais is_configured() retourne toujours False.
 
49
  """
50
 
51
  @property
 
53
  return ProviderType.VERTEX_API_KEY
54
 
55
  def is_configured(self) -> bool:
56
+ if os.environ.get(_ENV_KEY):
57
+ logger.warning(_UNAVAILABLE_MSG)
58
+ return False
 
 
 
 
 
 
 
 
 
 
59
 
60
  def list_models(self) -> list[ModelInfo]:
61
+ raise RuntimeError(_UNAVAILABLE_MSG)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
 
63
  def generate_content(self, image_bytes: bytes, prompt: str, model_id: str) -> str:
64
+ raise RuntimeError(_UNAVAILABLE_MSG)
 
 
 
 
 
 
 
 
backend/tests/test_ai_providers.py CHANGED
@@ -232,75 +232,27 @@ def test_vertex_key_provider_not_configured(monkeypatch):
232
  assert VertexAPIKeyProvider().is_configured() is False
233
 
234
 
235
- def test_vertex_key_provider_configured(monkeypatch):
 
236
  monkeypatch.setenv("VERTEX_API_KEY", "fake-vertex-key")
237
- assert VertexAPIKeyProvider().is_configured() is True
238
 
239
 
240
  def test_vertex_key_provider_type():
241
  assert VertexAPIKeyProvider().provider_type == ProviderType.VERTEX_API_KEY
242
 
243
 
244
- def test_vertex_key_provider_list_models_not_configured(monkeypatch):
245
- monkeypatch.delenv("VERTEX_API_KEY", raising=False)
246
- with pytest.raises(RuntimeError, match="VERTEX_API_KEY"):
247
- VertexAPIKeyProvider().list_models()
248
-
249
-
250
- def test_vertex_key_provider_list_models_success(monkeypatch):
251
- monkeypatch.setenv("VERTEX_API_KEY", "fake-vertex-key")
252
- mock_model = _make_mock_model(
253
- name="models/gemini-2.0-flash",
254
- display_name="Gemini 2.0 Flash",
255
- )
256
-
257
- with patch("app.services.ai.provider_vertex_key.genai.Client") as MockClient:
258
- MockClient.return_value.models.list.return_value = [mock_model]
259
- models = VertexAPIKeyProvider().list_models()
260
-
261
- assert len(models) == 1
262
- assert models[0].model_id == "models/gemini-2.0-flash"
263
- assert models[0].provider == ProviderType.VERTEX_API_KEY
264
- # vertexai=True est obligatoire pour router vers aiplatform.googleapis.com
265
- # (sans ça, le SDK route vers generativelanguage.googleapis.com → 403)
266
- MockClient.assert_called_once_with(vertexai=True, api_key="fake-vertex-key")
267
-
268
-
269
- def test_vertex_key_provider_list_models_includes_gemini_without_methods(monkeypatch):
270
- """Vertex peut retourner des modèles sans supported_generation_methods.
271
- Si le nom contient 'gemini', on les inclut quand même."""
272
  monkeypatch.setenv("VERTEX_API_KEY", "fake-vertex-key")
273
- model_no_methods = _make_mock_model(
274
- name="publishers/google/models/gemini-1.5-pro-002",
275
- display_name="Gemini 1.5 Pro 002",
276
- methods=[],
277
- )
278
- model_non_gemini = _make_mock_model(
279
- name="publishers/google/models/text-bison",
280
- display_name="Text Bison",
281
- methods=[],
282
- )
283
-
284
- with patch("app.services.ai.provider_vertex_key.genai.Client") as MockClient:
285
- MockClient.return_value.models.list.return_value = [model_no_methods, model_non_gemini]
286
- models = VertexAPIKeyProvider().list_models()
287
-
288
- assert len(models) == 1
289
- assert "gemini" in models[0].model_id.lower()
290
 
291
 
292
- def test_vertex_key_provider_generate_content_uses_vertexai(monkeypatch):
293
- """generate_content doit aussi utiliser vertexai=True."""
294
  monkeypatch.setenv("VERTEX_API_KEY", "fake-vertex-key")
295
-
296
- with patch("app.services.ai.provider_vertex_key.genai.Client") as MockClient:
297
- with patch("app.services.ai.provider_vertex_key.types.Part.from_bytes") as mock_part:
298
- mock_part.return_value = "fake-part"
299
- MockClient.return_value.models.generate_content.return_value.text = "result"
300
- result = VertexAPIKeyProvider().generate_content(b"img", "prompt", "gemini-2.0-flash")
301
-
302
- MockClient.assert_called_once_with(vertexai=True, api_key="fake-vertex-key")
303
- assert result == "result"
304
 
305
 
306
  # ---------------------------------------------------------------------------
@@ -417,12 +369,13 @@ def test_list_all_models_one_provider(monkeypatch):
417
 
418
 
419
  def test_list_all_models_aggregates_multiple_providers(monkeypatch):
420
- # Note : provider_google_ai et provider_vertex_key partagent le même objet
421
- # google.genai (import module). On patch au niveau des méthodes pour éviter
422
- # que le second patch.object("...genai.Client") écrase le premier.
 
423
  monkeypatch.setenv("GOOGLE_AI_STUDIO_API_KEY", "fake-key-ai")
424
- monkeypatch.setenv("VERTEX_API_KEY", "fake-key-vertex")
425
- monkeypatch.delenv("VERTEX_SERVICE_ACCOUNT_JSON", raising=False)
426
 
427
  models_ai = [ModelInfo(
428
  model_id="models/gemini-1.5-pro",
@@ -430,41 +383,42 @@ def test_list_all_models_aggregates_multiple_providers(monkeypatch):
430
  provider=ProviderType.GOOGLE_AI_STUDIO,
431
  supports_vision=True,
432
  )]
433
- models_vertex = [ModelInfo(
434
  model_id="models/gemini-2.0-flash",
435
  display_name="Gemini 2.0 Flash",
436
- provider=ProviderType.VERTEX_API_KEY,
437
  supports_vision=True,
438
  )]
439
 
440
  with patch.object(GoogleAIProvider, "list_models", return_value=models_ai):
441
- with patch.object(VertexAPIKeyProvider, "list_models", return_value=models_vertex):
442
  result = list_all_models()
443
 
444
  assert len(result) == 2
445
  providers = {m.provider for m in result}
446
  assert ProviderType.GOOGLE_AI_STUDIO in providers
447
- assert ProviderType.VERTEX_API_KEY in providers
448
 
449
 
450
  def test_list_all_models_failing_provider_is_skipped(monkeypatch):
 
451
  monkeypatch.setenv("GOOGLE_AI_STUDIO_API_KEY", "bad-key")
452
- monkeypatch.setenv("VERTEX_API_KEY", "good-key")
453
- monkeypatch.delenv("VERTEX_SERVICE_ACCOUNT_JSON", raising=False)
454
 
455
- models_vertex = [ModelInfo(
456
  model_id="models/gemini-2.0-flash",
457
  display_name="Gemini 2.0 Flash",
458
- provider=ProviderType.VERTEX_API_KEY,
459
  supports_vision=True,
460
  )]
461
 
462
  with patch.object(GoogleAIProvider, "list_models", side_effect=Exception("API key invalid")):
463
- with patch.object(VertexAPIKeyProvider, "list_models", return_value=models_vertex):
464
  result = list_all_models()
465
 
466
  assert len(result) == 1
467
- assert result[0].provider == ProviderType.VERTEX_API_KEY
468
 
469
 
470
  # ---------------------------------------------------------------------------
 
232
  assert VertexAPIKeyProvider().is_configured() is False
233
 
234
 
235
+ def test_vertex_key_provider_always_unavailable_even_with_key(monkeypatch):
236
+ """aiplatform.googleapis.com rejette les clés API → is_configured() toujours False."""
237
  monkeypatch.setenv("VERTEX_API_KEY", "fake-vertex-key")
238
+ assert VertexAPIKeyProvider().is_configured() is False
239
 
240
 
241
  def test_vertex_key_provider_type():
242
  assert VertexAPIKeyProvider().provider_type == ProviderType.VERTEX_API_KEY
243
 
244
 
245
+ def test_vertex_key_provider_list_models_raises(monkeypatch):
246
+ """list_models() et generate_content() lèvent RuntimeError (provider indisponible)."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
  monkeypatch.setenv("VERTEX_API_KEY", "fake-vertex-key")
248
+ with pytest.raises(RuntimeError, match="aiplatform"):
249
+ VertexAPIKeyProvider().list_models()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
250
 
251
 
252
+ def test_vertex_key_provider_generate_content_raises(monkeypatch):
 
253
  monkeypatch.setenv("VERTEX_API_KEY", "fake-vertex-key")
254
+ with pytest.raises(RuntimeError, match="aiplatform"):
255
+ VertexAPIKeyProvider().generate_content(b"img", "prompt", "gemini-2.0-flash")
 
 
 
 
 
 
 
256
 
257
 
258
  # ---------------------------------------------------------------------------
 
369
 
370
 
371
  def test_list_all_models_aggregates_multiple_providers(monkeypatch):
372
+ """Deux providers configurés les deux listes sont agrégées.
373
+ VertexAPIKeyProvider est toujours indisponible (aiplatform n'accepte pas les clés).
374
+ On utilise Google AI Studio + Vertex Service Account pour tester l'agrégation.
375
+ """
376
  monkeypatch.setenv("GOOGLE_AI_STUDIO_API_KEY", "fake-key-ai")
377
+ monkeypatch.delenv("VERTEX_API_KEY", raising=False)
378
+ monkeypatch.setenv("VERTEX_SERVICE_ACCOUNT_JSON", "{}") # déclenche is_configured()
379
 
380
  models_ai = [ModelInfo(
381
  model_id="models/gemini-1.5-pro",
 
383
  provider=ProviderType.GOOGLE_AI_STUDIO,
384
  supports_vision=True,
385
  )]
386
+ models_sa = [ModelInfo(
387
  model_id="models/gemini-2.0-flash",
388
  display_name="Gemini 2.0 Flash",
389
+ provider=ProviderType.VERTEX_SERVICE_ACCOUNT,
390
  supports_vision=True,
391
  )]
392
 
393
  with patch.object(GoogleAIProvider, "list_models", return_value=models_ai):
394
+ with patch.object(VertexServiceAccountProvider, "list_models", return_value=models_sa):
395
  result = list_all_models()
396
 
397
  assert len(result) == 2
398
  providers = {m.provider for m in result}
399
  assert ProviderType.GOOGLE_AI_STUDIO in providers
400
+ assert ProviderType.VERTEX_SERVICE_ACCOUNT in providers
401
 
402
 
403
  def test_list_all_models_failing_provider_is_skipped(monkeypatch):
404
+ """Un provider configuré qui échoue est ignoré ; l'autre est retourné."""
405
  monkeypatch.setenv("GOOGLE_AI_STUDIO_API_KEY", "bad-key")
406
+ monkeypatch.setenv("VERTEX_SERVICE_ACCOUNT_JSON", "{}")
407
+ monkeypatch.delenv("VERTEX_API_KEY", raising=False)
408
 
409
+ models_sa = [ModelInfo(
410
  model_id="models/gemini-2.0-flash",
411
  display_name="Gemini 2.0 Flash",
412
+ provider=ProviderType.VERTEX_SERVICE_ACCOUNT,
413
  supports_vision=True,
414
  )]
415
 
416
  with patch.object(GoogleAIProvider, "list_models", side_effect=Exception("API key invalid")):
417
+ with patch.object(VertexServiceAccountProvider, "list_models", return_value=models_sa):
418
  result = list_all_models()
419
 
420
  assert len(result) == 1
421
+ assert result[0].provider == ProviderType.VERTEX_SERVICE_ACCOUNT
422
 
423
 
424
  # ---------------------------------------------------------------------------
backend/tests/test_provider_mistral.py CHANGED
@@ -181,7 +181,8 @@ def _setup_list_models(monkeypatch, models: list[_FakeModel]) -> None:
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),
@@ -194,9 +195,10 @@ def test_list_models_dynamic_returns_all_non_embed(monkeypatch):
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):
@@ -237,11 +239,12 @@ def test_list_models_fallback_when_api_fails(monkeypatch):
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):
@@ -374,3 +377,132 @@ def test_generate_content_empty_response(monkeypatch):
374
 
375
  result = MistralProvider().generate_content(b"img", "prompt", "pixtral-large-latest")
376
  assert result == ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ mistral-ocr-latest est toujours ajouté s'il n'est pas dans la liste dynamique."""
186
  _setup_list_models(monkeypatch, [
187
  _FakeModel("pixtral-large-latest", vision=True),
188
  _FakeModel("pixtral-12b-2409", vision=True),
 
195
  assert "pixtral-large-latest" in ids
196
  assert "pixtral-12b-2409" in ids
197
  assert "mistral-large-latest" in ids
198
+ assert "mistral-ocr-latest" in ids # ajouté automatiquement
199
  assert "mistral-embed" not in ids
200
  assert "mistral-moderation" not in ids
201
+ assert len(models) == 4 # 3 filtres + OCR ajouté
202
 
203
 
204
  def test_list_models_vision_flag_from_capabilities(monkeypatch):
 
239
  monkeypatch.setitem(sys.modules, "mistralai", fake)
240
 
241
  models = MistralProvider().list_models()
242
+ # Fallback = _MISTRAL_FALLBACK_MODELS = Pixtral Large + 12B + mistral-ocr-latest
243
+ assert len(models) == 3
244
  ids = {m.model_id for m in models}
245
  assert "pixtral-large-latest" in ids
246
  assert "pixtral-12b-2409" in ids
247
+ assert "mistral-ocr-latest" in ids
248
 
249
 
250
  def test_list_models_raises_if_not_configured(monkeypatch):
 
377
 
378
  result = MistralProvider().generate_content(b"img", "prompt", "pixtral-large-latest")
379
  assert result == ""
380
+
381
+
382
+ # ---------------------------------------------------------------------------
383
+ # generate_content() — chemin OCR dédié (mistral-ocr-latest)
384
+ # ---------------------------------------------------------------------------
385
+
386
+ def test_generate_content_ocr_uses_ocr_endpoint(monkeypatch):
387
+ """mistral-ocr-latest utilise client.ocr.process(), pas client.chat.complete()."""
388
+ monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
389
+
390
+ ocr_calls: list[dict] = []
391
+ chat_calls: list = []
392
+
393
+ class _FakeOCRPage:
394
+ markdown = "Explicit liber primus..."
395
+
396
+ class _FakeOCRResponse:
397
+ pages = [_FakeOCRPage(), _FakeOCRPage()]
398
+
399
+ class _FakeOCR:
400
+ def process(self, *, model, document):
401
+ ocr_calls.append({"model": model, "document": document})
402
+ return _FakeOCRResponse()
403
+
404
+ class _FakeChat:
405
+ def complete(self, *, model, messages):
406
+ chat_calls.append(messages)
407
+
408
+ class _FakeMistral:
409
+ def __init__(self, api_key):
410
+ self.ocr = _FakeOCR()
411
+ self.chat = _FakeChat()
412
+ self.models = _FakeModelsAPI([])
413
+
414
+ fake = _types.ModuleType("mistralai")
415
+ fake.Mistral = _FakeMistral
416
+ monkeypatch.setitem(sys.modules, "mistralai", fake)
417
+
418
+ result = MistralProvider().generate_content(b"jpeg", "prompt", "mistral-ocr-latest")
419
+
420
+ # OCR endpoint appelé, pas chat
421
+ assert len(ocr_calls) == 1
422
+ assert len(chat_calls) == 0
423
+ assert ocr_calls[0]["model"] == "mistral-ocr-latest"
424
+ # Document doit être image_url avec data URI
425
+ doc = ocr_calls[0]["document"]
426
+ assert doc["type"] == "image_url"
427
+ assert doc["image_url"]["url"].startswith("data:image/jpeg;base64,")
428
+ # Résultat = pages concaténées
429
+ assert "Explicit liber primus..." in result
430
+
431
+
432
+ def test_generate_content_ocr_concatenates_pages(monkeypatch):
433
+ """OCR multi-pages : les markdowns sont concaténés par double saut de ligne."""
434
+ monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
435
+
436
+ class _Page:
437
+ def __init__(self, md):
438
+ self.markdown = md
439
+
440
+ class _FakeOCRResponse:
441
+ pages = [_Page("Page 1 texte"), _Page("Page 2 texte")]
442
+
443
+ class _FakeOCR:
444
+ def process(self, **kwargs):
445
+ return _FakeOCRResponse()
446
+
447
+ class _FakeMistral:
448
+ def __init__(self, api_key):
449
+ self.ocr = _FakeOCR()
450
+ self.models = _FakeModelsAPI([])
451
+
452
+ fake = _types.ModuleType("mistralai")
453
+ fake.Mistral = _FakeMistral
454
+ monkeypatch.setitem(sys.modules, "mistralai", fake)
455
+
456
+ result = MistralProvider().generate_content(b"jpeg", "prompt", "mistral-ocr-latest")
457
+
458
+ assert "Page 1 texte" in result
459
+ assert "Page 2 texte" in result
460
+ assert "\n\n" in result
461
+
462
+
463
+ def test_generate_content_ocr_model_not_called_for_vision(monkeypatch):
464
+ """Un modèle Pixtral NE passe PAS par l'endpoint OCR."""
465
+ monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
466
+ ocr_called = []
467
+
468
+ class _FakeOCR:
469
+ def process(self, **kwargs):
470
+ ocr_called.append(True)
471
+
472
+ class _FakeMistral:
473
+ def __init__(self, api_key):
474
+ self.ocr = _FakeOCR()
475
+ self.chat = type("C", (), {"complete": lambda self, **k: _FakeChatResponse()})()
476
+ self.models = _FakeModelsAPI([])
477
+
478
+ fake = _types.ModuleType("mistralai")
479
+ fake.Mistral = _FakeMistral
480
+ monkeypatch.setitem(sys.modules, "mistralai", fake)
481
+
482
+ MistralProvider().generate_content(b"jpeg", "prompt", "pixtral-large-latest")
483
+ assert len(ocr_called) == 0
484
+
485
+
486
+ def test_generate_content_ocr_model_detected_by_id(monkeypatch):
487
+ """Tout modèle contenant 'ocr' dans l'ID utilise l'endpoint OCR."""
488
+ monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
489
+ ocr_called = []
490
+
491
+ class _FakeOCR:
492
+ def process(self, **kwargs):
493
+ ocr_called.append(True)
494
+ class R:
495
+ pages = []
496
+ return R()
497
+
498
+ class _FakeMistral:
499
+ def __init__(self, api_key):
500
+ self.ocr = _FakeOCR()
501
+ self.models = _FakeModelsAPI([])
502
+
503
+ fake = _types.ModuleType("mistralai")
504
+ fake.Mistral = _FakeMistral
505
+ monkeypatch.setitem(sys.modules, "mistralai", fake)
506
+
507
+ MistralProvider().generate_content(b"jpeg", "prompt", "mistral-ocr-latest")
508
+ assert len(ocr_called) == 1