From c1da1fdcd56900868deffdf3f8026de657005598 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 16 Mar 2026 04:34:45 -0700 Subject: [PATCH] feat: auto-detect provider when switching models via /model (#1506) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When typing /model deepseek-chat while on a different provider, the model name now auto-resolves to the correct provider instead of silently staying on the wrong one and causing API errors. Detection priority: 1. Direct provider with credentials (e.g. DEEPSEEK_API_KEY set) 2. OpenRouter catalog match with proper slug remapping 3. Direct provider without creds (clear error beats silent failure) Also adds DeepSeek as a first-class API-key provider — just set DEEPSEEK_API_KEY and /model deepseek-chat routes directly. Bare model names get remapped to proper OpenRouter slugs: /model gpt-5.4 → openai/gpt-5.4 /model claude-opus-4.6 → anthropic/claude-opus-4.6 Salvages the concept from PR #1177 by @virtaava with credential awareness and OpenRouter slug mapping added. Co-authored-by: virtaava --- cli.py | 6 ++ gateway/run.py | 6 ++ hermes_cli/auth.py | 8 ++ hermes_cli/config.py | 14 +++ hermes_cli/models.py | 113 +++++++++++++++++++++++- tests/hermes_cli/test_models.py | 65 +++++++++++++- tests/test_cli_model_command.py | 4 +- tests/tools/test_local_env_blocklist.py | 2 +- 8 files changed, 213 insertions(+), 5 deletions(-) diff --git a/cli.py b/cli.py index d85fc4003..94433722f 100755 --- a/cli.py +++ b/cli.py @@ -2913,6 +2913,12 @@ class HermesCLI: # Parse provider:model syntax (e.g. "openrouter:anthropic/claude-sonnet-4.5") current_provider = self.provider or self.requested_provider or "openrouter" target_provider, new_model = parse_model_input(raw_input, current_provider) + # Auto-detect provider when no explicit provider:model syntax was used + if target_provider == current_provider: + from hermes_cli.models import detect_provider_for_model + detected = detect_provider_for_model(new_model, current_provider) + if detected: + target_provider, new_model = detected provider_changed = target_provider != current_provider # If provider is changing, re-resolve credentials for the new provider diff --git a/gateway/run.py b/gateway/run.py index 730f4ad2f..a7e637ec6 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -2106,6 +2106,12 @@ class GatewayRunner: # Parse provider:model syntax target_provider, new_model = parse_model_input(args, current_provider) + # Auto-detect provider when no explicit provider:model syntax was used + if target_provider == current_provider: + from hermes_cli.models import detect_provider_for_model + detected = detect_provider_for_model(new_model, current_provider) + if detected: + target_provider, new_model = detected provider_changed = target_provider != current_provider # Resolve credentials for the target provider (for API probe) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 37a971c3a..1863f0bb8 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -147,6 +147,14 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = { api_key_env_vars=("MINIMAX_CN_API_KEY",), base_url_env_var="MINIMAX_CN_BASE_URL", ), + "deepseek": ProviderConfig( + id="deepseek", + name="DeepSeek", + auth_type="api_key", + inference_base_url="https://api.deepseek.com/v1", + api_key_env_vars=("DEEPSEEK_API_KEY",), + base_url_env_var="DEEPSEEK_BASE_URL", + ), } diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 1fdfbad77..df30d566a 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -429,6 +429,20 @@ OPTIONAL_ENV_VARS = { "category": "provider", "advanced": True, }, + "DEEPSEEK_API_KEY": { + "description": "DeepSeek API key for direct DeepSeek access", + "prompt": "DeepSeek API Key", + "url": "https://platform.deepseek.com/api_keys", + "password": True, + "category": "provider", + }, + "DEEPSEEK_BASE_URL": { + "description": "Custom DeepSeek API base URL (advanced)", + "prompt": "DeepSeek Base URL", + "url": "", + "password": False, + "category": "provider", + }, # ── Tool API keys ── "FIRECRAWL_API_KEY": { diff --git a/hermes_cli/models.py b/hermes_cli/models.py index c4a95a021..13373afa9 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -78,6 +78,10 @@ _PROVIDER_MODELS: dict[str, list[str]] = { "claude-sonnet-4-20250514", "claude-haiku-4-5-20251001", ], + "deepseek": [ + "deepseek-chat", + "deepseek-reasoner", + ], } _PROVIDER_LABELS = { @@ -89,6 +93,7 @@ _PROVIDER_LABELS = { "minimax": "MiniMax", "minimax-cn": "MiniMax (China)", "anthropic": "Anthropic", + "deepseek": "DeepSeek", "custom": "Custom endpoint", } @@ -103,6 +108,7 @@ _PROVIDER_ALIASES = { "minimax_cn": "minimax-cn", "claude": "anthropic", "claude-code": "anthropic", + "deep-seek": "deepseek", } @@ -136,7 +142,7 @@ def list_available_providers() -> list[dict[str, str]]: # Canonical providers in display order _PROVIDER_ORDER = [ "openrouter", "nous", "openai-codex", - "zai", "kimi-coding", "minimax", "minimax-cn", "anthropic", + "zai", "kimi-coding", "minimax", "minimax-cn", "anthropic", "deepseek", ] # Build reverse alias map aliases_for: dict[str, list[str]] = {} @@ -212,6 +218,111 @@ def curated_models_for_provider(provider: Optional[str]) -> list[tuple[str, str] return [(m, "") for m in models] +def detect_provider_for_model( + model_name: str, + current_provider: str, +) -> Optional[tuple[str, str]]: + """Auto-detect the best provider for a model name. + + Returns ``(provider_id, model_name)`` — the model name may be remapped + (e.g. bare ``deepseek-chat`` → ``deepseek/deepseek-chat`` for OpenRouter). + Returns ``None`` when no confident match is found. + + Priority: + 1. Direct provider with credentials (highest) + 2. Direct provider without credentials → remap to OpenRouter slug + 3. OpenRouter catalog match + """ + name = (model_name or "").strip() + if not name: + return None + + name_lower = name.lower() + + # Aggregators list other providers' models — never auto-switch TO them + _AGGREGATORS = {"nous", "openrouter"} + + # If the model belongs to the current provider's catalog, don't suggest switching + current_models = _PROVIDER_MODELS.get(current_provider, []) + if any(name_lower == m.lower() for m in current_models): + return None + + # --- Step 1: check static provider catalogs for a direct match --- + direct_match: Optional[str] = None + for pid, models in _PROVIDER_MODELS.items(): + if pid == current_provider or pid in _AGGREGATORS: + continue + if any(name_lower == m.lower() for m in models): + direct_match = pid + break + + if direct_match: + # Check if we have credentials for this provider + has_creds = False + try: + from hermes_cli.auth import PROVIDER_REGISTRY + pconfig = PROVIDER_REGISTRY.get(direct_match) + if pconfig: + import os + for env_var in pconfig.api_key_env_vars: + if os.getenv(env_var, "").strip(): + has_creds = True + break + except Exception: + pass + + if has_creds: + return (direct_match, name) + + # No direct creds — try to find this model on OpenRouter instead + or_slug = _find_openrouter_slug(name) + if or_slug: + return ("openrouter", or_slug) + # Still return the direct provider — credential resolution will + # give a clear error rather than silently using the wrong provider + return (direct_match, name) + + # --- Step 2: check OpenRouter catalog --- + # First try exact match (handles provider/model format) + or_slug = _find_openrouter_slug(name) + if or_slug: + if current_provider != "openrouter": + return ("openrouter", or_slug) + # Already on openrouter, just return the resolved slug + if or_slug != name: + return ("openrouter", or_slug) + return None # already on openrouter with matching name + + return None + + +def _find_openrouter_slug(model_name: str) -> Optional[str]: + """Find the full OpenRouter model slug for a bare or partial model name. + + Handles: + - Exact match: ``anthropic/claude-opus-4.6`` → as-is + - Bare name: ``deepseek-chat`` → ``deepseek/deepseek-chat`` + - Bare name: ``claude-opus-4.6`` → ``anthropic/claude-opus-4.6`` + """ + name_lower = model_name.strip().lower() + if not name_lower: + return None + + # Exact match (already has provider/ prefix) + for mid, _ in OPENROUTER_MODELS: + if name_lower == mid.lower(): + return mid + + # Try matching just the model part (after the /) + for mid, _ in OPENROUTER_MODELS: + if "/" in mid: + _, model_part = mid.split("/", 1) + if name_lower == model_part.lower(): + return mid + + return None + + def normalize_provider(provider: Optional[str]) -> str: """Normalize provider aliases to Hermes' canonical provider ids. diff --git a/tests/hermes_cli/test_models.py b/tests/hermes_cli/test_models.py index 3eff1faa7..7593c2a84 100644 --- a/tests/hermes_cli/test_models.py +++ b/tests/hermes_cli/test_models.py @@ -1,6 +1,6 @@ """Tests for the hermes_cli models module.""" -from hermes_cli.models import OPENROUTER_MODELS, menu_labels, model_ids +from hermes_cli.models import OPENROUTER_MODELS, menu_labels, model_ids, detect_provider_for_model class TestModelIds: @@ -54,3 +54,66 @@ class TestOpenRouterModels: def test_at_least_5_models(self): """Sanity check that the models list hasn't been accidentally truncated.""" assert len(OPENROUTER_MODELS) >= 5 + + +class TestFindOpenrouterSlug: + def test_exact_match(self): + from hermes_cli.models import _find_openrouter_slug + assert _find_openrouter_slug("anthropic/claude-opus-4.6") == "anthropic/claude-opus-4.6" + + def test_bare_name_match(self): + from hermes_cli.models import _find_openrouter_slug + result = _find_openrouter_slug("claude-opus-4.6") + assert result == "anthropic/claude-opus-4.6" + + def test_case_insensitive(self): + from hermes_cli.models import _find_openrouter_slug + result = _find_openrouter_slug("Anthropic/Claude-Opus-4.6") + assert result is not None + + def test_unknown_returns_none(self): + from hermes_cli.models import _find_openrouter_slug + assert _find_openrouter_slug("totally-fake-model-xyz") is None + + +class TestDetectProviderForModel: + def test_anthropic_model_detected(self): + """claude-opus-4-6 should resolve to anthropic provider.""" + result = detect_provider_for_model("claude-opus-4-6", "openai-codex") + assert result is not None + assert result[0] == "anthropic" + + def test_deepseek_model_detected(self): + """deepseek-chat should resolve to deepseek provider.""" + result = detect_provider_for_model("deepseek-chat", "openai-codex") + assert result is not None + # Provider is deepseek (direct) or openrouter (fallback) depending on creds + assert result[0] in ("deepseek", "openrouter") + + def test_current_provider_model_returns_none(self): + """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_openrouter_slug_match(self): + """Models in the OpenRouter catalog should be found.""" + result = detect_provider_for_model("anthropic/claude-opus-4.6", "openai-codex") + assert result is not None + assert result[0] == "openrouter" + assert result[1] == "anthropic/claude-opus-4.6" + + def test_bare_name_gets_openrouter_slug(self): + """Bare model names should get mapped to full OpenRouter slugs.""" + result = detect_provider_for_model("claude-opus-4.6", "openai-codex") + assert result is not None + # Should find it on OpenRouter with full slug + assert result[1] == "anthropic/claude-opus-4.6" + + def test_unknown_model_returns_none(self): + """Completely unknown model names should return None.""" + assert detect_provider_for_model("nonexistent-model-xyz", "openai-codex") is None + + def test_aggregator_not_suggested(self): + """nous/openrouter should never be auto-suggested as target provider.""" + result = detect_provider_for_model("claude-opus-4-6", "openai-codex") + assert result is not None + assert result[0] not in ("nous",) # nous has claude models but shouldn't be suggested diff --git a/tests/test_cli_model_command.py b/tests/test_cli_model_command.py index 636958b0f..2a6042a70 100644 --- a/tests/test_cli_model_command.py +++ b/tests/test_cli_model_command.py @@ -64,8 +64,8 @@ class TestModelCommand: cli_obj.process_command("/model gpt-5.4") output = capsys.readouterr().out - # Model is accepted (with warning) even if not in API listing - assert cli_obj.model == "gpt-5.4" + # Auto-detection remaps bare model names to proper OpenRouter slugs + assert cli_obj.model == "openai/gpt-5.4" def test_validation_crash_falls_back_to_save(self, capsys): cli_obj = self._make_cli() diff --git a/tests/tools/test_local_env_blocklist.py b/tests/tools/test_local_env_blocklist.py index 3325a088a..3898db254 100644 --- a/tests/tools/test_local_env_blocklist.py +++ b/tests/tools/test_local_env_blocklist.py @@ -85,6 +85,7 @@ class TestProviderEnvBlocklist: "KIMI_API_KEY": "kimi-key", "MINIMAX_API_KEY": "mm-key", "MINIMAX_CN_API_KEY": "mmcn-key", + "DEEPSEEK_API_KEY": "deepseek-key", } result_env = _run_with_env(extra_os_env=registry_vars) @@ -95,7 +96,6 @@ class TestProviderEnvBlocklist: """Extra provider vars not in PROVIDER_REGISTRY must also be blocked.""" extra_provider_vars = { "GOOGLE_API_KEY": "google-key", - "DEEPSEEK_API_KEY": "deepseek-key", "MISTRAL_API_KEY": "mistral-key", "GROQ_API_KEY": "groq-key", "TOGETHER_API_KEY": "together-key",