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 `` for unavailable models, which visually inherited the last
+ rendered provider heading in the picker due to missing ` ` 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)"
+ )