Spaces:
Build error
refactor: use API-reported supports_vision instead of hardcoded model names
Browse filesThe vision capability of AI models is now determined entirely by the
provider API (e.g. Mistral capabilities.vision) and persisted in the
database, rather than guessing from model name substrings.
Changes:
- ModelConfigDB: add supports_vision column (Boolean)
- PUT /corpora/{id}/model: accept and persist supports_vision from client
- generate_content(): add supports_vision param, passed from DB through
ModelConfig → analyzer → provider, no more name-based heuristics
- provider_mistral._model_supports_vision(): use only capabilities.vision
from the API response, return False without model object (safe default)
- job_runner: read supports_vision from DB instead of hardcoding True
- Frontend: send supports_vision from model list when selecting a model
https://claude.ai/code/session_01Sxmf2zTTcjaQeZXCeXaCxr
- backend/app/api/v1/models_api.py +4 -0
- backend/app/models/model_config_db.py +2 -1
- backend/app/services/ai/analyzer.py +1 -0
- backend/app/services/ai/base.py +9 -1
- backend/app/services/ai/provider_google_ai.py +1 -1
- backend/app/services/ai/provider_mistral.py +21 -18
- backend/app/services/ai/provider_vertex_key.py +1 -1
- backend/app/services/ai/provider_vertex_sa.py +1 -1
- backend/app/services/job_runner.py +1 -1
- backend/tests/test_provider_mistral.py +25 -26
- frontend/src/lib/api.ts +3 -0
- frontend/src/pages/Admin.tsx +1 -1
|
@@ -45,6 +45,7 @@ class ModelSelectRequest(BaseModel):
|
|
| 45 |
model_id: str = Field(..., min_length=1, max_length=256)
|
| 46 |
provider_type: str = Field(..., min_length=1, max_length=64)
|
| 47 |
display_name: str = Field("", max_length=256)
|
|
|
|
| 48 |
|
| 49 |
|
| 50 |
class ModelConfigResponse(BaseModel):
|
|
@@ -54,6 +55,7 @@ class ModelConfigResponse(BaseModel):
|
|
| 54 |
provider_type: str
|
| 55 |
selected_model_id: str
|
| 56 |
selected_model_display_name: str
|
|
|
|
| 57 |
updated_at: datetime
|
| 58 |
|
| 59 |
|
|
@@ -133,6 +135,7 @@ async def set_corpus_model(
|
|
| 133 |
provider_type=body.provider_type,
|
| 134 |
selected_model_id=body.model_id,
|
| 135 |
selected_model_display_name=display_name,
|
|
|
|
| 136 |
updated_at=datetime.now(timezone.utc),
|
| 137 |
)
|
| 138 |
db.add(config)
|
|
@@ -140,6 +143,7 @@ async def set_corpus_model(
|
|
| 140 |
config.provider_type = body.provider_type
|
| 141 |
config.selected_model_id = body.model_id
|
| 142 |
config.selected_model_display_name = display_name
|
|
|
|
| 143 |
config.updated_at = datetime.now(timezone.utc)
|
| 144 |
|
| 145 |
await db.commit()
|
|
|
|
| 45 |
model_id: str = Field(..., min_length=1, max_length=256)
|
| 46 |
provider_type: str = Field(..., min_length=1, max_length=64)
|
| 47 |
display_name: str = Field("", max_length=256)
|
| 48 |
+
supports_vision: bool = Field(True)
|
| 49 |
|
| 50 |
|
| 51 |
class ModelConfigResponse(BaseModel):
|
|
|
|
| 55 |
provider_type: str
|
| 56 |
selected_model_id: str
|
| 57 |
selected_model_display_name: str
|
| 58 |
+
supports_vision: bool
|
| 59 |
updated_at: datetime
|
| 60 |
|
| 61 |
|
|
|
|
| 135 |
provider_type=body.provider_type,
|
| 136 |
selected_model_id=body.model_id,
|
| 137 |
selected_model_display_name=display_name,
|
| 138 |
+
supports_vision=body.supports_vision,
|
| 139 |
updated_at=datetime.now(timezone.utc),
|
| 140 |
)
|
| 141 |
db.add(config)
|
|
|
|
| 143 |
config.provider_type = body.provider_type
|
| 144 |
config.selected_model_id = body.model_id
|
| 145 |
config.selected_model_display_name = display_name
|
| 146 |
+
config.supports_vision = body.supports_vision
|
| 147 |
config.updated_at = datetime.now(timezone.utc)
|
| 148 |
|
| 149 |
await db.commit()
|
|
@@ -8,7 +8,7 @@ La clé API n'est JAMAIS stockée ici (R06) — elle reste dans l'environnement.
|
|
| 8 |
from datetime import datetime
|
| 9 |
|
| 10 |
# 2. third-party
|
| 11 |
-
from sqlalchemy import DateTime, ForeignKey, String
|
| 12 |
from sqlalchemy.orm import Mapped, mapped_column
|
| 13 |
|
| 14 |
# 3. local
|
|
@@ -26,4 +26,5 @@ class ModelConfigDB(Base):
|
|
| 26 |
provider_type: Mapped[str] = mapped_column(String, nullable=False)
|
| 27 |
selected_model_id: Mapped[str] = mapped_column(String, nullable=False)
|
| 28 |
selected_model_display_name: Mapped[str] = mapped_column(String, nullable=False)
|
|
|
|
| 29 |
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
|
|
|
| 8 |
from datetime import datetime
|
| 9 |
|
| 10 |
# 2. third-party
|
| 11 |
+
from sqlalchemy import Boolean, DateTime, ForeignKey, String
|
| 12 |
from sqlalchemy.orm import Mapped, mapped_column
|
| 13 |
|
| 14 |
# 3. local
|
|
|
|
| 26 |
provider_type: Mapped[str] = mapped_column(String, nullable=False)
|
| 27 |
selected_model_id: Mapped[str] = mapped_column(String, nullable=False)
|
| 28 |
selected_model_display_name: Mapped[str] = mapped_column(String, nullable=False)
|
| 29 |
+
supports_vision: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
| 30 |
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
|
@@ -121,6 +121,7 @@ def run_primary_analysis(
|
|
| 121 |
image_bytes=jpeg_bytes,
|
| 122 |
prompt=prompt_text,
|
| 123 |
model_id=model_config.selected_model_id,
|
|
|
|
| 124 |
)
|
| 125 |
|
| 126 |
# ── 4. Écriture ai_raw.json TOUJOURS EN PREMIER (R05) ─────────────────
|
|
|
|
| 121 |
image_bytes=jpeg_bytes,
|
| 122 |
prompt=prompt_text,
|
| 123 |
model_id=model_config.selected_model_id,
|
| 124 |
+
supports_vision=model_config.supports_vision,
|
| 125 |
)
|
| 126 |
|
| 127 |
# ── 4. Écriture ai_raw.json TOUJOURS EN PREMIER (R05) ─────────────────
|
|
@@ -42,13 +42,21 @@ class AIProvider(ABC):
|
|
| 42 |
...
|
| 43 |
|
| 44 |
@abstractmethod
|
| 45 |
-
def generate_content(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
"""Envoie une image + prompt à l'IA et retourne le texte brut de la réponse.
|
| 47 |
|
| 48 |
Args:
|
| 49 |
image_bytes: contenu JPEG de l'image dérivée.
|
| 50 |
prompt: texte du prompt rendu depuis le template.
|
| 51 |
model_id: identifiant technique du modèle à utiliser.
|
|
|
|
|
|
|
| 52 |
|
| 53 |
Returns:
|
| 54 |
Texte brut retourné par l'API (avant parsing).
|
|
|
|
| 42 |
...
|
| 43 |
|
| 44 |
@abstractmethod
|
| 45 |
+
def generate_content(
|
| 46 |
+
self,
|
| 47 |
+
image_bytes: bytes,
|
| 48 |
+
prompt: str,
|
| 49 |
+
model_id: str,
|
| 50 |
+
supports_vision: bool = True,
|
| 51 |
+
) -> str:
|
| 52 |
"""Envoie une image + prompt à l'IA et retourne le texte brut de la réponse.
|
| 53 |
|
| 54 |
Args:
|
| 55 |
image_bytes: contenu JPEG de l'image dérivée.
|
| 56 |
prompt: texte du prompt rendu depuis le template.
|
| 57 |
model_id: identifiant technique du modèle à utiliser.
|
| 58 |
+
supports_vision: True si le modèle accepte les images (déterminé
|
| 59 |
+
par l'API du provider lors du listing, stocké en BDD).
|
| 60 |
|
| 61 |
Returns:
|
| 62 |
Texte brut retourné par l'API (avant parsing).
|
|
@@ -55,7 +55,7 @@ class GoogleAIProvider(AIProvider):
|
|
| 55 |
)
|
| 56 |
return result
|
| 57 |
|
| 58 |
-
def generate_content(self, image_bytes: bytes, prompt: str, model_id: str) -> str:
|
| 59 |
if not self.is_configured():
|
| 60 |
raise RuntimeError(f"Variable d'environnement manquante : {_ENV_KEY}")
|
| 61 |
client = genai.Client(api_key=os.environ[_ENV_KEY])
|
|
|
|
| 55 |
)
|
| 56 |
return result
|
| 57 |
|
| 58 |
+
def generate_content(self, image_bytes: bytes, prompt: str, model_id: str, supports_vision: bool = True) -> str:
|
| 59 |
if not self.is_configured():
|
| 60 |
raise RuntimeError(f"Variable d'environnement manquante : {_ENV_KEY}")
|
| 61 |
client = genai.Client(api_key=os.environ[_ENV_KEY])
|
|
@@ -73,25 +73,21 @@ def _is_ocr_model(model_id: str) -> bool:
|
|
| 73 |
|
| 74 |
|
| 75 |
|
| 76 |
-
# Modèles Mistral connus pour supporter la vision (multimodaux).
|
| 77 |
-
# Mistral Small 25.01+ et Mistral Medium sont multimodaux.
|
| 78 |
-
# Cette liste sert de fallback quand capabilities.vision n'est pas exposé par le SDK.
|
| 79 |
-
_VISION_MODEL_SUBSTRINGS = ("pixtral", "vision", "ocr", "mistral-small", "mistral-medium")
|
| 80 |
-
|
| 81 |
-
|
| 82 |
def _model_supports_vision(model_id: str, model_obj: object = None) -> bool:
|
| 83 |
"""Détecte si un modèle Mistral supporte les entrées image.
|
| 84 |
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
|
|
|
| 88 |
"""
|
| 89 |
if model_obj is not None:
|
| 90 |
caps = getattr(model_obj, "capabilities", None)
|
| 91 |
if caps is not None:
|
| 92 |
return bool(getattr(caps, "vision", False))
|
| 93 |
-
|
| 94 |
-
|
|
|
|
| 95 |
|
| 96 |
|
| 97 |
class MistralProvider(AIProvider):
|
|
@@ -188,18 +184,25 @@ class MistralProvider(AIProvider):
|
|
| 188 |
)
|
| 189 |
return list(_MISTRAL_FALLBACK_MODELS)
|
| 190 |
|
| 191 |
-
def generate_content(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
"""Envoie image + prompt à Mistral et retourne le texte brut.
|
| 193 |
|
| 194 |
Trois chemins selon le modèle :
|
| 195 |
1. OCR (mistral-ocr-latest) :
|
| 196 |
client.ocr.process() → markdown de toutes les pages concaténées.
|
| 197 |
-
|
| 198 |
-
2. Vision (Pixtral) :
|
| 199 |
client.chat.complete() avec content multimodal (image base64 + texte).
|
| 200 |
-
3. Texte seul (
|
| 201 |
client.chat.complete() avec prompt texte uniquement.
|
| 202 |
-
|
|
|
|
|
|
|
| 203 |
"""
|
| 204 |
if not self.is_configured():
|
| 205 |
raise RuntimeError(
|
|
@@ -230,8 +233,8 @@ class MistralProvider(AIProvider):
|
|
| 230 |
getattr(page, "markdown", "") for page in pages
|
| 231 |
)
|
| 232 |
|
| 233 |
-
# ── Chemin 2 : Vision multimodale
|
| 234 |
-
if
|
| 235 |
content: object = [
|
| 236 |
{"type": "image_url", "image_url": {"url": data_url}},
|
| 237 |
{"type": "text", "text": prompt},
|
|
|
|
| 73 |
|
| 74 |
|
| 75 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
def _model_supports_vision(model_id: str, model_obj: object = None) -> bool:
|
| 77 |
"""Détecte si un modèle Mistral supporte les entrées image.
|
| 78 |
|
| 79 |
+
Source de vérité unique : capabilities.vision retourné par l'API Mistral.
|
| 80 |
+
Aucune liste hardcodée de noms de modèles — l'API fait autorité.
|
| 81 |
+
Si capabilities n'est pas disponible (SDK ancien), retourne False
|
| 82 |
+
par sécurité (le modèle sera utilisé en mode texte seul).
|
| 83 |
"""
|
| 84 |
if model_obj is not None:
|
| 85 |
caps = getattr(model_obj, "capabilities", None)
|
| 86 |
if caps is not None:
|
| 87 |
return bool(getattr(caps, "vision", False))
|
| 88 |
+
# Sans objet modèle (fallback statique), on ne peut pas deviner :
|
| 89 |
+
# retourner False pour éviter d'envoyer une image à un modèle texte seul.
|
| 90 |
+
return False
|
| 91 |
|
| 92 |
|
| 93 |
class MistralProvider(AIProvider):
|
|
|
|
| 184 |
)
|
| 185 |
return list(_MISTRAL_FALLBACK_MODELS)
|
| 186 |
|
| 187 |
+
def generate_content(
|
| 188 |
+
self,
|
| 189 |
+
image_bytes: bytes,
|
| 190 |
+
prompt: str,
|
| 191 |
+
model_id: str,
|
| 192 |
+
supports_vision: bool = True,
|
| 193 |
+
) -> str:
|
| 194 |
"""Envoie image + prompt à Mistral et retourne le texte brut.
|
| 195 |
|
| 196 |
Trois chemins selon le modèle :
|
| 197 |
1. OCR (mistral-ocr-latest) :
|
| 198 |
client.ocr.process() → markdown de toutes les pages concaténées.
|
| 199 |
+
2. Vision (supports_vision=True) :
|
|
|
|
| 200 |
client.chat.complete() avec content multimodal (image base64 + texte).
|
| 201 |
+
3. Texte seul (supports_vision=False) :
|
| 202 |
client.chat.complete() avec prompt texte uniquement.
|
| 203 |
+
|
| 204 |
+
Le flag supports_vision est déterminé dynamiquement par l'API Mistral
|
| 205 |
+
lors du listing des modèles (capabilities.vision), puis stocké en BDD.
|
| 206 |
"""
|
| 207 |
if not self.is_configured():
|
| 208 |
raise RuntimeError(
|
|
|
|
| 233 |
getattr(page, "markdown", "") for page in pages
|
| 234 |
)
|
| 235 |
|
| 236 |
+
# ── Chemin 2 : Vision multimodale ────────────────────────────────────
|
| 237 |
+
if supports_vision:
|
| 238 |
content: object = [
|
| 239 |
{"type": "image_url", "image_url": {"url": data_url}},
|
| 240 |
{"type": "text", "text": prompt},
|
|
@@ -57,5 +57,5 @@ class VertexAPIKeyProvider(AIProvider):
|
|
| 57 |
def list_models(self) -> list[ModelInfo]:
|
| 58 |
raise RuntimeError(_UNAVAILABLE_MSG)
|
| 59 |
|
| 60 |
-
def generate_content(self, image_bytes: bytes, prompt: str, model_id: str) -> str:
|
| 61 |
raise RuntimeError(_UNAVAILABLE_MSG)
|
|
|
|
| 57 |
def list_models(self) -> list[ModelInfo]:
|
| 58 |
raise RuntimeError(_UNAVAILABLE_MSG)
|
| 59 |
|
| 60 |
+
def generate_content(self, image_bytes: bytes, prompt: str, model_id: str, supports_vision: bool = True) -> str:
|
| 61 |
raise RuntimeError(_UNAVAILABLE_MSG)
|
|
@@ -85,7 +85,7 @@ class VertexServiceAccountProvider(AIProvider):
|
|
| 85 |
)
|
| 86 |
return result
|
| 87 |
|
| 88 |
-
def generate_content(self, image_bytes: bytes, prompt: str, model_id: str) -> str:
|
| 89 |
if not self.is_configured():
|
| 90 |
raise RuntimeError(f"Variable d'environnement manquante : {_ENV_KEY}")
|
| 91 |
client = self._build_client()
|
|
|
|
| 85 |
)
|
| 86 |
return result
|
| 87 |
|
| 88 |
+
def generate_content(self, image_bytes: bytes, prompt: str, model_id: str, supports_vision: bool = True) -> str:
|
| 89 |
if not self.is_configured():
|
| 90 |
raise RuntimeError(f"Variable d'environnement manquante : {_ENV_KEY}")
|
| 91 |
client = self._build_client()
|
|
@@ -126,7 +126,7 @@ async def _run_job_impl(job_id: str, db: AsyncSession) -> None:
|
|
| 126 |
selected_model_id=model_db.selected_model_id,
|
| 127 |
selected_model_display_name=model_db.selected_model_display_name,
|
| 128 |
provider=ProviderType(model_db.provider_type),
|
| 129 |
-
supports_vision=
|
| 130 |
last_fetched_at=model_db.updated_at,
|
| 131 |
available_models=[],
|
| 132 |
)
|
|
|
|
| 126 |
selected_model_id=model_db.selected_model_id,
|
| 127 |
selected_model_display_name=model_db.selected_model_display_name,
|
| 128 |
provider=ProviderType(model_db.provider_type),
|
| 129 |
+
supports_vision=model_db.supports_vision,
|
| 130 |
last_fetched_at=model_db.updated_at,
|
| 131 |
available_models=[],
|
| 132 |
)
|
|
@@ -88,39 +88,36 @@ def _make_fake_mistralai(models: list[_FakeModel] | None = None) -> _types.Modul
|
|
| 88 |
|
| 89 |
|
| 90 |
# ---------------------------------------------------------------------------
|
| 91 |
-
# _model_supports_vision() —
|
| 92 |
# ---------------------------------------------------------------------------
|
| 93 |
|
| 94 |
-
def
|
| 95 |
-
|
| 96 |
-
assert _model_supports_vision("pixtral-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
def test_vision_detection_multimodal_models_by_name():
|
| 100 |
-
"""Mistral Small 25.01+ et Mistral Medium sont multimodaux."""
|
| 101 |
-
assert _model_supports_vision("mistral-small-latest") is True
|
| 102 |
-
assert _model_supports_vision("mistral-small-2501") is True
|
| 103 |
-
assert _model_supports_vision("mistral-medium-latest") is True
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
def test_vision_detection_text_models_by_name():
|
| 107 |
-
assert _model_supports_vision("mistral-large-latest") is False
|
| 108 |
assert _model_supports_vision("codestral-latest") is False
|
| 109 |
|
| 110 |
|
| 111 |
-
def
|
|
|
|
| 112 |
m_vision = _FakeModel("some-model", vision=True)
|
| 113 |
m_text = _FakeModel("some-model", vision=False)
|
| 114 |
assert _model_supports_vision("some-model", m_vision) is True
|
| 115 |
assert _model_supports_vision("some-model", m_text) is False
|
| 116 |
|
| 117 |
|
| 118 |
-
def
|
| 119 |
-
"""capabilities.vision=False
|
| 120 |
m = _FakeModel("pixtral-test", vision=False)
|
| 121 |
assert _model_supports_vision("pixtral-test", m) is False
|
| 122 |
|
| 123 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
# ---------------------------------------------------------------------------
|
| 125 |
# is_configured()
|
| 126 |
# ---------------------------------------------------------------------------
|
|
@@ -269,25 +266,27 @@ def test_list_models_fallback_backward_compat():
|
|
| 269 |
# ---------------------------------------------------------------------------
|
| 270 |
|
| 271 |
def test_generate_content_vision_model_returns_text(monkeypatch):
|
| 272 |
-
"""Modèle vision
|
| 273 |
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
| 274 |
fake = _make_fake_mistralai()
|
| 275 |
monkeypatch.setitem(sys.modules, "mistralai", fake)
|
| 276 |
|
| 277 |
result = MistralProvider().generate_content(
|
| 278 |
-
b"fake-jpeg", "Analyse ce folio.", "pixtral-large-latest"
|
|
|
|
| 279 |
)
|
| 280 |
assert result == "Voici le JSON de la page."
|
| 281 |
|
| 282 |
|
| 283 |
def test_generate_content_text_model_returns_text(monkeypatch):
|
| 284 |
-
"""Modèle texte (
|
| 285 |
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
| 286 |
fake = _make_fake_mistralai()
|
| 287 |
monkeypatch.setitem(sys.modules, "mistralai", fake)
|
| 288 |
|
| 289 |
result = MistralProvider().generate_content(
|
| 290 |
-
b"fake-jpeg", "Analyse ce folio.", "mistral-large-latest"
|
|
|
|
| 291 |
)
|
| 292 |
assert result == "Voici le JSON de la page."
|
| 293 |
|
|
@@ -311,7 +310,7 @@ def test_generate_content_vision_sends_image_url(monkeypatch):
|
|
| 311 |
fake.Mistral = _FakeMistral
|
| 312 |
monkeypatch.setitem(sys.modules, "mistralai", fake)
|
| 313 |
|
| 314 |
-
MistralProvider().generate_content(b"jpeg", "prompt", "pixtral-large-latest")
|
| 315 |
|
| 316 |
assert len(captured) == 1
|
| 317 |
content = captured[0]["content"]
|
|
@@ -322,7 +321,7 @@ def test_generate_content_vision_sends_image_url(monkeypatch):
|
|
| 322 |
|
| 323 |
|
| 324 |
def test_generate_content_text_sends_string_content(monkeypatch):
|
| 325 |
-
"""Modèle texte : le message content est une chaîne (pas d'image)."""
|
| 326 |
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
| 327 |
captured: list[dict] = []
|
| 328 |
|
|
@@ -340,7 +339,7 @@ def test_generate_content_text_sends_string_content(monkeypatch):
|
|
| 340 |
fake.Mistral = _FakeMistral
|
| 341 |
monkeypatch.setitem(sys.modules, "mistralai", fake)
|
| 342 |
|
| 343 |
-
MistralProvider().generate_content(b"jpeg", "mon prompt", "mistral-large-latest")
|
| 344 |
|
| 345 |
assert len(captured) == 1
|
| 346 |
assert captured[0]["content"] == "mon prompt"
|
|
@@ -485,7 +484,7 @@ def test_generate_content_ocr_model_not_called_for_vision(monkeypatch):
|
|
| 485 |
fake.Mistral = _FakeMistral
|
| 486 |
monkeypatch.setitem(sys.modules, "mistralai", fake)
|
| 487 |
|
| 488 |
-
MistralProvider().generate_content(b"jpeg", "prompt", "pixtral-large-latest")
|
| 489 |
assert len(ocr_called) == 0
|
| 490 |
|
| 491 |
|
|
|
|
| 88 |
|
| 89 |
|
| 90 |
# ---------------------------------------------------------------------------
|
| 91 |
+
# _model_supports_vision() — détection dynamique via l'API
|
| 92 |
# ---------------------------------------------------------------------------
|
| 93 |
|
| 94 |
+
def test_vision_detection_without_model_obj_returns_false():
|
| 95 |
+
"""Sans objet modèle (pas de capabilities), retourne False par sécurité."""
|
| 96 |
+
assert _model_supports_vision("pixtral-large-latest") is False
|
| 97 |
+
assert _model_supports_vision("mistral-small-latest") is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
assert _model_supports_vision("codestral-latest") is False
|
| 99 |
|
| 100 |
|
| 101 |
+
def test_vision_detection_uses_capabilities_from_api():
|
| 102 |
+
"""La source de vérité est capabilities.vision retourné par l'API Mistral."""
|
| 103 |
m_vision = _FakeModel("some-model", vision=True)
|
| 104 |
m_text = _FakeModel("some-model", vision=False)
|
| 105 |
assert _model_supports_vision("some-model", m_vision) is True
|
| 106 |
assert _model_supports_vision("some-model", m_text) is False
|
| 107 |
|
| 108 |
|
| 109 |
+
def test_vision_detection_capabilities_false_on_any_model():
|
| 110 |
+
"""capabilities.vision=False → pas de vision, quel que soit le nom."""
|
| 111 |
m = _FakeModel("pixtral-test", vision=False)
|
| 112 |
assert _model_supports_vision("pixtral-test", m) is False
|
| 113 |
|
| 114 |
|
| 115 |
+
def test_vision_detection_capabilities_true_on_any_model():
|
| 116 |
+
"""capabilities.vision=True → vision activée, quel que soit le nom."""
|
| 117 |
+
m = _FakeModel("mistral-small-latest", vision=True)
|
| 118 |
+
assert _model_supports_vision("mistral-small-latest", m) is True
|
| 119 |
+
|
| 120 |
+
|
| 121 |
# ---------------------------------------------------------------------------
|
| 122 |
# is_configured()
|
| 123 |
# ---------------------------------------------------------------------------
|
|
|
|
| 266 |
# ---------------------------------------------------------------------------
|
| 267 |
|
| 268 |
def test_generate_content_vision_model_returns_text(monkeypatch):
|
| 269 |
+
"""Modèle vision : envoie l'image et retourne la réponse."""
|
| 270 |
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
| 271 |
fake = _make_fake_mistralai()
|
| 272 |
monkeypatch.setitem(sys.modules, "mistralai", fake)
|
| 273 |
|
| 274 |
result = MistralProvider().generate_content(
|
| 275 |
+
b"fake-jpeg", "Analyse ce folio.", "pixtral-large-latest",
|
| 276 |
+
supports_vision=True,
|
| 277 |
)
|
| 278 |
assert result == "Voici le JSON de la page."
|
| 279 |
|
| 280 |
|
| 281 |
def test_generate_content_text_model_returns_text(monkeypatch):
|
| 282 |
+
"""Modèle texte (supports_vision=False) : envoie seulement le prompt."""
|
| 283 |
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
| 284 |
fake = _make_fake_mistralai()
|
| 285 |
monkeypatch.setitem(sys.modules, "mistralai", fake)
|
| 286 |
|
| 287 |
result = MistralProvider().generate_content(
|
| 288 |
+
b"fake-jpeg", "Analyse ce folio.", "mistral-large-latest",
|
| 289 |
+
supports_vision=False,
|
| 290 |
)
|
| 291 |
assert result == "Voici le JSON de la page."
|
| 292 |
|
|
|
|
| 310 |
fake.Mistral = _FakeMistral
|
| 311 |
monkeypatch.setitem(sys.modules, "mistralai", fake)
|
| 312 |
|
| 313 |
+
MistralProvider().generate_content(b"jpeg", "prompt", "pixtral-large-latest", supports_vision=True)
|
| 314 |
|
| 315 |
assert len(captured) == 1
|
| 316 |
content = captured[0]["content"]
|
|
|
|
| 321 |
|
| 322 |
|
| 323 |
def test_generate_content_text_sends_string_content(monkeypatch):
|
| 324 |
+
"""Modèle texte (supports_vision=False) : le message content est une chaîne (pas d'image)."""
|
| 325 |
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
| 326 |
captured: list[dict] = []
|
| 327 |
|
|
|
|
| 339 |
fake.Mistral = _FakeMistral
|
| 340 |
monkeypatch.setitem(sys.modules, "mistralai", fake)
|
| 341 |
|
| 342 |
+
MistralProvider().generate_content(b"jpeg", "mon prompt", "mistral-large-latest", supports_vision=False)
|
| 343 |
|
| 344 |
assert len(captured) == 1
|
| 345 |
assert captured[0]["content"] == "mon prompt"
|
|
|
|
| 484 |
fake.Mistral = _FakeMistral
|
| 485 |
monkeypatch.setitem(sys.modules, "mistralai", fake)
|
| 486 |
|
| 487 |
+
MistralProvider().generate_content(b"jpeg", "prompt", "pixtral-large-latest", supports_vision=True)
|
| 488 |
assert len(ocr_called) == 0
|
| 489 |
|
| 490 |
|
|
@@ -280,11 +280,13 @@ export const selectModel = (
|
|
| 280 |
modelId: string,
|
| 281 |
displayName: string,
|
| 282 |
providerType: string,
|
|
|
|
| 283 |
): Promise<CorpusModelConfig> =>
|
| 284 |
put(`/api/v1/corpora/${corpusId}/model`, {
|
| 285 |
model_id: modelId,
|
| 286 |
display_name: displayName,
|
| 287 |
provider_type: providerType,
|
|
|
|
| 288 |
})
|
| 289 |
|
| 290 |
export const deleteCorpus = (id: string): Promise<void> =>
|
|
@@ -295,6 +297,7 @@ export interface CorpusModelConfig {
|
|
| 295 |
selected_model_id: string
|
| 296 |
selected_model_display_name: string
|
| 297 |
provider_type: string
|
|
|
|
| 298 |
updated_at: string
|
| 299 |
}
|
| 300 |
|
|
|
|
| 280 |
modelId: string,
|
| 281 |
displayName: string,
|
| 282 |
providerType: string,
|
| 283 |
+
supportsVision: boolean = true,
|
| 284 |
): Promise<CorpusModelConfig> =>
|
| 285 |
put(`/api/v1/corpora/${corpusId}/model`, {
|
| 286 |
model_id: modelId,
|
| 287 |
display_name: displayName,
|
| 288 |
provider_type: providerType,
|
| 289 |
+
supports_vision: supportsVision,
|
| 290 |
})
|
| 291 |
|
| 292 |
export const deleteCorpus = (id: string): Promise<void> =>
|
|
|
|
| 297 |
selected_model_id: string
|
| 298 |
selected_model_display_name: string
|
| 299 |
provider_type: string
|
| 300 |
+
supports_vision: boolean
|
| 301 |
updated_at: string
|
| 302 |
}
|
| 303 |
|
|
@@ -190,7 +190,7 @@ function ModelPanel({ corpusId, onSaved }: ModelPanelProps) {
|
|
| 190 |
setSavingModel(true)
|
| 191 |
const model = models.find((m) => m.model_id === selectedModelId)
|
| 192 |
try {
|
| 193 |
-
await selectModel(corpusId, selectedModelId, model?.display_name ?? selectedModelId, selectedProvider)
|
| 194 |
const updated = await getCorpusModel(corpusId)
|
| 195 |
setCurrentModel(updated)
|
| 196 |
setSaveSuccess(`Modele "${model?.display_name ?? selectedModelId}" associe.`)
|
|
|
|
| 190 |
setSavingModel(true)
|
| 191 |
const model = models.find((m) => m.model_id === selectedModelId)
|
| 192 |
try {
|
| 193 |
+
await selectModel(corpusId, selectedModelId, model?.display_name ?? selectedModelId, selectedProvider, model?.supports_vision ?? true)
|
| 194 |
const updated = await getCorpusModel(corpusId)
|
| 195 |
setCurrentModel(updated)
|
| 196 |
setSaveSuccess(`Modele "${model?.display_name ?? selectedModelId}" associe.`)
|