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
+7 -1
View File
@@ -154,6 +154,7 @@ class AgentEngine:
tool_schema = self.tools.schema(scope)
final_text = None
used_model = model or "" # peut être ajusté par un repli de modèle (404/410)
try:
for step in range(settings.agent_max_iterations or MAX_ITERATIONS):
@@ -167,6 +168,11 @@ class AgentEngine:
)
self._tokens += response.usage.get("total_tokens", 0) or 0
if getattr(response, "notice", ""):
yield self._event("notice", {"message": response.notice})
used_model = getattr(response, "model", "") or used_model
if response.text and response.text.strip():
yield self._event("reasoning", {"content": response.text})
@@ -242,7 +248,7 @@ class AgentEngine:
yield self._event("final", {"content": final_text})
self._persist_message(conversation_id, "assistant", final_text,
model=model, tokens=self._tokens)
model=used_model, tokens=self._tokens)
await self._autotitle(conversation_id, objective, final_text)
except Exception as exc: # noqa: BLE001
+29 -6
View File
@@ -85,6 +85,7 @@ class LLMResponse:
tool_calls: list[dict] = field(default_factory=list)
model: str = ""
usage: dict = field(default_factory=dict)
notice: str = ""
class LLMClient:
@@ -161,7 +162,7 @@ class LLMClient:
def _endpoint(self) -> str:
return f"{self.api_base.rstrip('/')}/chat/completions"
async def _http_complete(self, messages, model, tools) -> LLMResponse:
async def _http_complete(self, messages, model, tools, *, _noticer: str = "") -> LLMResponse:
payload: dict = {
"model": model,
"messages": messages,
@@ -173,11 +174,34 @@ class LLMClient:
headers = {"Content-Type": "application/json"}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
async with httpx.AsyncClient(timeout=settings.agent_run_timeout_seconds) as client:
resp = await client.post(self._endpoint(), json=payload, headers=headers)
resp.raise_for_status()
data = resp.json()
try:
async with httpx.AsyncClient(timeout=settings.agent_run_timeout_seconds) as client:
resp = await client.post(self._endpoint(), json=payload, headers=headers)
resp.raise_for_status()
data = resp.json()
except httpx.HTTPStatusError as exc:
# Repli robuste : le modèle choisi a été retiré / n'existe plus
# (404 « model not found » / 410 « has reached its end of life »).
# Au lieu d'échouer, on retente UNE fois avec le modèle par défaut du
# provider et on signale le basculement — la liste validée peut avoir
# vieilli (modèle déprécié entre deux rafraîchissements).
if exc.response.status_code in (404, 410) \
and model and self.default_model and model != self.default_model:
logger.warning(
"Model '%s' unavailable (%s) on %s — retrying with default '%s'",
model, exc.response.status_code, self.provider, self.default_model,
)
return await self._http_complete(messages, self.default_model, tools, _noticer=(
f"Le modèle « {model} » n'est plus disponible ({exc.response.status_code}). "
f"Réponse générée avec « {self.default_model} » à la place."
))
raise
response = self._parse_response(data, model)
response.notice = _noticer or ""
return response
def _parse_response(self, data: dict, model: str) -> LLMResponse:
choice = data["choices"][0]["message"]
text = choice.get("content") or ""
tool_calls = []
@@ -193,7 +217,6 @@ class LLMClient:
"arguments": args,
"arguments_raw": fn.get("arguments") or "",
})
return LLMResponse(
text=text,
tool_calls=tool_calls,
+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)]