"""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 use @nous: prefix for explicit portal routing.""" def test_nous_models_use_at_prefix(self): """All Nous static models must carry the @nous: explicit provider prefix. This ensures they route through the @provider:model branch of resolve_model_provider() — identical to the live-fetched path — rather than relying on the slash-only portal provider guard. """ 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 mid.startswith("@nous:"), ( f"Nous model '{mid}' must start with '@nous:' " f"(e.g. @nous:anthropic/claude-opus-4.6) so it routes through " f"the explicit provider hint branch, not the weaker portal guard." ) def test_nous_known_models_present(self): """Key Nous models must be present with correct @nous:-prefixed IDs.""" from api.config import _PROVIDER_MODELS nous_ids = {m["id"] for m in _PROVIDER_MODELS.get("nous", [])} assert "@nous:anthropic/claude-opus-4.6" in nous_ids, ( "@nous:anthropic/claude-opus-4.6 must be in Nous model list" ) assert "@nous:anthropic/claude-sonnet-4.6" in nous_ids, ( "@nous:anthropic/claude-sonnet-4.6 must be in Nous model list" ) assert "@nous:openai/gpt-5.4-mini" in nous_ids, ( "@nous:openai/gpt-5.4-mini must be in Nous model list" ) def test_nous_models_no_bare_or_slash_only(self): """No Nous static model should be bare or slash-only without @nous: prefix.""" from api.config import _PROVIDER_MODELS bad_ids = { "claude-opus-4.6", "claude-sonnet-4.6", "gpt-5.4-mini", "gemini-3.1-pro-preview", "anthropic/claude-opus-4.6", "anthropic/claude-sonnet-4.6", "openai/gpt-5.4-mini", "google/gemini-3.1-pro-preview", } nous_ids = {m["id"] for m in _PROVIDER_MODELS.get("nous", [])} for bad in bad_ids: assert bad not in nous_ids, ( f"Model ID '{bad}' found in Nous static list without @nous: prefix. " f"Use '@nous:{bad}' so routing matches the live-fetched path." ) 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"