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>
61 lines
2.2 KiB
Python
61 lines
2.2 KiB
Python
"""Tests unitaires de core.detection.model_health (détection modèles aveugles)."""
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from core.detection import model_health
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clear_cache():
|
|
model_health.reset_cache()
|
|
yield
|
|
model_health.reset_cache()
|
|
|
|
|
|
def _resp(status=200, capabilities=None):
|
|
r = MagicMock()
|
|
r.status_code = status
|
|
r.json.return_value = {"capabilities": capabilities if capabilities is not None else []}
|
|
return r
|
|
|
|
|
|
def test_vision_model_returns_true():
|
|
with patch("core.detection.model_health.requests.post", return_value=_resp(200, ["completion", "vision"])):
|
|
assert model_health.has_vision_capability("gemma4:26b", "http://x:11434") is True
|
|
|
|
|
|
def test_blind_model_returns_false():
|
|
with patch("core.detection.model_health.requests.post", return_value=_resp(200, ["tools", "completion"])):
|
|
assert model_health.has_vision_capability("0000/ui-tars-1.5-7b-q8_0:7b", "http://x:11434") is False
|
|
|
|
|
|
def test_http_error_is_fail_open():
|
|
with patch("core.detection.model_health.requests.post", return_value=_resp(404)):
|
|
assert model_health.has_vision_capability("absent:latest", "http://x:11434") is True
|
|
|
|
|
|
def test_exception_is_fail_open():
|
|
with patch("core.detection.model_health.requests.post", side_effect=ConnectionError("boom")):
|
|
assert model_health.has_vision_capability("any:latest", "http://x:11434") is True
|
|
|
|
|
|
def test_cache_avoids_second_call():
|
|
with patch("core.detection.model_health.requests.post", return_value=_resp(200, ["vision"])) as p:
|
|
model_health.has_vision_capability("m", "http://x:11434")
|
|
model_health.has_vision_capability("m", "http://x:11434")
|
|
assert p.call_count == 1 # 2e appel servi par le cache
|
|
|
|
|
|
def test_smoke_check_reports_each_model():
|
|
def _fake_post(url, json=None, timeout=None):
|
|
name = (json or {}).get("name", "")
|
|
caps = ["vision"] if name == "good" else ["completion"]
|
|
return _resp(200, caps)
|
|
|
|
with patch("core.detection.model_health.requests.post", side_effect=_fake_post):
|
|
res = model_health.smoke_check_models(["good", "blind"], "http://x:11434")
|
|
assert res == {"good": True, "blind": False}
|