fix: Nous portal model IDs + portal provider routing guard — v0.50.157 (closes #854)
Two bugs fixed: (1) _PROVIDER_MODELS["nous"] updated to slash-prefixed IDs that Nous API expects. (2) resolve_model_provider() now routes portal provider models through the portal (not OpenRouter) and preserves the full slash-prefixed model ID. 10 regression tests.
This commit is contained in:
@@ -1,5 +1,10 @@
|
|||||||
# Hermes Web UI -- Changelog
|
# Hermes Web UI -- Changelog
|
||||||
|
|
||||||
|
## [v0.50.157] — 2026-04-22
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Nous portal models now route and format correctly** — two bugs fixed: (1) `_PROVIDER_MODELS["nous"]` updated from bare IDs (`claude-opus-4.6`) to slash-prefixed format (`anthropic/claude-opus-4.6`) that the Nous portal API expects. (2) `resolve_model_provider()` now routes cross-namespace models through portal providers (Nous, OpenCode Zen, OpenCode Go) directly instead of mis-routing to OpenRouter. Portal guard returns the full slash-preserved model ID so Nous receives the correct format. 10 regression tests. (`api/config.py`) (closes #854)
|
||||||
|
|
||||||
## [v0.50.156] — 2026-04-22
|
## [v0.50.156] — 2026-04-22
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|||||||
@@ -613,10 +613,10 @@ _PROVIDER_MODELS = {
|
|||||||
{"id": "deepseek-reasoner", "label": "DeepSeek Reasoner"},
|
{"id": "deepseek-reasoner", "label": "DeepSeek Reasoner"},
|
||||||
],
|
],
|
||||||
"nous": [
|
"nous": [
|
||||||
{"id": "claude-opus-4.6", "label": "Claude Opus 4.6 (via Nous)"},
|
{"id": "anthropic/claude-opus-4.6", "label": "Claude Opus 4.6 (via Nous)"},
|
||||||
{"id": "claude-sonnet-4.6", "label": "Claude Sonnet 4.6 (via Nous)"},
|
{"id": "anthropic/claude-sonnet-4.6", "label": "Claude Sonnet 4.6 (via Nous)"},
|
||||||
{"id": "gpt-5.4-mini", "label": "GPT-5.4 Mini (via Nous)"},
|
{"id": "openai/gpt-5.4-mini", "label": "GPT-5.4 Mini (via Nous)"},
|
||||||
{"id": "gemini-3.1-pro-preview", "label": "Gemini 3.1 Pro Preview (via Nous)"},
|
{"id": "google/gemini-3.1-pro-preview", "label": "Gemini 3.1 Pro Preview (via Nous)"},
|
||||||
],
|
],
|
||||||
"zai": [
|
"zai": [
|
||||||
{"id": "glm-5.1", "label": "GLM-5.1"},
|
{"id": "glm-5.1", "label": "GLM-5.1"},
|
||||||
@@ -863,6 +863,21 @@ def resolve_model_provider(model_id: str) -> tuple:
|
|||||||
return bare, config_provider, config_base_url
|
return bare, config_provider, config_base_url
|
||||||
# Unknown prefix (not a named provider) — pass full model_id through.
|
# Unknown prefix (not a named provider) — pass full model_id through.
|
||||||
return model_id, config_provider, config_base_url
|
return model_id, config_provider, config_base_url
|
||||||
|
# Portal providers (Nous, OpenCode) serve models from multiple upstream
|
||||||
|
# namespaces. When the active provider is a portal, trust it for cross-
|
||||||
|
# namespace models rather than rerouting to OpenRouter — the portal
|
||||||
|
# handles the upstream routing itself. Preserve the full slash-prefixed
|
||||||
|
# model ID: portals use the provider/model path as the canonical name
|
||||||
|
# at their /chat/completions endpoint (e.g. Nous rejects a bare
|
||||||
|
# "claude-opus-4.6" — it needs "anthropic/claude-opus-4.6" to route
|
||||||
|
# upstream). This keeps the static-dropdown path consistent with the
|
||||||
|
# live-fetched path (`@nous:anthropic/claude-opus-4.6`), which also
|
||||||
|
# preserves the slash via the split-at-first-colon in the @-prefix
|
||||||
|
# branch above.
|
||||||
|
_PORTAL_PROVIDERS = {"nous", "opencode-zen", "opencode-go"}
|
||||||
|
if config_provider in _PORTAL_PROVIDERS:
|
||||||
|
return model_id, config_provider, config_base_url
|
||||||
|
|
||||||
# If prefix does NOT match config provider, the user picked a cross-provider model
|
# If prefix does NOT match config provider, the user picked a cross-provider model
|
||||||
# from the OpenRouter dropdown (e.g. config=anthropic but picked openai/gpt-5.4-mini).
|
# from the OpenRouter dropdown (e.g. config=anthropic but picked openai/gpt-5.4-mini).
|
||||||
# In this case always route through openrouter with the full provider/model string.
|
# In this case always route through openrouter with the full provider/model string.
|
||||||
|
|||||||
167
tests/test_nous_portal_routing.py
Normal file
167
tests/test_nous_portal_routing.py
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
"""Regression tests for Nous portal model routing bugs (issue #854).
|
||||||
|
|
||||||
|
Two bugs fixed:
|
||||||
|
1. Nous static model IDs were bare names (claude-opus-4.6) instead of
|
||||||
|
slash-prefixed (anthropic/claude-opus-4.6), causing Nous to reject them.
|
||||||
|
2. resolve_model_provider() routed slash-prefixed cross-namespace models
|
||||||
|
through OpenRouter instead of the configured portal provider.
|
||||||
|
|
||||||
|
Invariant: when a portal provider (Nous, OpenCode) is active, the full
|
||||||
|
slash-prefixed model ID MUST be preserved end-to-end — portals use the
|
||||||
|
provider/model path as the canonical name at their inference endpoint.
|
||||||
|
Stripping the prefix to a bare name is exactly Bug 1, so the fix for Bug 2
|
||||||
|
must not reintroduce it.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
|
||||||
|
|
||||||
|
def _models_with_provider(provider, monkeypatch):
|
||||||
|
"""Patch config.cfg to simulate an active provider, return resolve_model_provider."""
|
||||||
|
import api.config as config
|
||||||
|
|
||||||
|
old = dict(config.cfg)
|
||||||
|
config.cfg.clear()
|
||||||
|
config.cfg["model"] = {"provider": provider}
|
||||||
|
try:
|
||||||
|
config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime
|
||||||
|
except Exception:
|
||||||
|
config._cfg_mtime = 0.0
|
||||||
|
try:
|
||||||
|
from api.config import resolve_model_provider
|
||||||
|
return resolve_model_provider
|
||||||
|
finally:
|
||||||
|
config.cfg.clear()
|
||||||
|
config.cfg.update(old)
|
||||||
|
|
||||||
|
|
||||||
|
class TestNousModelIds:
|
||||||
|
"""Nous static model IDs must be slash-prefixed for Nous API compatibility."""
|
||||||
|
|
||||||
|
def test_nous_models_use_slash_prefixed_ids(self):
|
||||||
|
"""All Nous static models must carry a provider/model slash prefix."""
|
||||||
|
from api.config import _PROVIDER_MODELS
|
||||||
|
nous_models = _PROVIDER_MODELS.get("nous", [])
|
||||||
|
assert nous_models, "Nous must have at least one static model"
|
||||||
|
for m in nous_models:
|
||||||
|
mid = m["id"]
|
||||||
|
assert "/" in mid, (
|
||||||
|
f"Nous model '{mid}' must be in provider/model format "
|
||||||
|
f"(e.g. anthropic/claude-opus-4.6) so Nous routes it correctly. "
|
||||||
|
f"Bare names cause Nous to reject the request."
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_nous_known_models_present(self):
|
||||||
|
"""Key Nous models must be present with correct slash-prefixed IDs."""
|
||||||
|
from api.config import _PROVIDER_MODELS
|
||||||
|
nous_ids = {m["id"] for m in _PROVIDER_MODELS.get("nous", [])}
|
||||||
|
assert "anthropic/claude-opus-4.6" in nous_ids, (
|
||||||
|
"anthropic/claude-opus-4.6 must be in Nous model list"
|
||||||
|
)
|
||||||
|
assert "anthropic/claude-sonnet-4.6" in nous_ids, (
|
||||||
|
"anthropic/claude-sonnet-4.6 must be in Nous model list"
|
||||||
|
)
|
||||||
|
assert "openai/gpt-5.4-mini" in nous_ids, (
|
||||||
|
"openai/gpt-5.4-mini must be in Nous model list"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_nous_models_no_bare_names(self):
|
||||||
|
"""No Nous model should use a bare name without a slash prefix."""
|
||||||
|
from api.config import _PROVIDER_MODELS
|
||||||
|
bare_names = {"claude-opus-4.6", "claude-sonnet-4.6", "gpt-5.4-mini",
|
||||||
|
"gemini-3.1-pro-preview"}
|
||||||
|
nous_ids = {m["id"] for m in _PROVIDER_MODELS.get("nous", [])}
|
||||||
|
for bare in bare_names:
|
||||||
|
assert bare not in nous_ids, (
|
||||||
|
f"Bare model ID '{bare}' found in Nous model list. "
|
||||||
|
f"Must be slash-prefixed (e.g. anthropic/{bare})."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPortalProviderRouting:
|
||||||
|
"""Portal providers (Nous, OpenCode) must route cross-namespace models
|
||||||
|
through themselves, not through OpenRouter."""
|
||||||
|
|
||||||
|
def _resolve(self, model_id, provider):
|
||||||
|
import api.config as config
|
||||||
|
old = dict(config.cfg)
|
||||||
|
old_mtime = config._cfg_mtime
|
||||||
|
config.cfg.clear()
|
||||||
|
config.cfg["model"] = {"provider": provider}
|
||||||
|
try:
|
||||||
|
config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime
|
||||||
|
except Exception:
|
||||||
|
config._cfg_mtime = 0.0
|
||||||
|
try:
|
||||||
|
from api.config import resolve_model_provider
|
||||||
|
return resolve_model_provider(model_id)
|
||||||
|
finally:
|
||||||
|
config.cfg.clear()
|
||||||
|
config.cfg.update(old)
|
||||||
|
config._cfg_mtime = old_mtime
|
||||||
|
|
||||||
|
def test_nous_routes_anthropic_model(self):
|
||||||
|
"""anthropic/claude-opus-4.6 with nous provider must route to nous with
|
||||||
|
the full slash-prefixed ID preserved — Nous rejects bare names."""
|
||||||
|
model, provider, _ = self._resolve("anthropic/claude-opus-4.6", "nous")
|
||||||
|
assert provider == "nous", (
|
||||||
|
f"Expected provider='nous', got '{provider}'. "
|
||||||
|
f"Nous portal must handle cross-namespace models directly."
|
||||||
|
)
|
||||||
|
assert model == "anthropic/claude-opus-4.6", (
|
||||||
|
f"Expected full slash-prefixed 'anthropic/claude-opus-4.6', got '{model}'. "
|
||||||
|
f"Portals need the provider/model path to route upstream (Bug 1)."
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_nous_routes_openai_model(self):
|
||||||
|
"""openai/gpt-5.4-mini with nous provider must route to nous with slash preserved."""
|
||||||
|
model, provider, _ = self._resolve("openai/gpt-5.4-mini", "nous")
|
||||||
|
assert provider == "nous", f"Expected provider='nous', got '{provider}'."
|
||||||
|
assert model == "openai/gpt-5.4-mini", (
|
||||||
|
f"Expected 'openai/gpt-5.4-mini', got '{model}' — portal must preserve namespace."
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_nous_routes_google_model(self):
|
||||||
|
"""google/gemini-3.1-pro-preview with nous provider must route to nous with slash preserved."""
|
||||||
|
model, provider, _ = self._resolve("google/gemini-3.1-pro-preview", "nous")
|
||||||
|
assert provider == "nous", f"Expected provider='nous', got '{provider}'."
|
||||||
|
assert model == "google/gemini-3.1-pro-preview", (
|
||||||
|
f"Expected 'google/gemini-3.1-pro-preview', got '{model}'."
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_opencode_zen_routes_cross_namespace(self):
|
||||||
|
"""opencode-zen is also a portal — cross-namespace models must route through it
|
||||||
|
with the slash-prefixed ID preserved."""
|
||||||
|
model, provider, _ = self._resolve("anthropic/claude-sonnet-4.6", "opencode-zen")
|
||||||
|
assert provider == "opencode-zen", f"Expected provider='opencode-zen', got '{provider}'."
|
||||||
|
assert model == "anthropic/claude-sonnet-4.6", (
|
||||||
|
f"Expected 'anthropic/claude-sonnet-4.6', got '{model}'."
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_portal_path_matches_at_prefix_path(self):
|
||||||
|
"""Static dropdown (bare slash) and live-fetched (@provider: prefix) paths
|
||||||
|
must produce identical resolver output for the same model — otherwise
|
||||||
|
Nous receives different forms depending on catalog source.
|
||||||
|
"""
|
||||||
|
# Static dropdown form
|
||||||
|
m1, p1, _ = self._resolve("anthropic/claude-opus-4.6", "nous")
|
||||||
|
# Live-fetched form (after ui.js _fetchLiveModels prefixes with @nous:)
|
||||||
|
m2, p2, _ = self._resolve("@nous:anthropic/claude-opus-4.6", "nous")
|
||||||
|
assert (m1, p1) == (m2, p2), (
|
||||||
|
f"Static path {m1, p1} and live path {m2, p2} must match — "
|
||||||
|
f"both should send the same model ID to Nous."
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_non_portal_still_routes_to_openrouter(self):
|
||||||
|
"""Non-portal providers (anthropic) must still route cross-namespace to OpenRouter."""
|
||||||
|
model, provider, _ = self._resolve("openai/gpt-5.4-mini", "anthropic")
|
||||||
|
assert provider == "openrouter", (
|
||||||
|
f"Expected provider='openrouter' for cross-namespace with anthropic config, "
|
||||||
|
f"got '{provider}'."
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_openrouter_config_keeps_full_path(self):
|
||||||
|
"""OpenRouter config must always keep the full provider/model path."""
|
||||||
|
model, provider, _ = self._resolve("anthropic/claude-sonnet-4.6", "openrouter")
|
||||||
|
assert provider == "openrouter"
|
||||||
|
assert model == "anthropic/claude-sonnet-4.6"
|
||||||
Reference in New Issue
Block a user