fix(agent): validation robuste des modeles Nvidia (payload production + repli 404/410) + logo noir/blanc
Validation: - _validate_chat_models utilise le payload de production (temperature 0.2, sans max_tokens) : un 200 a la validation == 200 a l'usage reel. - Race corrigee : payload reconstruit par modele (plus de noms croises entre requetes concurrentes) — la v5.1.8 retombait par intermittence sur la liste brute (68 modeles dont la plupart en 404/410). - Retente une fois sur timeout/5xx (les modeles lents mais fonctionnels survivent) ; rejette les 4xx (404 inconnu, 410 retire) ; garde les 429. - Verifie en live contre l'API NVIDIA : 12 modeles valides au lieu de 68. Repli runtime: - LLMClient._http_complete : sur 404/410, retente une fois avec le modele par defaut du provider et marque un notice dans LLMResponse. - AgentEngine emet un evenement SSE "notice" (bandeau .fd-ap-notice dans le panneau Agent, reset a chaque conversation) ; le modele reel est persistee. UI: - FAB (rond bas droite) et logo header du panneau : rond noir/blanc qui suit le theme clair/sombre (--text-primary / --bg-primary), eclair monochrome SVG a la place de l'emoji robot. Tests: - 2 nouveaux tests (429 garde / 410 retire ; repli runtime sur 410). - 278 tests passent.
This commit is contained in:
+99
-35
@@ -33,53 +33,78 @@ MOCK_CATALOG = [
|
||||
|
||||
|
||||
def _make_server(reject_non_chat: bool = True, accept: set | None = None,
|
||||
fail_all_chat: bool = False):
|
||||
fail_all_chat: bool = False, catalog: list | None = None,
|
||||
chat_status: dict | None = None):
|
||||
"""HTTP server mimicking an OpenAI-compatible /models + /chat/completions.
|
||||
|
||||
With ``reject_non_chat`` the chat endpoint returns 404 for any model whose
|
||||
id contains embed/rerank/flux, exactly like NVIDIA's public API.
|
||||
With ``fail_all_chat`` every chat call returns 404 (probe failure case).
|
||||
``accept`` is an optional allow-list; when set, only those models answer 200.
|
||||
``catalog`` overrides the GET /models listing.
|
||||
``chat_status`` is an optional {model → status_code} map that takes precedence.
|
||||
"""
|
||||
catalog = catalog or MOCK_CATALOG
|
||||
accept_models = accept if accept is not None else set(MOCK_CATALOG[:2])
|
||||
status_map = chat_status
|
||||
|
||||
def lookup_status(model: str) -> int | None:
|
||||
if callable(status_map):
|
||||
v = status_map(model)
|
||||
return v if isinstance(v, int) else None
|
||||
if isinstance(status_map, dict):
|
||||
return status_map.get(model)
|
||||
return None
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *args): # silence test noise
|
||||
pass
|
||||
def log_message(self, *args): # silence test noise
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
if self.path.endswith("/models"):
|
||||
body = json.dumps({"data": [{"id": m} for m in MOCK_CATALOG]}).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
else:
|
||||
self.send_response(404)
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
def do_GET(self):
|
||||
if self.path.endswith("/models"):
|
||||
body = json.dumps({"data": [{"id": m} for m in catalog]}).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
else:
|
||||
self.send_response(404)
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
|
||||
def do_POST(self):
|
||||
if self.path.endswith("/chat/completions"):
|
||||
n = int(self.headers.get("Content-Length", 0) or 0)
|
||||
try:
|
||||
req = json.loads(self.rfile.read(n) or b"{}")
|
||||
except ValueError:
|
||||
req = {}
|
||||
model = req.get("model", "")
|
||||
works = model in accept_models
|
||||
if reject_non_chat and any(x in model for x in ("embed", "rerank", "flux")):
|
||||
works = False
|
||||
if fail_all_chat:
|
||||
works = False
|
||||
self.send_response(200 if works else 404)
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
else:
|
||||
self.send_response(404)
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
def do_POST(self):
|
||||
if self.path.endswith("/chat/completions"):
|
||||
n = int(self.headers.get("Content-Length", 0) or 0)
|
||||
try:
|
||||
req = json.loads(self.rfile.read(n) or b"{}")
|
||||
except ValueError:
|
||||
req = {}
|
||||
model = req.get("model", "")
|
||||
status = lookup_status(model)
|
||||
if status is None:
|
||||
works = model in accept_models
|
||||
if reject_non_chat and any(x in model for x in ("embed", "rerank", "flux")):
|
||||
works = False
|
||||
if fail_all_chat:
|
||||
works = False
|
||||
status = 200 if works else 404
|
||||
payload = b""
|
||||
if status == 200:
|
||||
payload = json.dumps({
|
||||
"choices": [{"index": 0, "message": {"role": "assistant", "content": "PONG"}}],
|
||||
"usage": {"total_tokens": 5},
|
||||
}).encode()
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
if payload:
|
||||
self.wfile.write(payload)
|
||||
else:
|
||||
self.send_response(404)
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
@@ -135,5 +160,44 @@ def test_other_providers_keep_full_list(mock_server):
|
||||
assert models == MOCK_CATALOG
|
||||
|
||||
|
||||
def test_validation_keeps_429_but_drops_410():
|
||||
# A model answering 429 is rate-limited but routed → kept.
|
||||
# A model answering 410 (end of life) → definitively unusable → dropped.
|
||||
def status_for(model):
|
||||
return {"nvidia/good-chat": 200,
|
||||
"nvidia/rate-limited-chat": 429,
|
||||
"nvidia/retired-model": 410}.get(model, 404)
|
||||
|
||||
srv = _make_server(chat_status=status_for, catalog=["nvidia/good-chat",
|
||||
"nvidia/rate-limited-chat", "nvidia/retired-model"])
|
||||
try:
|
||||
base = f"http://127.0.0.1:{srv.server_port}/v1"
|
||||
models = asyncio.run(fetch_provider_models(
|
||||
"nvidia", api_key="nvapi-test", api_base=base, timeout=2))
|
||||
finally:
|
||||
srv.shutdown()
|
||||
assert set(models) == {"nvidia/good-chat", "nvidia/rate-limited-chat"}
|
||||
|
||||
|
||||
def test_runtime_fallback_on_model_gone():
|
||||
"""LLMClient retries once with the provider default when the chosen model
|
||||
answers 404/410, and the response carries a `notice` (visible in the UI)."""
|
||||
from app.services.llm_client import LLMClient
|
||||
|
||||
def status_for(model):
|
||||
return {"gone-or-deprecated": 410, "gpt-4o": 200}.get(model, 404)
|
||||
|
||||
srv = _make_server(chat_status=status_for)
|
||||
try:
|
||||
base = f"http://127.0.0.1:{srv.server_port}/v1"
|
||||
llm = LLMClient(provider="openai", api_key="sk-test", api_base=base)
|
||||
resp = asyncio.run(llm._http_complete(
|
||||
[{"role": "user", "content": "ping"}], "gone-or-deprecated", None))
|
||||
finally:
|
||||
srv.shutdown()
|
||||
assert resp.model == "gpt-4o"
|
||||
assert "gpt-4o" in resp.notice
|
||||
|
||||
|
||||
def test_offline_returns_empty_list():
|
||||
assert asyncio.run(fetch_provider_models("offline", api_base="", timeout=2)) == []
|
||||
Reference in New Issue
Block a user