diff --git a/CHANGELOG.md b/CHANGELOG.md index 028521d..a51b551 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/VERSION b/VERSION index a106d2a..bd96b42 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.1.8 \ No newline at end of file +5.1.9 \ No newline at end of file diff --git a/app/main.py b/app/main.py index df0c96c..264c945 100644 --- a/app/main.py +++ b/app/main.py @@ -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, diff --git a/app/services/agent_engine.py b/app/services/agent_engine.py index d92452e..1e54f69 100644 --- a/app/services/agent_engine.py +++ b/app/services/agent_engine.py @@ -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 diff --git a/app/services/llm_client.py b/app/services/llm_client.py index 5cf6631..bc5c787 100644 --- a/app/services/llm_client.py +++ b/app/services/llm_client.py @@ -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, diff --git a/app/services/llm_config.py b/app/services/llm_config.py index a7c548d..9a2dbb4 100644 --- a/app/services/llm_config.py +++ b/app/services/llm_config.py @@ -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 \ No newline at end of file + return [m for m in results if isinstance(m, str)] \ No newline at end of file diff --git a/app/templates/agent_panel.html b/app/templates/agent_panel.html index 8e6619d..d0ed6c1 100644 --- a/app/templates/agent_panel.html +++ b/app/templates/agent_panel.html @@ -1,7 +1,7 @@ {# ════════════ FlowDeck Agent panel v4.13.0 — Notion-AI-style sidebar ════════════ #}