feat(health): gate vision + détection des modèles aveugles
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>
This commit is contained in:
60
tests/unit/test_model_health.py
Normal file
60
tests/unit/test_model_health.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""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}
|
||||
Reference in New Issue
Block a user