diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 021e9c0ca..c209a8b47 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -898,7 +898,7 @@ def resolve_provider( _PROVIDER_ALIASES = { "glm": "zai", "z-ai": "zai", "z.ai": "zai", "zhipu": "zai", "google": "gemini", "google-gemini": "gemini", "google-ai-studio": "gemini", - "kimi": "kimi-coding", "moonshot": "kimi-coding", + "kimi": "kimi-coding", "kimi-for-coding": "kimi-coding", "moonshot": "kimi-coding", "minimax-china": "minimax-cn", "minimax_cn": "minimax-cn", "claude": "anthropic", "claude-code": "anthropic", "github": "copilot", "github-copilot": "copilot", diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index 56e5265be..273da0871 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -812,45 +812,66 @@ def list_authenticated_providers( # --- 2. Check Hermes-only providers (nous, openai-codex, copilot, opencode-go) --- from hermes_cli.providers import HERMES_OVERLAYS from hermes_cli.auth import PROVIDER_REGISTRY as _auth_registry + + # Build reverse mapping: models.dev ID → Hermes provider ID. + # HERMES_OVERLAYS keys may be models.dev IDs (e.g. "github-copilot") + # while _PROVIDER_MODELS and config.yaml use Hermes IDs ("copilot"). + _mdev_to_hermes = {v: k for k, v in PROVIDER_TO_MODELS_DEV.items()} + for pid, overlay in HERMES_OVERLAYS.items(): if pid in seen_slugs: continue + + # Resolve Hermes slug — e.g. "github-copilot" → "copilot" + hermes_slug = _mdev_to_hermes.get(pid, pid) + if hermes_slug in seen_slugs: + continue + # Check if credentials exist has_creds = False if overlay.extra_env_vars: has_creds = any(os.environ.get(ev) for ev in overlay.extra_env_vars) # Also check api_key_env_vars from PROVIDER_REGISTRY for api_key auth_type if not has_creds and overlay.auth_type == "api_key": - pcfg = _auth_registry.get(pid) - if pcfg and pcfg.api_key_env_vars: - has_creds = any(os.environ.get(ev) for ev in pcfg.api_key_env_vars) - if overlay.auth_type in ("oauth_device_code", "oauth_external", "external_process"): + for _key in (pid, hermes_slug): + pcfg = _auth_registry.get(_key) + if pcfg and pcfg.api_key_env_vars: + if any(os.environ.get(ev) for ev in pcfg.api_key_env_vars): + has_creds = True + break + if not has_creds and overlay.auth_type in ("oauth_device_code", "oauth_external", "external_process"): # These use auth stores, not env vars — check for auth.json entries try: from hermes_cli.auth import _load_auth_store store = _load_auth_store() - if store and (pid in store.get("providers", {}) or pid in store.get("credential_pool", {})): + providers_store = store.get("providers", {}) + pool_store = store.get("credential_pool", {}) + if store and ( + pid in providers_store or hermes_slug in providers_store + or pid in pool_store or hermes_slug in pool_store + ): has_creds = True except Exception as exc: logger.debug("Auth store check failed for %s: %s", pid, exc) if not has_creds: continue - # Use curated list - model_ids = curated.get(pid, []) + # Use curated list — look up by Hermes slug, fall back to overlay key + model_ids = curated.get(hermes_slug, []) or curated.get(pid, []) total = len(model_ids) top = model_ids[:max_models] results.append({ - "slug": pid, - "name": get_label(pid), - "is_current": pid == current_provider, + "slug": hermes_slug, + "name": get_label(hermes_slug), + "is_current": hermes_slug == current_provider or pid == current_provider, "is_user_defined": False, "models": top, "total_models": total, "source": "hermes", }) seen_slugs.add(pid) + seen_slugs.add(hermes_slug) # --- 3. User-defined endpoints from config --- if user_providers and isinstance(user_providers, dict): diff --git a/tests/hermes_cli/test_overlay_slug_resolution.py b/tests/hermes_cli/test_overlay_slug_resolution.py new file mode 100644 index 000000000..ccd3748fb --- /dev/null +++ b/tests/hermes_cli/test_overlay_slug_resolution.py @@ -0,0 +1,83 @@ +"""Test that overlay providers with mismatched models.dev keys resolve correctly. + +HERMES_OVERLAYS keys may be models.dev IDs (e.g. "github-copilot") while +_PROVIDER_MODELS and config.yaml use Hermes IDs ("copilot"). The slug +resolution in list_authenticated_providers() Section 2 must bridge this gap. + +Covers: #5223, #6492 +""" + +import json +import os +from unittest.mock import patch + +import pytest + +from hermes_cli.model_switch import list_authenticated_providers + + +# -- Copilot slug resolution (env var path) ---------------------------------- + +@patch.dict(os.environ, {"COPILOT_GITHUB_TOKEN": "fake-ghu"}, clear=False) +def test_copilot_uses_hermes_slug(): + """github-copilot overlay should resolve to slug='copilot' with curated models.""" + providers = list_authenticated_providers(current_provider="copilot") + + copilot = next((p for p in providers if p["slug"] == "copilot"), None) + assert copilot is not None, "copilot should appear when COPILOT_GITHUB_TOKEN is set" + assert copilot["total_models"] > 0, "copilot should have curated models" + assert copilot["is_current"] is True + + # Must NOT appear under the models.dev key + gh_copilot = next((p for p in providers if p["slug"] == "github-copilot"), None) + assert gh_copilot is None, "github-copilot slug should not appear (resolved to copilot)" + + +@patch.dict(os.environ, {"COPILOT_GITHUB_TOKEN": "fake-ghu"}, clear=False) +def test_copilot_no_duplicate_entries(): + """Copilot must appear only once — not as both 'copilot' (section 1) and 'github-copilot' (section 2).""" + providers = list_authenticated_providers(current_provider="copilot") + + copilot_slugs = [p["slug"] for p in providers if "copilot" in p["slug"]] + # Should have at most one copilot entry (may also have copilot-acp if creds exist) + copilot_main = [s for s in copilot_slugs if s == "copilot"] + assert len(copilot_main) == 1, f"Expected exactly one 'copilot' entry, got {copilot_main}" + + +# -- kimi-for-coding alias in auth.py ---------------------------------------- + +def test_kimi_for_coding_alias(): + """resolve_provider('kimi-for-coding') should return 'kimi-coding'.""" + from hermes_cli.auth import resolve_provider + + result = resolve_provider("kimi-for-coding") + assert result == "kimi-coding" + + +# -- Generic slug mismatch providers ----------------------------------------- + +@patch.dict(os.environ, {"KIMI_API_KEY": "fake-key"}, clear=False) +def test_kimi_for_coding_overlay_uses_hermes_slug(): + """kimi-for-coding overlay should resolve to slug='kimi-coding'.""" + providers = list_authenticated_providers(current_provider="kimi-coding") + + kimi = next((p for p in providers if p["slug"] == "kimi-coding"), None) + assert kimi is not None, "kimi-coding should appear when KIMI_API_KEY is set" + assert kimi["is_current"] is True + + # Must NOT appear under the models.dev key + kimi_mdev = next((p for p in providers if p["slug"] == "kimi-for-coding"), None) + assert kimi_mdev is None, "kimi-for-coding slug should not appear (resolved to kimi-coding)" + + +@patch.dict(os.environ, {"KILOCODE_API_KEY": "fake-key"}, clear=False) +def test_kilo_overlay_uses_hermes_slug(): + """kilo overlay should resolve to slug='kilocode'.""" + providers = list_authenticated_providers(current_provider="kilocode") + + kilo = next((p for p in providers if p["slug"] == "kilocode"), None) + assert kilo is not None, "kilocode should appear when KILOCODE_API_KEY is set" + assert kilo["is_current"] is True + + kilo_mdev = next((p for p in providers if p["slug"] == "kilo"), None) + assert kilo_mdev is None, "kilo slug should not appear (resolved to kilocode)"