From 7f16a41a3138ac911f748e452808dbc9946022a9 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 19 Apr 2026 23:22:26 -0700 Subject: [PATCH] =?UTF-8?q?fix:=20normalize=20stale=20session=20models=20a?= =?UTF-8?q?fter=20provider=20switch=20=E2=80=94=20v0.50.99=20(#751)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Rebased-on-behalf of @likawa3b (originally PR #748 — stale base). Sessions can outlive provider changes. When an old session still points to a model from a previous provider (e.g. `gemini-3.1-pro-preview` after switching the agent to OpenAI Codex), starting a chat hits the wrong backend and fails silently. This PR adds a lightweight normalization pass: - `_normalize_provider_id()` maps common prefixes to canonical provider IDs - `_resolve_compatible_session_model()` checks the session model's provider against `active_provider` and returns the default model if they differ - `_normalize_session_model_in_place()` is called at GET `/api/session` — corrects and persists stale models once - Chat start also normalizes via `_resolve_compatible_session_model()` and returns `effective_model` in the response - `messages.js` applies `effective_model` back to the UI/localStorage/dropdown if set Closes #748 ## Tests 1498 passed (2 pre-existing ordering failures unrelated to this PR; 5 new tests added in `test_provider_mismatch.py`). **Original author:** @likawa3b --- CHANGELOG.md | 5 ++ api/routes.py | 81 +++++++++++++++++++++++- static/messages.js | 6 ++ tests/test_provider_mismatch.py | 107 ++++++++++++++++++++++++++++++++ 4 files changed, 197 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d042415..dd4aecc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Hermes Web UI -- Changelog +## [v0.50.99] — 2026-04-20 + +### Fixed +- **Stale session models normalized after provider switch** — sessions that still reference a model from a previous provider (e.g. a `gemini-*` model after switching to OpenAI Codex) are silently corrected to the current provider's default on load, preventing startup failures. (Closes #748, credit: @likawa3b) + ## [v0.50.98] — 2026-04-20 ### Fixed diff --git a/api/routes.py b/api/routes.py index 3f101f1..450091c 100644 --- a/api/routes.py +++ b/api/routes.py @@ -17,6 +17,13 @@ from urllib.parse import parse_qs logger = logging.getLogger(__name__) +_PROVIDER_ALIASES = { + "claude": "anthropic", + "gpt": "openai", + "gemini": "google", + "openai-codex": "openai", +} + from api.config import ( STATE_DIR, SESSION_DIR, @@ -158,6 +165,71 @@ def _check_csrf(handler) -> bool: return False +def _normalize_provider_id(value: str | None) -> str: + raw = str(value or "").strip().lower() + if not raw: + return "" + if raw in _PROVIDER_ALIASES: + return _PROVIDER_ALIASES[raw] + for prefix, normalized in ( + ("openai-codex", "openai"), + ("openai", "openai"), + ("anthropic", "anthropic"), + ("claude", "anthropic"), + ("google", "google"), + ("gemini", "google"), + ("openrouter", "openrouter"), + ("custom", "custom"), + ): + if raw.startswith(prefix): + return normalized + return raw + + +def _resolve_compatible_session_model(model_id: str | None) -> tuple[str, bool]: + """Return (effective_model, was_normalized) for persisted session models. + + Sessions can outlive provider changes. When an older session still points at + a different provider namespace (for example `gemini/...` after switching the + agent to OpenAI Codex), reusing that stale model causes chat startup to hit + the wrong backend and fail. Normalize only obvious cross-provider mismatches; + preserve bare model IDs and OpenRouter/custom setups. + """ + catalog = get_available_models() + default_model = str(catalog.get("default_model") or DEFAULT_MODEL or "").strip() + model = str(model_id or "").strip() + if not model: + return default_model, bool(default_model) + + active_provider = _normalize_provider_id(catalog.get("active_provider")) + if not active_provider or active_provider in {"custom", "openrouter"}: + return model, False + + slash = model.find("/") + if slash < 0: + model_lower = model.lower() + for bare_prefix in ("gpt", "claude", "gemini"): + if model_lower.startswith(bare_prefix): + model_provider = _normalize_provider_id(bare_prefix) + if model_provider and model_provider != active_provider and default_model: + return default_model, True + return model, False + return model, False + + model_provider = _normalize_provider_id(model[:slash]) + if model_provider and model_provider != active_provider and default_model: + return default_model, True + return model, False + + +def _normalize_session_model_in_place(session) -> str: + effective_model, changed = _resolve_compatible_session_model(getattr(session, "model", None)) + if changed and effective_model and getattr(session, "model", None) != effective_model: + session.model = effective_model + session.save(touch_updated_at=False) + return effective_model + + from api.models import ( Session, get_session, @@ -481,6 +553,7 @@ def handle_get(handler, parsed) -> bool: return j(handler, {"error": "session_id is required"}, status=400) try: s = get_session(sid) + _normalize_session_model_in_place(s) raw = s.compact() | { "messages": s.messages, "tool_calls": getattr(s, "tool_calls", []), @@ -2071,7 +2144,8 @@ def _handle_chat_start(handler, body): workspace = str(resolve_trusted_workspace(body.get("workspace") or s.workspace)) except ValueError as e: return bad(handler, str(e)) - model = body.get("model") or s.model + requested_model = body.get("model") or s.model + model, normalized_model = _resolve_compatible_session_model(requested_model) # Prevent duplicate runs in the same session while a stream is still active. # This commonly happens after page refresh/reconnect races and can produce # duplicated clarify cards for what appears to be a single user request. @@ -2108,7 +2182,10 @@ def _handle_chat_start(handler, body): daemon=True, ) thr.start() - return j(handler, {"stream_id": stream_id, "session_id": s.session_id}) + response = {"stream_id": stream_id, "session_id": s.session_id} + if normalized_model: + response["effective_model"] = model + return j(handler, response) def _handle_chat_sync(handler, body): diff --git a/static/messages.js b/static/messages.js index 3c3597a..4475d8e 100644 --- a/static/messages.js +++ b/static/messages.js @@ -71,6 +71,12 @@ async function send(){ model:S.session.model||$('modelSelect').value,workspace:S.session.workspace, attachments:uploaded.length?uploaded:undefined })}); + if(startData.effective_model && S.session){ + S.session.model=startData.effective_model; + localStorage.setItem('hermes-webui-model', startData.effective_model); + if($('modelSelect')) _applyModelToDropdown(startData.effective_model, $('modelSelect')); + if(typeof syncTopbar==='function') syncTopbar(); + } streamId=startData.stream_id; S.activeStreamId = streamId; markInflight(activeSid, streamId); diff --git a/tests/test_provider_mismatch.py b/tests/test_provider_mismatch.py index d120c2a..3cf6cba 100644 --- a/tests/test_provider_mismatch.py +++ b/tests/test_provider_mismatch.py @@ -278,6 +278,99 @@ def test_api_models_includes_active_provider(): ) +def test_bare_gemini_session_model_normalizes_to_active_provider_default(monkeypatch): + """Persisted bare Gemini IDs must not survive a provider switch.""" + import api.routes as routes + + monkeypatch.setattr( + routes, + "get_available_models", + lambda: { + "active_provider": "openai-codex", + "default_model": "gpt-5.4-mini", + }, + ) + + effective, changed = routes._resolve_compatible_session_model( + "gemini-3.1-pro-preview" + ) + + assert changed is True + assert effective == "gpt-5.4-mini" + + +def test_prefixed_google_session_model_normalizes_to_active_provider_default(monkeypatch): + """Persisted provider-prefixed Gemini IDs must normalize too.""" + import api.routes as routes + + monkeypatch.setattr( + routes, + "get_available_models", + lambda: { + "active_provider": "openai-codex", + "default_model": "gpt-5.4-mini", + }, + ) + + effective, changed = routes._resolve_compatible_session_model( + "google/gemini-3.1-pro-preview" + ) + + assert changed is True + assert effective == "gpt-5.4-mini" + + +def test_google_active_provider_keeps_valid_gemini_session_model(monkeypatch): + """A Google-configured session must keep its Gemini model.""" + import api.routes as routes + + monkeypatch.setattr( + routes, + "get_available_models", + lambda: { + "active_provider": "google", + "default_model": "gemini-3.1-pro-preview", + }, + ) + + effective, changed = routes._resolve_compatible_session_model( + "gemini-3.1-pro-preview" + ) + + assert changed is False + assert effective == "gemini-3.1-pro-preview" + + +def test_session_model_normalizer_persists_corrected_model(monkeypatch): + """GET /api/session should persist the corrected model back to disk/state.""" + import api.routes as routes + + monkeypatch.setattr( + routes, + "get_available_models", + lambda: { + "active_provider": "openai-codex", + "default_model": "gpt-5.4-mini", + }, + ) + + save_calls = [] + + class DummySession: + def __init__(self): + self.model = "gemini-3.1-pro-preview" + + def save(self, touch_updated_at=True): + save_calls.append(touch_updated_at) + + session = DummySession() + effective = routes._normalize_session_model_in_place(session) + + assert effective == "gpt-5.4-mini" + assert session.model == "gpt-5.4-mini" + assert save_calls == [False] + + # ── Model switch toast (#419) ───────────────────────────────────────────────── class TestModelSwitchToast: @@ -323,3 +416,17 @@ class TestModelSwitchToast: assert "typeof showToast" in surrounding, ( "showToast call must be guarded with typeof check" ) + + +class TestChatStartEffectiveModelRecovery: + """messages.js must accept an effective_model correction from the backend.""" + + def test_send_applies_effective_model_from_chat_start(self): + src = _read("static/messages.js") + assert "startData.effective_model" in src, ( + "send() must read effective_model from /api/chat/start so the UI can " + "recover from stale persisted session models" + ) + assert "localStorage.setItem('hermes-webui-model', startData.effective_model)" in src, ( + "effective_model correction must update the saved model preference" + )