fix(models): stale cross-provider model no longer shows as unavailable in picker (closes #829)
* fix(models): stale cross-provider model no longer shows as unavailable in picker Two bugs allowed an openai/gpt-5.4-mini stale session model to appear as '(unavailable)' under a custom provider group for users who never configured OpenAI (#829). Backend (api/routes.py): _resolve_compatible_session_model() had a blanket early-return for active_provider in {custom, openrouter} that skipped all normalization regardless of whether any catalog group could route the model's prefix. A custom_providers-only user with a stale openai/... session model was never corrected. Fixed: only skip normalization when the model prefix is actually routable (matches a catalog group provider_id, or an openrouter group is present that can route any provider/model). Frontend (static/ui.js): renderSession() injected a bare <option> (not in any <optgroup>) for models not found in the dropdown. renderModelDropdown() rendered bare options without emitting a group heading, so they visually inherited the last rendered provider heading — making the stale model appear to belong to the custom provider group. Fixed: silently reset to the first available model and fire a PATCH to persist the correction instead of injecting a misleading (unavailable) option. 5 new tests in test_provider_mismatch.py cover: - stale openai model cleared when custom_providers-only + no default_model - stale openai model cleared when custom_providers-only + default_model set - openrouter model preserved when openrouter group present - custom/ namespace always preserved - ui.js no longer injects model_unavailable option * fix(ui): declare modelSel locally in syncTopbar reset path; fix test assertion - Use const modelSel=$('modelSelect') instead of undeclared sel in the stale-model reset branch of syncTopbar() (caught in Opus review) - Fix test assertion: or → and for model_unavailable key absence check --------- Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
This commit is contained in:
17
CHANGELOG.md
17
CHANGELOG.md
@@ -1,5 +1,20 @@
|
|||||||
# Hermes Web UI -- Changelog
|
# 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 `<option>` for unavailable models, which visually inherited the last
|
||||||
|
rendered provider heading in the picker due to missing `<optgroup>` context. Fixed to
|
||||||
|
silently reset to the first available model instead. Closes #829. (#831)
|
||||||
|
|
||||||
## [v0.50.141] — 2026-04-22
|
## [v0.50.141] — 2026-04-22
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
@@ -7,7 +22,7 @@
|
|||||||
bfcache was restoring a prior search query into `#sessionSearch` on page restore,
|
bfcache was restoring a prior search query into `#sessionSearch` on page restore,
|
||||||
causing `renderSessionListFromCache()` to silently filter out all sessions (including
|
causing `renderSessionListFromCache()` to silently filter out all sessions (including
|
||||||
newly created ones). Added `autocomplete="off"` to the search input and an explicit
|
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
|
## [v0.50.140] — 2026-04-22
|
||||||
|
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ def _resolve_compatible_session_model(model_id: str | None) -> tuple[str, bool]:
|
|||||||
return default_model, bool(default_model)
|
return default_model, bool(default_model)
|
||||||
|
|
||||||
active_provider = _normalize_provider_id(catalog.get("active_provider"))
|
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
|
return model, False
|
||||||
|
|
||||||
slash = model.find("/")
|
slash = model.find("/")
|
||||||
@@ -223,6 +223,32 @@ def _resolve_compatible_session_model(model_id: str | None) -> tuple[str, bool]:
|
|||||||
return model, False
|
return model, False
|
||||||
|
|
||||||
model_provider = _normalize_provider_id(model[:slash])
|
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
|
# Skip normalization for models on custom/openrouter namespaces — these are
|
||||||
# user-controlled and should never be silently replaced.
|
# 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:
|
if model_provider and model_provider not in {"", "custom", "openrouter"} and model_provider != active_provider and default_model:
|
||||||
|
|||||||
28
static/ui.js
28
static/ui.js
@@ -1209,18 +1209,24 @@ function syncTopbar(){
|
|||||||
currentModel=modelOverride;
|
currentModel=modelOverride;
|
||||||
} else {
|
} else {
|
||||||
const applied=_applyModelToDropdown(currentModel,$('modelSelect'));
|
const applied=_applyModelToDropdown(currentModel,$('modelSelect'));
|
||||||
// If the model isn't in the current provider list, add it as a visually marked
|
// If the model isn't in the current provider list, silently reset to the
|
||||||
// "(unavailable)" entry so the session value is preserved without misleading the user.
|
// first available model so stale values don't pollute the picker (#829).
|
||||||
// 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(!applied && currentModel){
|
if(!applied && currentModel){
|
||||||
const opt=document.createElement('option');
|
// Stale session model not in the current provider catalog — reset to the
|
||||||
opt.value=currentModel;
|
// first available model rather than injecting an "(unavailable)" option
|
||||||
opt.textContent=getModelLabel(currentModel)+t('model_unavailable');
|
// that visually appears under the wrong provider group (#829).
|
||||||
opt.style.color='var(--muted, #888)';
|
const modelSel=$('modelSelect');
|
||||||
opt.title=t('model_unavailable_title');
|
const first=modelSel&&modelSel.querySelector('optgroup > option, option');
|
||||||
$('modelSelect').appendChild(opt);
|
if(first){
|
||||||
$('modelSelect').value=currentModel;
|
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();
|
if(typeof syncModelChip==='function') syncModelChip();
|
||||||
|
|||||||
@@ -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 "
|
"_normalize_session_model_in_place must not call session.save() when "
|
||||||
"the session has no stored model — no correction needed, just a fallback."
|
"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)"
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user