From 201235d80766cdb130e28f50ce6dce61fdc48918 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Wed, 22 Apr 2026 13:21:02 -0700 Subject: [PATCH] =?UTF-8?q?fix:=20live-fetched=20portal=20models=20route?= =?UTF-8?q?=20through=20configured=20provider=20=E2=80=94=20v0.50.153=20(c?= =?UTF-8?q?loses=20#854)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _fetchLiveModels() applies @provider: prefix to model IDs from portal providers. --- CHANGELOG.md | 5 ++ static/ui.js | 25 ++++++- tests/test_issue854_live_model_prefix.py | 91 ++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 3 deletions(-) create mode 100644 tests/test_issue854_live_model_prefix.py diff --git a/CHANGELOG.md b/CHANGELOG.md index aacc120..e8597e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # 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 ### Fixed diff --git a/static/ui.js b/static/ui.js index 4e8a412..5b6671b 100644 --- a/static/ui.js +++ b/static/ui.js @@ -154,14 +154,31 @@ async function _fetchLiveModels(provider, sel){ // Rebuild options from live data const existingIds=new Set([...sel.options].map(o=>o.value)); 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){ - 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'); - opt.value=m.id; + opt.value=mid; opt.textContent=m.label||m.id; opt.title='Live model — fetched from provider'; providerGroup.appendChild(opt); - _dynamicModelLabels[m.id]=m.label||m.id; + _dynamicModelLabels[mid]=m.label||m.id; added++; } if(added>0){ @@ -188,6 +205,8 @@ async function _fetchLiveModels(provider, sel){ function _checkProviderMismatch(modelId){ const ap=(window._activeProvider||'').toLowerCase(); 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('/'); if(slash<0) return null; // bare model name, no provider prefix const modelProvider=modelId.substring(0,slash).toLowerCase(); diff --git a/tests/test_issue854_live_model_prefix.py b/tests/test_issue854_live_model_prefix.py new file mode 100644 index 0000000..9b6441e --- /dev/null +++ b/tests/test_issue854_live_model_prefix.py @@ -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" + )