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.
181 lines
6.7 KiB
Python
181 lines
6.7 KiB
Python
"""Tests for the curated model-capability table (#81)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from backend.model_capabilities import (
|
|
CAPABILITY_KEYS,
|
|
get_capabilities_for_models,
|
|
get_model_capabilities,
|
|
model_supports_vision,
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _no_declared_caps():
|
|
"""Curated-table tests must not see the process-wide declaration cache."""
|
|
from backend.provider_capabilities import clear_declared_capabilities
|
|
|
|
clear_declared_capabilities()
|
|
yield
|
|
clear_declared_capabilities()
|
|
|
|
|
|
class TestCapabilityShape:
|
|
def test_every_result_has_all_keys(self):
|
|
for provider, model in [
|
|
("deepseek", "deepseek-chat"),
|
|
("gemini", "gemini-2.0-flash"),
|
|
("qwencloud", "qwen-vl-max"),
|
|
("openrouter", "unknown-model"),
|
|
]:
|
|
caps = get_model_capabilities(provider, model)
|
|
assert set(caps.keys()) == set(CAPABILITY_KEYS)
|
|
assert all(isinstance(v, bool) for v in caps.values())
|
|
|
|
|
|
class TestVisionDetection:
|
|
def test_vision_models_detected(self):
|
|
assert model_supports_vision("qwencloud", "qwen-vl-max")
|
|
assert model_supports_vision("openrouter", "openai/gpt-4o")
|
|
assert model_supports_vision("gemini", "gemini-2.0-flash")
|
|
assert model_supports_vision("mistral", "pixtral-large-latest")
|
|
|
|
def test_text_models_not_vision(self):
|
|
assert not model_supports_vision("deepseek", "deepseek-chat")
|
|
assert not model_supports_vision("xiaomi", "mimo-v2.5-pro")
|
|
|
|
def test_vision_implies_chat(self):
|
|
caps = get_model_capabilities("qwencloud", "qwen-vl-plus")
|
|
assert caps["vision"] is True
|
|
assert caps["chat"] is True
|
|
|
|
|
|
class TestSpecialModalities:
|
|
def test_embeddings(self):
|
|
caps = get_model_capabilities("openai", "text-embedding-3-small")
|
|
assert caps["embeddings"] is True
|
|
assert caps["chat"] is False
|
|
|
|
def test_rerank(self):
|
|
caps = get_model_capabilities("nvidia", "nvidia/llama-3.2-nv-rerankqa-1b-v2")
|
|
assert caps["rerank"] is True
|
|
|
|
def test_audio_transcription(self):
|
|
caps = get_model_capabilities("xiaomi", "mimo-v2.5-asr")
|
|
assert caps["audio_transcription"] is True
|
|
|
|
def test_audio_speech(self):
|
|
caps = get_model_capabilities("xiaomi", "mimo-v2.5-tts")
|
|
assert caps["audio_speech"] is True
|
|
|
|
def test_image_generation(self):
|
|
caps = get_model_capabilities("openai", "dall-e-3")
|
|
assert caps["images"] is True
|
|
|
|
|
|
class TestProviderDefaults:
|
|
def test_unknown_model_uses_provider_default(self):
|
|
caps = get_model_capabilities("deepseek", "some-new-model")
|
|
assert caps["chat"] is True
|
|
assert caps["vision"] is False
|
|
|
|
def test_unknown_provider_defaults_to_chat(self):
|
|
caps = get_model_capabilities("nope", "mystery")
|
|
assert caps["chat"] is True
|
|
|
|
def test_empty_model_uses_provider_default(self):
|
|
assert get_model_capabilities("gemini", "")["vision"] is True
|
|
|
|
|
|
class TestBatch:
|
|
def test_capabilities_for_models_map(self):
|
|
result = get_capabilities_for_models(
|
|
"qwencloud", ["qwen-max", "qwen-vl-max"]
|
|
)
|
|
assert result["qwen-max"]["vision"] is False
|
|
assert result["qwen-vl-max"]["vision"] is True
|
|
|
|
|
|
class TestMistralFamilies:
|
|
"""BUG-044 — the curated fallback must know the current Mistral families.
|
|
|
|
This fallback is what answers when the provider declaration is unavailable
|
|
(no API key, offline, cold cache), so it has to agree with what
|
|
``api.mistral.ai/v1/models`` declares today. Before the fix, no Mistral
|
|
model at all was reported as vision-capable — only the dead ``pixtral``
|
|
pattern matched.
|
|
"""
|
|
|
|
@pytest.mark.parametrize(
|
|
"model",
|
|
[
|
|
"mistral-small-latest",
|
|
"mistral-small-2603",
|
|
"mistral-medium-latest",
|
|
"mistral-medium-3.5",
|
|
"ministral-3b-latest",
|
|
"ministral-8b-2512",
|
|
"ministral-14b-latest",
|
|
"magistral-small-latest",
|
|
"magistral-medium-latest",
|
|
"mistral-vibe-cli-latest",
|
|
"pixtral-12b-2409",
|
|
],
|
|
)
|
|
def test_vision_chat_families(self, model):
|
|
caps = get_model_capabilities("mistral", model)
|
|
assert caps["vision"] is True, model
|
|
assert caps["chat"] is True, model
|
|
|
|
@pytest.mark.parametrize(
|
|
"model",
|
|
[
|
|
"mistral-large-latest",
|
|
"codestral-latest",
|
|
"open-mixtral-8x7b",
|
|
"voxtral-small-latest",
|
|
"mistral-code-latest",
|
|
],
|
|
)
|
|
def test_text_only_models_are_not_vision(self, model):
|
|
caps = get_model_capabilities("mistral", model)
|
|
assert caps["vision"] is False, model
|
|
assert caps["chat"] is True, model
|
|
|
|
def test_ocr_models_are_vision_without_chat(self):
|
|
caps = get_model_capabilities("mistral", "mistral-ocr-latest")
|
|
assert caps["vision"] is True
|
|
assert caps["chat"] is False
|
|
|
|
def test_embeddings_only_for_the_embed_models(self):
|
|
assert get_model_capabilities("mistral", "mistral-embed")["embeddings"] is True
|
|
# Regression: every Mistral model used to inherit embeddings=True from
|
|
# the provider default, wrongly branding chat models as embedders.
|
|
assert get_model_capabilities("mistral", "mistral-large-latest")["embeddings"] is False
|
|
assert get_model_capabilities("mistral", "some-unknown-mistral")["embeddings"] is False
|
|
|
|
def test_current_api_vision_models_are_all_covered(self):
|
|
"""Every model the API declares vision=true is vision-capable offline.
|
|
|
|
List captured from ``GET https://api.mistral.ai/v1/models`` (2026-09-15);
|
|
the live declaration layer covers renames, this guards the offline path.
|
|
"""
|
|
api_vision_models = [
|
|
"magistral-medium-latest", "magistral-small-latest",
|
|
"ministral-14b-2512", "ministral-14b-latest",
|
|
"ministral-3b-2512", "ministral-3b-latest",
|
|
"ministral-8b-2512", "ministral-8b-latest",
|
|
"mistral-medium", "mistral-medium-2604", "mistral-medium-3",
|
|
"mistral-medium-3-5", "mistral-medium-3.5", "mistral-medium-latest",
|
|
"mistral-ocr-2512", "mistral-ocr-3", "mistral-ocr-3-0",
|
|
"mistral-ocr-4", "mistral-ocr-4-0", "mistral-ocr-4-1",
|
|
"mistral-ocr-latest",
|
|
"mistral-small-2603", "mistral-small-latest",
|
|
"mistral-vibe-cli-fast", "mistral-vibe-cli-latest",
|
|
"mistral-vibe-cli-with-tools",
|
|
]
|
|
missing = [m for m in api_vision_models if not model_supports_vision("mistral", m)]
|
|
assert missing == [], f"curated fallback misses vision for: {missing}"
|