diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ef01aa..0c068f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -85,6 +85,12 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ré-ancrage automatique de Chrome (le fil est reconstruit à chaque token, ce qui décalait la vue de ~33 px) et une passe de correction recale le défilement d'après la géométrie mesurée. +- **#91 — Tag fournisseur/modèle incomplet** : le backend renvoyait le modèle *demandé + par le client* (`req.model or ""`), donc une valeur vide dès que le client s'en remet + au défaut du fournisseur — le tag se réduisait à « openrouter ». `_effective_model()` + résout désormais le modèle réellement utilisé dans les deux flux SSE (`/chat` et + `/agent`) : le tag affiche « openrouter · openai/gpt-4o-mini ». Couvert par + `tests/test_bookslm.py::TestEffectiveModel` et le test SSE de l'endpoint agent. - **BUG-041 — Assistant IA bloqué sur un répertoire vide** : l'assistant ne renvoie plus `⚠ Error: Aucun fichier markdown trouvé dans ce dossier` (HTTP 404). diff --git a/backend/bookslm_routes.py b/backend/bookslm_routes.py index 6e933c9..89cfe9d 100644 --- a/backend/bookslm_routes.py +++ b/backend/bookslm_routes.py @@ -317,6 +317,22 @@ def _resolve_provider_name(requested: str | None) -> str | None: return None +def _effective_model(provider: str | None, requested: str | None) -> str: + """Model actually used for a request. + + The client may leave `model` empty (provider default) — reporting the raw + request would show nothing in the "provider · model" tag, so the provider's + configured default is returned instead. + """ + if requested: + return requested + if not provider: + return "" + from backend.ai import PROVIDERS + + return PROVIDERS.get(provider, {}).get("model", "") or "" + + def _tool_event_sse(rec) -> str: """Serialize one executed tool call as an SSE ``tool`` event.""" payload = json.dumps( @@ -407,7 +423,11 @@ async def api_bookslm_chat( max_tokens=4096, ): data = json.dumps( - {"token": token, "provider": cfg_name, "model": req.model or ""}, + { + "token": token, + "provider": cfg_name, + "model": _effective_model(cfg_name, req.model), + }, ensure_ascii=False, ) yield f"event: message\ndata: {data}\n\n" @@ -532,7 +552,7 @@ async def api_bookslm_agent( { "token": result.content, "provider": cfg_name, - "model": req.model or "", + "model": _effective_model(cfg_name, req.model), "iterations": result.iterations, "stopped": result.stopped, }, diff --git a/docs/features/ai-assistant-conversation-ux.md b/docs/features/ai-assistant-conversation-ux.md index 27b6dab..02b546c 100644 --- a/docs/features/ai-assistant-conversation-ux.md +++ b/docs/features/ai-assistant-conversation-ux.md @@ -64,6 +64,10 @@ (déjà émis par `/chat` et `/agent`) sur le message assistant. - [x] **C2.** `.bookslm-msg-meta` au-dessus du bloc assistant : « fournisseur · modèle » (persisté avec la session, visible au rechargement). + - [x] **C3.** Le backend émet le modèle **réellement utilisé** : + `_effective_model(provider, req.model)` résout le défaut du fournisseur quand le + client ne précise pas de modèle (avant : `req.model or ""` → tag réduit au seul + fournisseur). Idem dans les deux flux SSE (`/chat` et `/agent`). ## D. Barre d'actions — ✅ livré - [x] **D1.** `_appendActionBar()` sous chaque bloc non vide : bouton Copier pour diff --git a/tests/test_bookslm.py b/tests/test_bookslm.py index 9b52117..d58c03c 100644 --- a/tests/test_bookslm.py +++ b/tests/test_bookslm.py @@ -967,3 +967,60 @@ class TestBooksLMAgentEndpoint: assert executed.get("ran") is True assert "C'est fait." in resp2.text assert "event: message" in resp2.text + + def test_agent_message_reports_effective_model(self, bookslm_client, monkeypatch): + """The SSE payload carries the model really used, not the raw request. + + The client may leave `model` empty (provider default) — reporting it + verbatim left the "provider · model" tag in the UI with the provider + only. + """ + import re + + import backend.bookslm_routes as routes + from backend.ai import PROVIDERS + from backend.ai_chat import LLMResponse + + async def fake_chat_completion(messages, **kwargs): + return LLMResponse(content="Bonjour.") + + monkeypatch.setattr(routes, "chat_completion", fake_chat_completion) + monkeypatch.setattr(routes, "_resolve_provider_name", lambda requested: "openrouter") + + token, _ = _login_bookslm(bookslm_client) + resp = bookslm_client.post( + "/api/ai/bookslm/agent", + json={"vault": "TestVault", "directory": "", "message": "salut", "mode": "directory"}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 200 + match = re.search(r"event: message\ndata: (.*)", resp.text) + assert match, resp.text + data = json.loads(match.group(1)) + assert data["provider"] == "openrouter" + expected = PROVIDERS.get("openrouter", {}).get("model", "") or "" + assert data["model"] == expected, data + assert data["model"], "provider default model must not be reported empty" + + +class TestEffectiveModel: + """Unit tests for the provider/model tag payload helper.""" + + def test_requested_model_wins(self): + from backend.bookslm_routes import _effective_model + + assert _effective_model("openrouter", "openai/gpt-4o-mini") == "openai/gpt-4o-mini" + + def test_falls_back_to_provider_default(self): + from backend.ai import PROVIDERS + from backend.bookslm_routes import _effective_model + + default = PROVIDERS.get("openrouter", {}).get("model", "") + assert _effective_model("openrouter", None) == default + assert _effective_model("openrouter", "") == default + + def test_unknown_provider_or_missing_provider_is_empty(self): + from backend.bookslm_routes import _effective_model + + assert _effective_model("nope-not-a-provider", None) == "" + assert _effective_model(None, None) == ""