fix(agent): validation robuste des modeles Nvidia (payload production + repli 404/410) + logo noir/blanc
FlowDeck CI / test (push) Failing after 15s
FlowDeck CI / docker (push) Skipped

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:
2026-09-07 14:45:28 -04:00
parent b15289b4bc
commit bd582866d1
8 changed files with 215 additions and 76 deletions
+32 -24
View File
@@ -351,44 +351,52 @@ async def fetch_provider_models(provider: str, *, api_key: str = "",
async def _validate_chat_models(base_url: str, api_key: str, candidates: list[str],
*, timeout: float = 6.0, concurrency: int = 6,
deadline: float = 55.0) -> list[str]:
"""Probe `POST {base_url}/chat/completions` for each candidate with a minimal
payload and keep the models that accept it. A 429 (rate limit) is treated as
"probably fine" since it proves the model is routed, not that it's invalid."""
*, timeout: float = 12.0, concurrency: int = 5,
deadline: float = 90.0, attempts: int = 2) -> list[str]:
"""Probe `POST {base_url}/chat/completions` for each candidate and keep only
the models that genuinely answer — using the exact payload the app sends at
runtime (`temperature: 0.2`, no `max_tokens`).
Hard failures (404 model inconnu, 410 modèle retiré…) exclude the model.
A 429 (rate limit) is kept: it proves the route exists, so the model is
usable once the quota frees up. Transient failures (timeouts, 5xx, network)
are retried once before giving up, so slow-but-working models survive.
"""
import asyncio
import httpx
valid: list[str] = []
sem = asyncio.Semaphore(concurrency)
start = time.monotonic()
headers = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
payload: dict = {
"model": None,
url = f"{base_url.rstrip('/')}/chat/completions"
payload_tpl = {
"messages": [{"role": "user", "content": "ping"}],
"max_tokens": 4,
"temperature": 0.2,
}
async def probe(model: str) -> str | None:
if time.monotonic() - start > deadline:
return None
payload["model"] = model
try:
async with sem:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.post(
f"{base_url}/chat/completions", headers=headers, json=payload
)
if resp.status_code < 300 or resp.status_code == 429:
return model
except Exception: # noqa: BLE001 — réseau/timeout ⇒ rejeté
return None
payload: dict = {"model": model, **payload_tpl}
for attempt in range(attempts):
if time.monotonic() - start > deadline:
return None
try:
async with sem:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.post(url, headers=headers, json=payload)
code = resp.status_code
if code < 300 or code == 429:
return model
if code < 500: # 400/401/403/404/410… → définitivement non utilisable
return None
# 5xx → passage passager, on retente une fois
except Exception: # noqa: BLE001 — timeouts / connexion → on retente
if attempt >= attempts - 1:
return None
return None
results = await asyncio.gather(*(probe(m) for m in candidates))
for m in results:
if m:
valid.append(m)
return valid
return [m for m in results if isinstance(m, str)]