diff --git a/CHANGELOG.md b/CHANGELOG.md index 255366f..8baac29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Hermes Web UI -- Changelog +## [v0.50.142] — 2026-04-22 + +### Fixed +- **Stale model no longer shows as "(unavailable)" in the model picker** — users with + only `custom_providers` configured (no OpenAI key) were seeing "GPT-5.4 Mini (unavailable)" + appear in the picker, visually grouped under their custom provider section and selected + as the default model. Two root causes: (1) `_resolve_compatible_session_model()` in + `api/routes.py` had a blanket skip for `active_provider == "custom"` that prevented + stale cross-provider session models (e.g. `openai/gpt-5.4-mini` from a pre-v0.50 default) + from ever being cleaned up. Fixed to only skip normalization when the model's prefix is + actually routable by a group in the catalog. (2) `renderSession()` in `static/ui.js` + injected a bare `` context. Fixed to + silently reset to the first available model instead. Closes #829. (#831) + ## [v0.50.141] — 2026-04-22 ### Fixed @@ -7,7 +22,7 @@ bfcache was restoring a prior search query into `#sessionSearch` on page restore, causing `renderSessionListFromCache()` to silently filter out all sessions (including newly created ones). Added `autocomplete="off"` to the search input and an explicit - value-clear at boot before the first render. Closes #822. (#829) + value-clear at boot before the first render. Closes #822. (#830) ## [v0.50.140] — 2026-04-22 diff --git a/api/routes.py b/api/routes.py index 38647ca..a014860 100644 --- a/api/routes.py +++ b/api/routes.py @@ -208,7 +208,7 @@ def _resolve_compatible_session_model(model_id: str | None) -> tuple[str, bool]: 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"}: + if not active_provider: return model, False slash = model.find("/") @@ -223,6 +223,32 @@ def _resolve_compatible_session_model(model_id: str | None) -> tuple[str, bool]: return model, False model_provider = _normalize_provider_id(model[:slash]) + + # For custom/openrouter active providers: only skip normalization when the + # model's namespace prefix is actually routable by a group in the catalog. + # A user who only has custom_providers configured (active_provider="custom") + # with a stale session model like "openai/gpt-5.4-mini" would otherwise + # never get cleaned up, causing "(unavailable)" to appear in the picker. + if active_provider in {"custom", "openrouter"}: + # These namespaces are always routable as-is — preserve them. + if model_provider in {"", "custom", "openrouter"}: + return model, False + # Check if any catalog group can actually route this model's prefix. + groups = catalog.get("groups") or [] + routable_provider_ids = { + _normalize_provider_id(g.get("provider_id") or "") for g in groups + } + # openrouter group can route any provider/model namespace + has_openrouter_group = any( + (g.get("provider_id") or "") == "openrouter" for g in groups + ) + if model_provider in routable_provider_ids or has_openrouter_group: + return model, False + # Model prefix is not routable — stale cross-provider reference, clear it. + if default_model: + return default_model, True + return model, False + # Skip normalization for models on custom/openrouter namespaces — these are # user-controlled and should never be silently replaced. if model_provider and model_provider not in {"", "custom", "openrouter"} and model_provider != active_provider and default_model: diff --git a/static/ui.js b/static/ui.js index d3f7e9c..be1d210 100644 --- a/static/ui.js +++ b/static/ui.js @@ -1209,18 +1209,24 @@ function syncTopbar(){ currentModel=modelOverride; } else { const applied=_applyModelToDropdown(currentModel,$('modelSelect')); - // If the model isn't in the current provider list, add it as a visually marked - // "(unavailable)" entry so the session value is preserved without misleading the user. - // Selecting it will still attempt to send (same as before), but the label makes - // clear it's a stale model from a previous session. + // If the model isn't in the current provider list, silently reset to the + // first available model so stale values don't pollute the picker (#829). if(!applied && currentModel){ - const opt=document.createElement('option'); - opt.value=currentModel; - opt.textContent=getModelLabel(currentModel)+t('model_unavailable'); - opt.style.color='var(--muted, #888)'; - opt.title=t('model_unavailable_title'); - $('modelSelect').appendChild(opt); - $('modelSelect').value=currentModel; + // Stale session model not in the current provider catalog — reset to the + // first available model rather than injecting an "(unavailable)" option + // that visually appears under the wrong provider group (#829). + const modelSel=$('modelSelect'); + const first=modelSel&&modelSel.querySelector('optgroup > option, option'); + if(first){ + modelSel.value=first.value; + S.session.model=first.value; + // Persist the correction so the session doesn't re-inject on next load. + fetch(new URL('api/session/update',location.href).href,{ + method:'POST',credentials:'include', + headers:{'Content-Type':'application/json'}, + body:JSON.stringify({session_id:S.session.id||S.session.session_id,model:first.value}) + }).catch(()=>{}); + } } } if(typeof syncModelChip==='function') syncModelChip(); diff --git a/tests/test_provider_mismatch.py b/tests/test_provider_mismatch.py index 14c2a8d..b440703 100644 --- a/tests/test_provider_mismatch.py +++ b/tests/test_provider_mismatch.py @@ -498,3 +498,133 @@ def test_empty_model_session_does_not_trigger_save(monkeypatch): "_normalize_session_model_in_place must not call session.save() when " "the session has no stored model — no correction needed, just a fallback." ) + + +# ── Issue #829: stale cross-provider model on custom_providers-only setup ───── + +def test_stale_openai_model_cleared_for_custom_only_provider(monkeypatch): + """A stale openai/... session model must be cleared when active provider is + 'custom' and no catalog group can route the openai prefix (#829).""" + import api.routes as routes + + monkeypatch.setattr( + routes, + "get_available_models", + lambda: { + "active_provider": "custom", + "default_model": "", + "groups": [ + {"provider": "Agent37", "provider_id": "custom:agent37", + "models": [{"id": "agent37/default", "label": "default"}]}, + ], + }, + ) + + effective, changed = routes._resolve_compatible_session_model( + "openai/gpt-5.4-mini" + ) + + # No routable group for openai/ — should clear to default (empty → model itself + # only if no default available, which means changed=False when default_model="") + # When default_model is empty, we can't clear — preserve and return False + assert changed is False + assert effective == "openai/gpt-5.4-mini" + + +def test_stale_openai_model_cleared_for_custom_provider_with_default(monkeypatch): + """When active_provider='custom', no openrouter group, and default_model is + configured, stale openai/... model should be cleared to default (#829).""" + import api.routes as routes + + monkeypatch.setattr( + routes, + "get_available_models", + lambda: { + "active_provider": "custom", + "default_model": "agent37/default", + "groups": [ + {"provider": "Agent37", "provider_id": "custom:agent37", + "models": [{"id": "agent37/default", "label": "default"}]}, + ], + }, + ) + + effective, changed = routes._resolve_compatible_session_model( + "openai/gpt-5.4-mini" + ) + + assert changed is True + assert effective == "agent37/default" + + +def test_openrouter_model_preserved_when_openrouter_group_present(monkeypatch): + """When active_provider='openrouter' and openrouter group exists, + openai/... model IDs must pass through unchanged — they are routable (#829).""" + import api.routes as routes + + monkeypatch.setattr( + routes, + "get_available_models", + lambda: { + "active_provider": "openrouter", + "default_model": "openai/gpt-5.4-mini", + "groups": [ + {"provider": "OpenRouter", "provider_id": "openrouter", + "models": [{"id": "openai/gpt-5.4-mini", "label": "GPT-5.4 Mini"}]}, + ], + }, + ) + + effective, changed = routes._resolve_compatible_session_model( + "openai/gpt-5.4-mini" + ) + + assert changed is False + assert effective == "openai/gpt-5.4-mini" + + +def test_custom_namespace_model_always_preserved_on_custom_provider(monkeypatch): + """Model IDs with 'custom/' prefix must always pass through unchanged even + when active_provider='custom' (#829).""" + import api.routes as routes + + monkeypatch.setattr( + routes, + "get_available_models", + lambda: { + "active_provider": "custom", + "default_model": "agent37/default", + "groups": [ + {"provider": "Agent37", "provider_id": "custom:agent37", + "models": [{"id": "agent37/default", "label": "default"}]}, + ], + }, + ) + + effective, changed = routes._resolve_compatible_session_model( + "custom/my-local-llm" + ) + + assert changed is False + assert effective == "custom/my-local-llm" + + +def test_stale_ui_js_does_not_inject_unavailable_option(): + """renderSession() must no longer inject a bare (unavailable) option into + modelSelect when the session model is not in the provider list (#829). + It should silently reset to the first available model instead.""" + import os + src = open(os.path.join(os.path.dirname(__file__), "..", "static", "ui.js"), + encoding="utf-8").read() + + # The old pattern must be gone — both keys removed from ui.js + assert "model_unavailable" not in src and "model_unavailable_title" not in src, ( + "renderSession() must not inject '(unavailable)' options — " + "stale models should be silently reset to the first available model (#829)" + ) + + # The new silent-reset pattern must be present + assert "first.value" in src and "S.session.model=first.value" in src, ( + "renderSession() must silently reset S.session.model to the first " + "available option when the session model is not in the dropdown (#829)" + )