Files
ObsiGate/backend/model_capabilities.py
T
bruno b69cb9b0f8
CI / lint (push) Successful in 1m20s
CI / security (push) Successful in 53s
CI / test (push) Successful in 2m27s
CI / build (push) Successful in 50s
CI / e2e (push) Successful in 10m48s
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.
2026-09-15 09:26:31 -04:00

184 lines
6.4 KiB
Python

"""Model-capability metadata for the AI assistant.
Two layers, in order of trust:
1. **Provider-declared** (:mod:`backend.provider_capabilities`) — when the
provider publishes per-model capabilities in its models endpoint (Mistral
``capabilities``, OpenRouter ``architecture``), that declaration *wins* for
every flag it mentions. The snapshot is cached by the model-list endpoint.
2. **Curated table** (this module) — a static, hand-maintained map of known
model-name patterns to capability flags with per-provider defaults. It fills
the flags the provider stays silent about, and is the only source for
providers and models that declare nothing (offline, no API key, DeepSeek,
NVIDIA, QwenCloud, Xiaomi…).
The UI uses the result to show, when a model is selected, which features it
supports (Chat, Embeddings, Rerank, Images, Video, Audio Speech, Audio
Transcriptions, Vision).
The curated layer is intentionally conservative: an unknown model falls back to
the provider default (usually ``chat`` only), so we never claim a capability the
model may not have. A provider declaration, on the other hand, is authoritative
in both directions — it can also *revoke* a flag the curated table guessed
wrongly (e.g. ``mistral-embed`` declares no chat).
"""
from __future__ import annotations
from typing import Any
from backend.provider_capabilities import get_declared_capabilities
# Ordered list of capability keys exposed to the UI. Keep in sync with the
# frontend ``AI_CAPABILITY_KEYS`` and the i18n ``ai.cap_*`` labels.
CAPABILITY_KEYS: tuple[str, ...] = (
"chat",
"embeddings",
"rerank",
"images",
"video",
"audio_speech",
"audio_transcription",
"vision",
)
def _caps(**kwargs: bool) -> dict[str, bool]:
"""Build a full capability dict (missing flags default to False)."""
base = {key: False for key in CAPABILITY_KEYS}
base.update(kwargs)
return base
# Provider defaults, used when no model-name pattern matches.
_PROVIDER_DEFAULTS: dict[str, dict[str, bool]] = {
"deepseek": _caps(chat=True),
"openrouter": _caps(chat=True),
"gemini": _caps(chat=True, vision=True, embeddings=True, audio_speech=True),
"ollama": _caps(chat=True, embeddings=True),
"nvidia": _caps(chat=True),
"qwencloud": _caps(chat=True),
"xiaomi": _caps(chat=True),
# Mistral: chat only. ``embeddings`` used to be assumed for every Mistral
# model, which wrongly labelled mistral-large / codestral as embedders
# (BUG-044); the ``embed`` rule below covers the real embedding models.
"mistral": _caps(chat=True),
}
# Ordered (substrings, capabilities) rules — the first matching rule wins.
# More specific modalities are listed before the broad vision/chat rule.
_MODEL_RULES: list[tuple[tuple[str, ...], dict[str, bool]]] = [
# Rerankers.
(("rerank", "cross-encoder"), _caps(rerank=True)),
# Embedding models (usually not chat-capable).
(
("text-embedding", "embed", "bge-", "e5-", "nomic-embed", "gte-"),
_caps(embeddings=True),
),
# Speech-to-text / transcription.
(
("whisper", "transcrib", "-asr", "asr-", "speech-to-text"),
_caps(audio_transcription=True),
),
# Text-to-speech.
(
("-tts", "text-to-speech", "voiceclone", "voicedesign"),
_caps(audio_speech=True),
),
# Image generation.
(
("dall-e", "stable-diffusion", "flux", "imagen", "image-gen"),
_caps(images=True),
),
# Video generation.
(("veo-", "sora", "video-gen", "-video"), _caps(video=True)),
# Document OCR models — image input, but not a chat endpoint.
(("mistral-ocr",), _caps(vision=True)),
# Vision-capable chat models (multimodal input).
(
(
"gpt-4o",
"gpt-4.1",
"gpt-5",
"o3",
"o4",
"claude-3",
"claude-4",
"gemini",
"qwen-vl",
"-vl-",
"vl-",
"vision",
"llava",
"pixtral",
"internvl",
"minicpm-v",
"llama-3.2-vision",
"mimo-vl",
# Mistral vision families (BUG-044): text-only mistral-large,
# codestral and voxtral are deliberately absent.
"ministral",
"magistral",
"mistral-small",
"mistral-medium",
"mistral-vibe-cli",
"labs-leanstral",
),
_caps(chat=True, vision=True),
),
]
def _curated_capabilities(provider: str, model: str) -> dict[str, bool]:
"""Curated table lookup (model rules first, then the provider default)."""
if model:
for needles, caps in _MODEL_RULES:
if any(needle in model for needle in needles):
return dict(caps)
return dict(_PROVIDER_DEFAULTS.get(provider, _caps(chat=True)))
def get_model_capabilities(provider: str, model: str) -> dict[str, bool]:
"""Return the capability flags for a ``provider``/``model`` pair.
A provider declaration (see :mod:`backend.provider_capabilities`) overrides
the curated table for every flag it mentions; the curated table supplies the
rest.
Args:
provider: Provider identifier (e.g. ``"deepseek"``). Case-insensitive.
model: Model identifier (e.g. ``"deepseek-chat"``). May be empty, in
which case the provider default is returned.
Returns:
A dict with every key of :data:`CAPABILITY_KEYS` and boolean values.
"""
provider = (provider or "").strip().lower()
name = (model or "").strip().lower()
caps = _curated_capabilities(provider, name)
declared = get_declared_capabilities(provider, name)
if declared:
caps.update({key: value for key, value in declared.items() if key in CAPABILITY_KEYS})
return caps
def get_capabilities_for_models(
provider: str, models: list[str]
) -> dict[str, dict[str, bool]]:
"""Return a ``{model: capabilities}`` map for a list of models."""
return {model: get_model_capabilities(provider, model) for model in models}
def model_supports_vision(provider: str, model: str) -> bool:
"""True when the given model can accept image input."""
return bool(get_model_capabilities(provider, model).get("vision"))
def capabilities_payload(provider: str, model: str) -> dict[str, Any]:
"""Serialize capabilities for API responses."""
return {
"provider": provider,
"model": model,
"capabilities": get_model_capabilities(provider, model),
}