fix: live-fetched portal models route through configured provider — v0.50.153 (closes #854)

_fetchLiveModels() applies @provider: prefix to model IDs from portal providers.
This commit is contained in:
nesquena-hermes
2026-04-22 13:21:02 -07:00
committed by GitHub
parent 256b3fbbdf
commit 201235d807
3 changed files with 118 additions and 3 deletions

View File

@@ -1,5 +1,10 @@
# Hermes Web UI -- Changelog # Hermes Web UI -- Changelog
## [v0.50.153] — 2026-04-22
### Fixed
- **Live-fetched portal models route through configured provider** — `_fetchLiveModels()` applies `@provider:` prefix. (closes #854)
## [v0.50.152] — 2026-04-22 ## [v0.50.152] — 2026-04-22
### Fixed ### Fixed

View File

@@ -154,14 +154,31 @@ async function _fetchLiveModels(provider, sel){
// Rebuild options from live data // Rebuild options from live data
const existingIds=new Set([...sel.options].map(o=>o.value)); const existingIds=new Set([...sel.options].map(o=>o.value));
let added=0; let added=0;
// Apply @provider: prefix to live-fetched model IDs (mirrors the server-side
// behaviour for static lists). Portal providers like Nous return upstream
// vendor IDs (e.g. "minimax/minimax-m2.7", "anthropic/claude-opus-4.7") —
// without a `@nous:` prefix, `resolve_model_provider()` sees the slash and
// mis-routes via OpenRouter → 404. Prefixing with `@${provider}:` makes
// the portal hint explicit so routing honours it (#854).
//
// Scope: only apply the prefix when this fetch is for the active provider
// and that provider is a portal (not OpenRouter / custom, which use bare
// or cross-namespace IDs natively). Skip IDs that already carry an
// `@prefix:` — they've already been disambiguated upstream.
const _ap=(window._activeProvider||'').toLowerCase();
const _isPortalFetch=_ap && _ap!=='openrouter' && _ap!=='custom' && provider===_ap;
for(const m of data.models){ for(const m of data.models){
if(existingIds.has(m.id)) continue; // already shown from static list let mid=m.id;
if(_isPortalFetch && !mid.startsWith('@')){
mid=`@${provider}:${mid}`;
}
if(existingIds.has(mid)) continue; // already shown from static list
const opt=document.createElement('option'); const opt=document.createElement('option');
opt.value=m.id; opt.value=mid;
opt.textContent=m.label||m.id; opt.textContent=m.label||m.id;
opt.title='Live model — fetched from provider'; opt.title='Live model — fetched from provider';
providerGroup.appendChild(opt); providerGroup.appendChild(opt);
_dynamicModelLabels[m.id]=m.label||m.id; _dynamicModelLabels[mid]=m.label||m.id;
added++; added++;
} }
if(added>0){ if(added>0){
@@ -188,6 +205,8 @@ async function _fetchLiveModels(provider, sel){
function _checkProviderMismatch(modelId){ function _checkProviderMismatch(modelId){
const ap=(window._activeProvider||'').toLowerCase(); const ap=(window._activeProvider||'').toLowerCase();
if(!ap||ap==='custom'||ap==='openrouter') return null; // can't reliably check if(!ap||ap==='custom'||ap==='openrouter') return null; // can't reliably check
// @provider: prefixed IDs came from that provider's live model list — no mismatch possible
if(modelId.startsWith('@')) return null;
const slash=modelId.indexOf('/'); const slash=modelId.indexOf('/');
if(slash<0) return null; // bare model name, no provider prefix if(slash<0) return null; // bare model name, no provider prefix
const modelProvider=modelId.substring(0,slash).toLowerCase(); const modelProvider=modelId.substring(0,slash).toLowerCase();

View File

@@ -0,0 +1,91 @@
"""Regression tests for #854 — live-fetched models must route through the
configured portal provider, not OpenRouter."""
import os
import re
_SRC = os.path.join(os.path.dirname(__file__), "..")
def _read(name):
return open(os.path.join(_SRC, name), encoding="utf-8").read()
class TestLiveModelPrefix:
"""_fetchLiveModels() must apply @provider: prefix to live-fetched model
IDs when the fetch is for the active portal provider (Nous, OpenCode,
etc.) — including IDs that already contain a slash (the upstream vendor
namespace), since those would otherwise be mis-routed via OpenRouter."""
def test_apply_prefix_to_any_non_at_id(self):
"""The prefix check must not gate on `!mid.includes('/')`. The bug
scenario in #854 is precisely about slash-prefixed IDs like
`minimax/minimax-m2.7` from Nous's live catalog — excluding them
leaves the bug unfixed."""
js = _read("static/ui.js")
m = re.search(r'async function _fetchLiveModels\(.*?\n\}', js, re.DOTALL)
assert m, "_fetchLiveModels not found"
fn = m.group(0)
# The prefix application block must NOT have `!mid.includes('/')`
# as a guard — slash-prefixed IDs from portal providers also need
# the prefix.
prefix_block = re.search(
r"if\s*\(\s*[^)]*!mid\.startsWith\(['\"]@['\"]\)[^)]*\)\s*\{\s*mid\s*=\s*`@",
fn,
)
assert prefix_block, "@provider: prefix application not found"
# The block must prefix when portal-fetch is true and not already @-prefixed.
# It must NOT check for slash presence — that's the bug.
assert "!mid.includes('/')" not in prefix_block.group(0), (
"The prefix application must NOT exclude slash-prefixed IDs — "
"portal catalogs return `minimax/minimax-m2.7` and similar that "
"need `@nous:` prefix to route through the configured portal (#854)"
)
def test_portal_fetch_flag_semantics(self):
"""The flag controlling prefix application should be named/structured
so the prefix is ADDED when the flag is true (portal fetch), not when
false. Earlier revision used `!_needsPrefix` (inverted)."""
js = _read("static/ui.js")
m = re.search(r'async function _fetchLiveModels\(.*?\n\}', js, re.DOTALL)
assert m
fn = m.group(0)
# New flag: _isPortalFetch (positive semantics)
assert "_isPortalFetch" in fn, (
"flag should be named _isPortalFetch to reflect positive semantics "
"(prefix ADDED when true, not when false)"
)
# And the prefix application should be guarded BY the flag (not by its negation)
gate = re.search(
r"if\s*\(\s*_isPortalFetch\s*&&\s*!mid\.startsWith",
fn,
)
assert gate, "prefix application must be guarded by _isPortalFetch (true ⇒ prefix)"
def test_portal_fetch_excludes_openrouter_and_custom(self):
"""OpenRouter IDs are cross-namespace by design, and `custom` providers
use user-defined bare names — neither should get a `@provider:` prefix."""
js = _read("static/ui.js")
m = re.search(r'async function _fetchLiveModels\(.*?\n\}', js, re.DOTALL)
assert m
fn = m.group(0)
assert "_ap!=='openrouter'" in fn or "_ap !== 'openrouter'" in fn, (
"portal flag must exclude openrouter"
)
assert "_ap!=='custom'" in fn or "_ap !== 'custom'" in fn, (
"portal flag must exclude custom"
)
class TestCheckProviderMismatchAtPrefix:
"""_checkProviderMismatch() must not warn on `@provider:`-prefixed IDs —
the prefix itself is an explicit provider hint, so there's no mismatch."""
def test_returns_null_for_at_prefix_ids(self):
js = _read("static/ui.js")
m = re.search(r'function _checkProviderMismatch\(.*?\n\}', js, re.DOTALL)
assert m, "_checkProviderMismatch not found"
fn = m.group(0)
assert "modelId.startsWith('@')" in fn or 'modelId.startsWith("@")' in fn, (
"_checkProviderMismatch must return null early for @provider: prefixed IDs"
)