diff --git a/CHANGELOG.md b/CHANGELOG.md index 1aafe17..bf1f291 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # 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 ### Security diff --git a/api/config.py b/api/config.py index 41578c6..7dbd94b 100644 --- a/api/config.py +++ b/api/config.py @@ -613,10 +613,10 @@ _PROVIDER_MODELS = { {"id": "deepseek-reasoner", "label": "DeepSeek Reasoner"}, ], "nous": [ - {"id": "claude-opus-4.6", "label": "Claude Opus 4.6 (via Nous)"}, - {"id": "claude-sonnet-4.6", "label": "Claude Sonnet 4.6 (via Nous)"}, - {"id": "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": "anthropic/claude-opus-4.6", "label": "Claude Opus 4.6 (via Nous)"}, + {"id": "anthropic/claude-sonnet-4.6", "label": "Claude Sonnet 4.6 (via Nous)"}, + {"id": "openai/gpt-5.4-mini", "label": "GPT-5.4 Mini (via Nous)"}, + {"id": "google/gemini-3.1-pro-preview", "label": "Gemini 3.1 Pro Preview (via Nous)"}, ], "zai": [ {"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 # Unknown prefix (not a named provider) — pass full model_id through. 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 # 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. diff --git a/tests/test_nous_portal_routing.py b/tests/test_nous_portal_routing.py new file mode 100644 index 0000000..fbf62ef --- /dev/null +++ b/tests/test_nous_portal_routing.py @@ -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"