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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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)]
|
||||
Reference in New Issue
Block a user