Détecte les modèles VLM/grounding « aveugles » (capabilities sans vision, ex. UI-TARS réimporté sans mmproj) pour éviter le HTTP 500 silencieux masqué par la cascade de grounding. - core/detection/model_health.py : has_vision_capability() (cache, fail-open) + smoke_check_models() - core/execution/input_handler.py : gate vision dans _grounding_ui_tars (skip propre vers niveau 3 si modèle aveugle, plus de 500 silencieux) - tests/unit/test_model_health.py : 6 tests (vision/aveugle/fail-open/cache/smoke) Incident 2026-06-08 : UI-TARS sans mmproj -> niveau 2 cascade en 500 silencieux, non détecté (hors chemin runtime démo + échec avalé par fallback + zéro test). NB : le smoke non bloquant au démarrage (api_stream.py startup) reste dans le WIP de la branche, mélangé au préflight non committé. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
98 lines
3.7 KiB
Python
98 lines
3.7 KiB
Python
"""Santé des modèles VLM/grounding — détection des modèles « aveugles ».
|
|
|
|
Motivation (incident 2026-06-08) : un modèle de grounding réimporté sans son projecteur
|
|
vision (`mmproj`) déclare des `capabilities` sans `vision` et renvoie HTTP 500 sur toute
|
|
requête image. Dans la cascade `find_element_on_screen`, l'échec était avalé (`return None`)
|
|
et masqué par le fallback VLM → panne invisible malgré les tests.
|
|
|
|
Ce module permet de :
|
|
- **gater** un appel image : vérifier que le modèle a `vision` avant de lui envoyer une image
|
|
(évite le 500, skip propre vers le niveau suivant) ;
|
|
- **smoke-tester** les modèles de grounding/VLM au démarrage : rendre une panne visible
|
|
immédiatement plutôt que noyée dans un `warning` runtime.
|
|
|
|
Volontairement sans dépendance lourde : un simple appel `/api/show` Ollama.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
from typing import Dict, List
|
|
|
|
import requests
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
DEFAULT_ENDPOINT = os.environ.get("OLLAMA_URL", "http://localhost:11434")
|
|
|
|
# Cache (endpoint::model) -> bool. Un modèle ne change pas de capacité en cours de session.
|
|
_VISION_CACHE: Dict[str, bool] = {}
|
|
|
|
|
|
def has_vision_capability(
|
|
model: str,
|
|
endpoint: str = DEFAULT_ENDPOINT,
|
|
*,
|
|
use_cache: bool = True,
|
|
timeout: float = 5.0,
|
|
) -> bool:
|
|
"""Retourne True si le modèle Ollama déclare la capacité ``vision``.
|
|
|
|
Interroge ``/api/show`` et lit ``capabilities``. Résultat mis en cache par
|
|
``(endpoint, model)``.
|
|
|
|
**Fail-open** : en cas d'erreur réseau/HTTP sur ``/api/show`` (indisponibilité
|
|
transitoire), retourne ``True`` — on ne bloque pas le grounding sur un doute ;
|
|
l'appel image en aval gérera l'échec. Seule une réponse explicite **sans** ``vision``
|
|
retourne ``False`` (modèle réellement aveugle).
|
|
"""
|
|
key = f"{endpoint}::{model}"
|
|
if use_cache and key in _VISION_CACHE:
|
|
return _VISION_CACHE[key]
|
|
try:
|
|
resp = requests.post(f"{endpoint}/api/show", json={"name": model}, timeout=timeout)
|
|
if resp.status_code != 200:
|
|
logger.debug("model_health: /api/show %s → HTTP %s (fail-open)", model, resp.status_code)
|
|
return True
|
|
caps = resp.json().get("capabilities", []) or []
|
|
has_vision = "vision" in caps
|
|
_VISION_CACHE[key] = has_vision
|
|
if not has_vision:
|
|
logger.warning(
|
|
"model_health: modèle '%s' SANS capacité 'vision' (capabilities=%s) — "
|
|
"modèle aveugle, les requêtes image échoueront",
|
|
model,
|
|
caps,
|
|
)
|
|
return has_vision
|
|
except Exception as e: # réseau, JSON, timeout
|
|
logger.debug("model_health: échec vérification vision %s: %s (fail-open)", model, e)
|
|
return True
|
|
|
|
|
|
def smoke_check_models(models: List[str], endpoint: str = DEFAULT_ENDPOINT) -> Dict[str, bool]:
|
|
"""Vérifie la capacité ``vision`` d'une liste de modèles (au démarrage/healthcheck).
|
|
|
|
Non bloquant : logue ``info`` par modèle sain, ``error`` par modèle aveugle.
|
|
Retourne ``{model: has_vision}``.
|
|
"""
|
|
results: Dict[str, bool] = {}
|
|
for m in models:
|
|
if not m:
|
|
continue
|
|
ok = has_vision_capability(m, endpoint, use_cache=False)
|
|
results[m] = ok
|
|
if ok:
|
|
logger.info("model_health[smoke]: %s → vision OK", m)
|
|
else:
|
|
logger.error(
|
|
"model_health[smoke]: %s → AVEUGLE (pas de vision) — grounding image KO sur ce modèle",
|
|
m,
|
|
)
|
|
return results
|
|
|
|
|
|
def reset_cache() -> None:
|
|
"""Vide le cache de capacités (tests, ou après réimport d'un modèle)."""
|
|
_VISION_CACHE.clear()
|