"""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()