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:
@@ -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
|
||||||
|
|||||||
25
static/ui.js
25
static/ui.js
@@ -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();
|
||||||
|
|||||||
91
tests/test_issue854_live_model_prefix.py
Normal file
91
tests/test_issue854_live_model_prefix.py
Normal 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"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user