feat: auto-detect provider when switching models via /model (#1506)
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 <virtaava@users.noreply.github.com>
This commit is contained in:
parent
9cf7e2f0af
commit
c1da1fdcd5
8 changed files with 213 additions and 5 deletions
6
cli.py
6
cli.py
|
|
@ -2913,6 +2913,12 @@ class HermesCLI:
|
||||||
# Parse provider:model syntax (e.g. "openrouter:anthropic/claude-sonnet-4.5")
|
# Parse provider:model syntax (e.g. "openrouter:anthropic/claude-sonnet-4.5")
|
||||||
current_provider = self.provider or self.requested_provider or "openrouter"
|
current_provider = self.provider or self.requested_provider or "openrouter"
|
||||||
target_provider, new_model = parse_model_input(raw_input, current_provider)
|
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
|
provider_changed = target_provider != current_provider
|
||||||
|
|
||||||
# If provider is changing, re-resolve credentials for the new provider
|
# If provider is changing, re-resolve credentials for the new provider
|
||||||
|
|
|
||||||
|
|
@ -2106,6 +2106,12 @@ class GatewayRunner:
|
||||||
|
|
||||||
# Parse provider:model syntax
|
# Parse provider:model syntax
|
||||||
target_provider, new_model = parse_model_input(args, current_provider)
|
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
|
provider_changed = target_provider != current_provider
|
||||||
|
|
||||||
# Resolve credentials for the target provider (for API probe)
|
# Resolve credentials for the target provider (for API probe)
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,14 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
|
||||||
api_key_env_vars=("MINIMAX_CN_API_KEY",),
|
api_key_env_vars=("MINIMAX_CN_API_KEY",),
|
||||||
base_url_env_var="MINIMAX_CN_BASE_URL",
|
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",
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -429,6 +429,20 @@ OPTIONAL_ENV_VARS = {
|
||||||
"category": "provider",
|
"category": "provider",
|
||||||
"advanced": True,
|
"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 ──
|
# ── Tool API keys ──
|
||||||
"FIRECRAWL_API_KEY": {
|
"FIRECRAWL_API_KEY": {
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,10 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
||||||
"claude-sonnet-4-20250514",
|
"claude-sonnet-4-20250514",
|
||||||
"claude-haiku-4-5-20251001",
|
"claude-haiku-4-5-20251001",
|
||||||
],
|
],
|
||||||
|
"deepseek": [
|
||||||
|
"deepseek-chat",
|
||||||
|
"deepseek-reasoner",
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
_PROVIDER_LABELS = {
|
_PROVIDER_LABELS = {
|
||||||
|
|
@ -89,6 +93,7 @@ _PROVIDER_LABELS = {
|
||||||
"minimax": "MiniMax",
|
"minimax": "MiniMax",
|
||||||
"minimax-cn": "MiniMax (China)",
|
"minimax-cn": "MiniMax (China)",
|
||||||
"anthropic": "Anthropic",
|
"anthropic": "Anthropic",
|
||||||
|
"deepseek": "DeepSeek",
|
||||||
"custom": "Custom endpoint",
|
"custom": "Custom endpoint",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -103,6 +108,7 @@ _PROVIDER_ALIASES = {
|
||||||
"minimax_cn": "minimax-cn",
|
"minimax_cn": "minimax-cn",
|
||||||
"claude": "anthropic",
|
"claude": "anthropic",
|
||||||
"claude-code": "anthropic",
|
"claude-code": "anthropic",
|
||||||
|
"deep-seek": "deepseek",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -136,7 +142,7 @@ def list_available_providers() -> list[dict[str, str]]:
|
||||||
# Canonical providers in display order
|
# Canonical providers in display order
|
||||||
_PROVIDER_ORDER = [
|
_PROVIDER_ORDER = [
|
||||||
"openrouter", "nous", "openai-codex",
|
"openrouter", "nous", "openai-codex",
|
||||||
"zai", "kimi-coding", "minimax", "minimax-cn", "anthropic",
|
"zai", "kimi-coding", "minimax", "minimax-cn", "anthropic", "deepseek",
|
||||||
]
|
]
|
||||||
# Build reverse alias map
|
# Build reverse alias map
|
||||||
aliases_for: dict[str, list[str]] = {}
|
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]
|
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:
|
def normalize_provider(provider: Optional[str]) -> str:
|
||||||
"""Normalize provider aliases to Hermes' canonical provider ids.
|
"""Normalize provider aliases to Hermes' canonical provider ids.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"""Tests for the hermes_cli models module."""
|
"""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:
|
class TestModelIds:
|
||||||
|
|
@ -54,3 +54,66 @@ class TestOpenRouterModels:
|
||||||
def test_at_least_5_models(self):
|
def test_at_least_5_models(self):
|
||||||
"""Sanity check that the models list hasn't been accidentally truncated."""
|
"""Sanity check that the models list hasn't been accidentally truncated."""
|
||||||
assert len(OPENROUTER_MODELS) >= 5
|
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
|
||||||
|
|
|
||||||
|
|
@ -64,8 +64,8 @@ class TestModelCommand:
|
||||||
cli_obj.process_command("/model gpt-5.4")
|
cli_obj.process_command("/model gpt-5.4")
|
||||||
|
|
||||||
output = capsys.readouterr().out
|
output = capsys.readouterr().out
|
||||||
# Model is accepted (with warning) even if not in API listing
|
# Auto-detection remaps bare model names to proper OpenRouter slugs
|
||||||
assert cli_obj.model == "gpt-5.4"
|
assert cli_obj.model == "openai/gpt-5.4"
|
||||||
|
|
||||||
def test_validation_crash_falls_back_to_save(self, capsys):
|
def test_validation_crash_falls_back_to_save(self, capsys):
|
||||||
cli_obj = self._make_cli()
|
cli_obj = self._make_cli()
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,7 @@ class TestProviderEnvBlocklist:
|
||||||
"KIMI_API_KEY": "kimi-key",
|
"KIMI_API_KEY": "kimi-key",
|
||||||
"MINIMAX_API_KEY": "mm-key",
|
"MINIMAX_API_KEY": "mm-key",
|
||||||
"MINIMAX_CN_API_KEY": "mmcn-key",
|
"MINIMAX_CN_API_KEY": "mmcn-key",
|
||||||
|
"DEEPSEEK_API_KEY": "deepseek-key",
|
||||||
}
|
}
|
||||||
result_env = _run_with_env(extra_os_env=registry_vars)
|
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 not in PROVIDER_REGISTRY must also be blocked."""
|
||||||
extra_provider_vars = {
|
extra_provider_vars = {
|
||||||
"GOOGLE_API_KEY": "google-key",
|
"GOOGLE_API_KEY": "google-key",
|
||||||
"DEEPSEEK_API_KEY": "deepseek-key",
|
|
||||||
"MISTRAL_API_KEY": "mistral-key",
|
"MISTRAL_API_KEY": "mistral-key",
|
||||||
"GROQ_API_KEY": "groq-key",
|
"GROQ_API_KEY": "groq-key",
|
||||||
"TOGETHER_API_KEY": "together-key",
|
"TOGETHER_API_KEY": "together-key",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue