fix(ai): BUG-044 capacités des modèles lues chez le fournisseur (Mistral vision)
Le panneau de modèle par défaut et la bulle ⓘ n'affichaient aucun modèle Mistral « Vision capable » alors que GET api.mistral.ai/v1/models en déclare 28 : la table de capacités était entièrement statique et aucun de ses motifs ne correspondait aux familles Mistral actuelles (seul `pixtral`, retiré de l'API, les matchait). - backend/provider_capabilities.py (nouveau) : capacités déclarées par le fournisseur (Mistral `capabilities`, OpenRouter `architecture`), détectées par la forme du payload, snapshot en cache process-wide (TTL 30 min, surchargeable par AI_CAPABILITIES_TTL_SECONDS) rempli par GET /api/config/ai-models. - backend/model_capabilities.py : une déclaration prime sur la table statique pour chaque drapeau mentionné ; la table ne comble que le reste (Mistral ne déclare jamais `embedding`). Table corrigée pour le repli hors ligne : familles vision Mistral (ministral, magistral, mistral-small, mistral-medium, mistral-vibe-cli, labs-leanstral), mistral-ocr = vision sans chat, et défaut du fournisseur Mistral sans `embeddings` (mistral-large / codestral n'étaient plus des « embedders »). - backend/ai_routes.py : GET /api/ai/model-capabilities reste sans appel réseau (cache froid → table statique). - Tests : tests/test_provider_capabilities.py (nouveau), TestMistralFamilies et TestDeclaredCapabilities (bout en bout via l'API). - Docs : CHANGELOG [Unreleased], registre + journal ISSUES_TODOLIST, fiche docs/features/ai-provider-picker.md (§L). Vérifié : 28/28 modèles vision déclarés par Mistral détectés (0 avant), 0 écart dans les deux sens ; pytest 1007 passed / 6 skipped ; ruff 0 (backend) ; mypy 0 ; tests frontend unit 9/9 + IA 66/66 + validate-imports 37 modules ; instance de test reconstruite et vérifiée sur http://localhost:2020.
This commit is contained in:
@@ -14,6 +14,16 @@ from fastapi.testclient import TestClient
|
||||
|
||||
from backend.main import _FALLBACK_MODELS, app
|
||||
|
||||
#: Trimmed-down copy of what api.mistral.ai/v1/models really returns (BUG-044).
|
||||
MISTRAL_LIVE_PAYLOAD = {
|
||||
"object": "list",
|
||||
"data": [
|
||||
{"id": "mistral-small-latest", "capabilities": {"completion_chat": True, "vision": True}},
|
||||
{"id": "mistral-medium-latest", "capabilities": {"completion_chat": True, "vision": True}},
|
||||
{"id": "mistral-embed", "capabilities": {"completion_chat": False, "vision": False}},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_client(tmp_path):
|
||||
@@ -83,6 +93,16 @@ def _login_admin(client):
|
||||
return resp.json()["access_token"]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_declared_caps():
|
||||
"""Provider declarations are cached process-wide: isolate every test."""
|
||||
from backend.provider_capabilities import clear_declared_capabilities
|
||||
|
||||
clear_declared_capabilities()
|
||||
yield
|
||||
clear_declared_capabilities()
|
||||
|
||||
|
||||
# ── Unit tests for the fallback table itself ─────────────────────────────
|
||||
|
||||
|
||||
@@ -339,3 +359,112 @@ class TestListModelsEndpoint:
|
||||
assert xiaomi_hdrs["api-key"] == "fake-key"
|
||||
# Bearer prefix must NOT be in the api-key value
|
||||
assert "Bearer" not in xiaomi_hdrs["api-key"]
|
||||
|
||||
|
||||
class TestDeclaredCapabilities:
|
||||
"""BUG-044 — the provider's own ``capabilities[]`` must reach the UI.
|
||||
|
||||
``GET /api/config/ai-models`` is what fills the capability cache; the picker
|
||||
bubble (``GET /api/ai/model-capabilities``) and the vision gate then read it.
|
||||
Before the fix no Mistral model was ever reported as vision-capable.
|
||||
"""
|
||||
|
||||
def _serve_payload(self, monkeypatch, payload):
|
||||
"""Make the provider models call return ``payload`` verbatim."""
|
||||
import json
|
||||
import urllib.request as global_urllib_mod
|
||||
|
||||
body = json.dumps(payload).encode()
|
||||
|
||||
def fake_Request(url, *args, **kwargs):
|
||||
class FakeResp:
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *a): pass
|
||||
def read(self): return body
|
||||
|
||||
return FakeResp()
|
||||
|
||||
monkeypatch.setattr(global_urllib_mod, "Request", fake_Request, raising=True)
|
||||
monkeypatch.setattr(
|
||||
global_urllib_mod, "urlopen", lambda req, timeout=None: req, raising=True
|
||||
)
|
||||
|
||||
def _fake_key(self, monkeypatch):
|
||||
"""main.py binds get_ai_key at import: patch that binding too."""
|
||||
import backend.ai as aimod
|
||||
import backend.main as bmain
|
||||
|
||||
monkeypatch.setattr(aimod, "get_ai_key", lambda name: "fake-mistral-key")
|
||||
if hasattr(bmain, "get_ai_key"):
|
||||
monkeypatch.setattr(bmain, "get_ai_key", lambda name: "fake-mistral-key")
|
||||
|
||||
def test_declared_vision_reaches_both_endpoints(self, admin_client, monkeypatch):
|
||||
self._fake_key(monkeypatch)
|
||||
self._serve_payload(monkeypatch, MISTRAL_LIVE_PAYLOAD)
|
||||
|
||||
token = _login_admin(admin_client)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
listing = admin_client.get(
|
||||
"/api/config/ai-models",
|
||||
params={"provider": "mistral"},
|
||||
headers=headers,
|
||||
)
|
||||
assert listing.status_code == 200, listing.text
|
||||
data = listing.json()
|
||||
assert data["source"] == "live"
|
||||
assert data["capabilities"]["mistral-small-latest"]["vision"] is True
|
||||
assert data["capabilities"]["mistral-medium-latest"]["vision"] is True
|
||||
# The declaration also revokes: mistral-embed is not a chat model.
|
||||
assert data["capabilities"]["mistral-embed"]["chat"] is False
|
||||
assert data["capabilities"]["mistral-embed"]["embeddings"] is True
|
||||
|
||||
for model in ("mistral-small-latest", "mistral-medium-latest"):
|
||||
detail = admin_client.get(
|
||||
"/api/ai/model-capabilities",
|
||||
params={"provider": "mistral", "model": model},
|
||||
headers=headers,
|
||||
)
|
||||
assert detail.status_code == 200, detail.text
|
||||
assert detail.json()["capabilities"]["vision"] is True, model
|
||||
|
||||
def test_text_only_mistral_models_stay_text_only(self, admin_client, monkeypatch):
|
||||
"""mistral-large-latest declares no vision in the real API — keep it so."""
|
||||
self._fake_key(monkeypatch)
|
||||
self._serve_payload(monkeypatch, MISTRAL_LIVE_PAYLOAD)
|
||||
|
||||
token = _login_admin(admin_client)
|
||||
resp = admin_client.get(
|
||||
"/api/config/ai-models",
|
||||
params={"provider": "mistral"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
caps = resp.json()["capabilities"]["mistral-large-latest"]
|
||||
assert caps["vision"] is False
|
||||
assert caps["embeddings"] is False
|
||||
|
||||
def test_curated_fallback_covers_mistral_without_any_api_call(
|
||||
self, admin_client, monkeypatch,
|
||||
):
|
||||
"""No API key: the curated list must still answer correctly (offline)."""
|
||||
import backend.ai as aimod
|
||||
import backend.main as bmain
|
||||
|
||||
monkeypatch.setattr(aimod, "get_ai_key", lambda name: None)
|
||||
if hasattr(bmain, "get_ai_key"):
|
||||
monkeypatch.setattr(bmain, "get_ai_key", lambda name: None)
|
||||
|
||||
token = _login_admin(admin_client)
|
||||
resp = admin_client.get(
|
||||
"/api/config/ai-models",
|
||||
params={"provider": "mistral"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = resp.json()
|
||||
assert data["source"] == "fallback"
|
||||
caps = data["capabilities"]
|
||||
assert caps["mistral-small-latest"]["vision"] is True
|
||||
assert caps["mistral-large-latest"]["vision"] is False
|
||||
assert caps["mistral-large-latest"]["embeddings"] is False
|
||||
|
||||
Reference in New Issue
Block a user