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
+35
View File
@@ -1,5 +1,40 @@
# Changelog — FlowDeck
## v5.1.9 (2026-09-07) — Validation robuste des modèles Nvidia + logo Agent noir & blanc
> La liste Nvidia « diminuée » de la v5.1.8 contenait encore des modèles en
> erreur 404/410. Deux causes réelles : (1) le payload de validation ne
> correspondait pas à celui utilisé au runtime et un `payload` partagé entre
> les requêtes concurrentes envoyait des noms de modèles croisés (race) ;
> (2) rien ne protégeait l'usage si un modèle devenait indisponible après
> validation.
### Validation des modèles (fournisseur bruité)
- `_validate_chat_models()` utilise désormais le **payload de production**
(`messages` + `temperature: 0.2`, sans `max_tokens`) : un modèle qui répond
200 à la validation répond aussi 200 à l'usage réel.
- **Race fixée** : le payload est reconstruit pour chaque modèle (plus de nom
de modèle croisé entre requêtes concurrentes) — c'est pourquoi la v5.1.8
retombait par intermittence sur la liste brute non validée.
- Un seul payload partagé retiré ; retente **une fois** sur timeout/5xx (les
modèles lents mais fonctionnels survivent) ; rejette définitivement les 4xx
(404 modèle inconnu, 410 retiré/déprécié) ; garde les 429 (routés donc
utilisables).
### Repli automatique au runtime (model not found / gone)
- `LLMClient._http_complete` : si le modèle choisi répond **404/410**, retente
une fois avec le modèle par défaut du provider et ajoute un `notice`
expliquant le basculement — visible dans le panneau Agent (nouvel événement
SSE « notice », bandeau `.fd-ap-notice`, réinitialisé à chaque conversation).
- Le modèle réellement utilisé est persisté dans l'historique de conversation.
### Panneau Agent — logo noir & blanc thémé
- Le rond bleu en bas à droite (FAB) et le logo du header du panneau (à gauche
de « How can I help you today? ») passent de l'accent bleu + émoji 🤖 à un
rond noir/blanc qui suit le thème clair/sombre, avec une étincelle ✨
monochrome (SVG `currentColor` : rond `--text-primary`, étincelle
`--bg-primary`).
## v5.1.8 (2026-09-07) — Multi-lignes réellement conservées + modèles IA validés (Nvidia)
> Deux correctifs : le bug multi-lignes (v5.1.7 corrigeait le symptôme au
+1 -1
View File
@@ -1 +1 @@
5.1.8
5.1.9
+1 -1
View File
@@ -60,7 +60,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="5.1.8",
version="5.1.9",
docs_url="/docs" if settings.log_level == "DEBUG" else None,
redoc_url=None,
lifespan=lifespan,
+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)]
+11 -8
View File
@@ -1,7 +1,7 @@
{# ════════════ FlowDeck Agent panel v4.13.0 — Notion-AI-style sidebar ════════════ #}
<style>
#fd-agent-fab{position:fixed;right:22px;bottom:22px;width:54px;height:54px;border-radius:50%;background:var(--accent,#2383E2);color:#fff;border:none;font-size:24px;cursor:pointer;box-shadow:0 8px 26px rgba(35,131,226,.5);display:flex;align-items:center;justify-content:center;z-index:1700;transition:transform .15s,box-shadow .15s}
#fd-agent-fab:hover{transform:scale(1.06);box-shadow:0 12px 34px rgba(35,131,226,.62)}
#fd-agent-fab{position:fixed;right:22px;bottom:22px;width:54px;height:54px;border-radius:50%;background:var(--text-primary,#37352F);color:var(--bg-primary,#fff);border:none;cursor:pointer;box-shadow:0 8px 26px rgba(0,0,0,.35);display:flex;align-items:center;justify-content:center;z-index:1700;transition:transform .15s,box-shadow .15s}
#fd-agent-fab:hover{transform:scale(1.06);box-shadow:0 12px 34px rgba(0,0,0,.45)}
#fd-agent-panel{position:fixed;top:0;right:0;bottom:0;width:470px;max-width:100vw;background:var(--bg-primary,#191919);border-left:1px solid var(--border,rgba(255,255,255,.1));z-index:1850;display:flex;flex-direction:column;box-shadow:-16px 0 48px rgba(0,0,0,.55);transform:translateX(102%);transition:transform .24s cubic-bezier(.16,1,.3,1)}
#fd-agent-panel.open{transform:translateX(0)}
#fd-agent-panel ::-webkit-scrollbar{width:8px;height:8px}
@@ -10,7 +10,7 @@
/* Header */
.fd-ap-head{display:flex;align-items:center;gap:12px;padding:16px 18px 12px}
.fd-ap-logo{width:36px;height:36px;border-radius:50%;background:linear-gradient(135deg,var(--accent,#2383E2),#5b8def);color:#fff;display:flex;align-items:center;justify-content:center;font-size:19px;flex-shrink:0;box-shadow:0 4px 12px rgba(35,131,226,.35)}
.fd-ap-logo{width:36px;height:36px;border-radius:50%;background:var(--text-primary,#37352F);color:var(--bg-primary,#fff);display:flex;align-items:center;justify-content:center;flex-shrink:0;box-shadow:0 2px 8px rgba(0,0,0,.25)}
.fd-ap-head-main{flex:1;min-width:0}
.fd-ap-title{font-size:15px;font-weight:600;color:var(--text,#f0f0f0);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.fd-ap-sub{font-size:11.5px;color:var(--text-dim,#9aa);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
@@ -64,6 +64,7 @@
.fd-proposal-note{font-size:11px;color:var(--text-dim,#999)}
.fd-agent-thinking{font-size:12.5px;color:var(--text-dim,#999);padding:10px 12px;border-radius:9px;background:rgba(255,255,255,.04);margin:4px 0 8px;font-style:italic}
.fd-ap-err{color:#ef8a8a;font-size:12px;padding:4px 2px}
.fd-ap-notice{color:var(--text-secondary,#ccc);font-size:12px;padding:7px 10px;margin:2px 0 8px;border-radius:9px;background:rgba(128,128,128,.12);border:1px solid var(--border,rgba(255,255,255,.12))}
/* Composer */
.fd-ap-composer{padding:10px 14px 14px;border-top:1px solid var(--border,rgba(255,255,255,.09));position:relative}
@@ -191,12 +192,12 @@
</script>
{# ── Floating button ── #}
<button id="fd-agent-fab" title="FlowDeck Agent (Ctrl+J)" onclick="window.fdAgent&&window.fdAgent.toggle()">🤖</button>
<button id="fd-agent-fab" title="FlowDeck Agent (Ctrl+J)" onclick="window.fdAgent&&window.fdAgent.toggle()"><svg viewBox="0 0 24 24" width="26" height="26" fill="currentColor" aria-hidden="true"><path d="M12 3l1.9 5.8a2 2 0 0 0 1.3 1.3L21 12l-5.8 1.9a2 2 0 0 0-1.3 1.3L12 21l-1.9-5.8a2 2 0 0 0-1.3-1.3L3 12l5.8-1.9a2 2 0 0 0 1.3-1.3L12 3Z"/></svg></button>
{# ── Panel ── #}
<div id="fd-agent-panel" class="fd-ap" :class="{open: open}" x-data="agentPanel()" @keydown.escape.window="close()" role="dialog" aria-label="Agent FlowDeck">
<div class="fd-ap-head">
<span class="fd-ap-logo">🤖</span>
<span class="fd-ap-logo"><svg viewBox="0 0 24 24" width="19" height="19" fill="currentColor" aria-hidden="true"><path d="M12 3l1.9 5.8a2 2 0 0 0 1.3 1.3L21 12l-5.8 1.9a2 2 0 0 0-1.3 1.3L12 21l-1.9-5.8a2 2 0 0 0-1.3-1.3L3 12l5.8-1.9a2 2 0 0 0 1.3-1.3L12 3Z"/></svg></span>
<div class="fd-ap-head-main">
<div class="fd-ap-title">How can I help you today?</div>
<div class="fd-ap-sub" x-text="providerModelLabel"></div>
@@ -276,6 +277,7 @@
</div>
</template>
<div class="fd-agent-thinking" x-show="streaming">L'agent réfléchit et agit… <span x-text="liveReasoning"></span></div>
<div class="fd-ap-notice" x-show="notice" x-cloak x-text="notice"></div>
<div class="fd-ap-err" x-show="errorMsg" x-text="errorMsg"></div>
</div>
</template>
@@ -412,7 +414,7 @@
open: false, modelOpen: false, tab: 'chat',
agents: [], conversations: [], currentConv: null,
messages: [], input: '', streaming: false, liveReasoning: '',
provider: 'offline', providerAvailable: false, errorMsg: '',
provider: 'offline', providerAvailable: false, errorMsg: '', notice: '',
allProviders: [], providers: [], providerModels: [], llmProvider: 'offline',
llmModel: '', llmKeys: [], defaultProvider: 'offline',
activeContext: null, ctxPinnedKey: null,
@@ -756,7 +758,7 @@
.then(function(r){return r.json()}).then(function(d){
var conv = {id:d.id, title:d.title, updated_at:new Date().toISOString()};
self.conversations.unshift(conv); self.currentConv = conv;
self.messages = []; self.errorMsg=''; self.tab='chat';
self.messages = []; self.errorMsg=''; self.notice=''; self.tab='chat';
self.contextChips = []; self.mentionOpen = false;
self.$nextTick(function(){ self.resizeInput(); });
}).catch(function(){});
@@ -773,7 +775,7 @@
}).then(function(r){return r.json()}).then(function(d){
var conv = {id:d.id, title:d.title, updated_at:new Date().toISOString()};
self.conversations.unshift(conv); self.currentConv = conv;
self.messages = []; self.errorMsg=''; self.tab='chat';
self.messages = []; self.errorMsg=''; self.notice=''; self.tab='chat';
self.contextChips = []; self.mentionOpen = false;
self._ensuring = null;
return conv;
@@ -1682,6 +1684,7 @@
self.messages.push({id:'f-'+Date.now(), role:'assistant', content: ev.content});
if(window.__fdInsertAI){ try{ var h=window.__fdInsertAI; window.__fdInsertAI=null; h(ev.content||''); }catch(e){} }
}
else if(ev.type==='notice'){ self.notice = ev.message; }
else if(ev.type==='error'){ self.errorMsg = ev.message; }
}catch(e){}
});
+99 -35
View File
@@ -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)) == []