diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 494c3d2a9a..93a5111c56 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -1393,6 +1393,59 @@ def _model_in_provider_catalog(name_lower: str, providers: set[str]) -> bool: ) +_AGGREGATOR_PROVIDERS = frozenset( + {"nous", "openrouter", "ai-gateway", "copilot", "kilocode"} +) + + +def _resolve_static_model_alias( + name_lower: str, + current_keys: set[str], +) -> Optional[tuple[str, str]]: + """Resolve short aliases (e.g. sonnet/opus) using static catalogs only.""" + try: + from hermes_cli.model_switch import MODEL_ALIASES + except Exception: + return None + + identity = MODEL_ALIASES.get(name_lower) + if identity is None: + return None + + vendor = identity.vendor + family = identity.family + + def _match(provider: str) -> Optional[str]: + models = _PROVIDER_MODELS.get(provider, []) + if not models: + return None + prefix = ( + f"{vendor}/{family}" + if provider in _AGGREGATOR_PROVIDERS + else family + ).lower() + for model in models: + if model.lower().startswith(prefix): + return model + return None + + for provider in current_keys: + if matched := _match(provider): + return provider, matched + + for provider in _PROVIDER_MODELS: + if provider in current_keys or provider in _AGGREGATOR_PROVIDERS: + continue + if matched := _match(provider): + return provider, matched + + for provider in _AGGREGATOR_PROVIDERS: + if provider in current_keys and (matched := _match(provider)): + return provider, matched + + return None + + def detect_static_provider_for_model( model_name: str, current_provider: str, @@ -1409,6 +1462,10 @@ def detect_static_provider_for_model( name_lower = name.lower() current_keys = _provider_keys(current_provider) + alias_match = _resolve_static_model_alias(name_lower, current_keys) + if alias_match: + return alias_match + # --- Step 0: bare provider name typed as model --- # If someone types `/model nous` or `/model anthropic`, treat it as a # provider switch and pick the first model from that provider's catalog. @@ -1425,15 +1482,13 @@ def detect_static_provider_for_model( return (resolved_provider, default_models[0]) # Aggregators list other providers' models — never auto-switch TO them - _AGGREGATORS = {"nous", "openrouter", "ai-gateway", "copilot", "kilocode"} - # If the model belongs to the current provider's catalog, don't suggest switching if _model_in_provider_catalog(name_lower, current_keys): return None # --- Step 1: check static provider catalogs for a direct match --- for pid, models in _PROVIDER_MODELS.items(): - if pid in current_keys or pid in _AGGREGATORS: + if pid in current_keys or pid in _AGGREGATOR_PROVIDERS: continue if any(name_lower == m.lower() for m in models): return (pid, name) diff --git a/tests/hermes_cli/test_models.py b/tests/hermes_cli/test_models.py index b493fd2b63..d0201a3e80 100644 --- a/tests/hermes_cli/test_models.py +++ b/tests/hermes_cli/test_models.py @@ -256,6 +256,17 @@ class TestDetectProviderForModel: """Models belonging to the current provider should not trigger a switch.""" assert detect_provider_for_model("gpt-5.3-codex", "openai-codex") is None + def test_short_alias_resolves_to_static_model(self): + """Short aliases (e.g. sonnet) should resolve without network lookups.""" + with patch( + "hermes_cli.models.fetch_openrouter_models", + side_effect=AssertionError("network lookup should not run"), + ): + result = detect_provider_for_model("sonnet", "auto") + assert result is not None + assert result[0] == "anthropic" + assert result[1].startswith("claude-sonnet") + def test_openrouter_slug_match(self): """Models in the OpenRouter catalog should be found.""" with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS): diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 4fd2322b5b..d4cdab8421 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -141,6 +141,24 @@ def test_startup_runtime_detects_provider_for_model_env(monkeypatch): ) +def test_startup_runtime_resolves_short_alias_without_network(monkeypatch): + monkeypatch.setenv("HERMES_MODEL", "sonnet") + monkeypatch.delenv("HERMES_TUI_PROVIDER", raising=False) + monkeypatch.delenv("HERMES_INFERENCE_PROVIDER", raising=False) + monkeypatch.setattr(server, "_load_cfg", lambda: {"model": {"provider": "auto"}}) + monkeypatch.setattr( + "hermes_cli.models.fetch_openrouter_models", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("network lookup should not run") + ), + ) + + model, provider = server._resolve_startup_runtime() + + assert provider == "anthropic" + assert model.startswith("claude-sonnet") + + def test_startup_runtime_does_not_call_network_detector(monkeypatch): monkeypatch.setenv("HERMES_MODEL", "sonnet") monkeypatch.delenv("HERMES_TUI_PROVIDER", raising=False)