If a user has Claude Code installed but never configured Hermes, the first-run guard found those external credentials and skipped the setup wizard. Users got silently routed to someone else's inference without being asked. Now _has_any_provider_configured() checks whether Hermes itself has been explicitly configured (model in config differs from hardcoded default) before counting Claude Code credentials. Fresh installs trigger the wizard regardless of what external tools are on the machine. Salvaged from PR #4194 by sudoingX — wizard trigger fix only. Model auto-detect change under separate review. Co-authored-by: Xpress AI (Dip KD) <200180104+sudoingX@users.noreply.github.com>
838 lines
37 KiB
Python
838 lines
37 KiB
Python
"""Tests for API-key provider support (z.ai/GLM, Kimi, MiniMax, AI Gateway)."""
|
|
|
|
import os
|
|
import sys
|
|
import types
|
|
|
|
import pytest
|
|
|
|
# Ensure dotenv doesn't interfere
|
|
if "dotenv" not in sys.modules:
|
|
fake_dotenv = types.ModuleType("dotenv")
|
|
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
|
|
sys.modules["dotenv"] = fake_dotenv
|
|
|
|
from hermes_cli.auth import (
|
|
PROVIDER_REGISTRY,
|
|
ProviderConfig,
|
|
resolve_provider,
|
|
get_api_key_provider_status,
|
|
resolve_api_key_provider_credentials,
|
|
get_external_process_provider_status,
|
|
resolve_external_process_provider_credentials,
|
|
get_auth_status,
|
|
AuthError,
|
|
KIMI_CODE_BASE_URL,
|
|
_try_gh_cli_token,
|
|
_resolve_kimi_base_url,
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Provider Registry tests
|
|
# =============================================================================
|
|
|
|
class TestProviderRegistry:
|
|
"""Test that new providers are correctly registered."""
|
|
|
|
@pytest.mark.parametrize("provider_id,name,auth_type", [
|
|
("copilot-acp", "GitHub Copilot ACP", "external_process"),
|
|
("copilot", "GitHub Copilot", "api_key"),
|
|
("huggingface", "Hugging Face", "api_key"),
|
|
("zai", "Z.AI / GLM", "api_key"),
|
|
("kimi-coding", "Kimi / Moonshot", "api_key"),
|
|
("minimax", "MiniMax", "api_key"),
|
|
("minimax-cn", "MiniMax (China)", "api_key"),
|
|
("ai-gateway", "AI Gateway", "api_key"),
|
|
("kilocode", "Kilo Code", "api_key"),
|
|
])
|
|
def test_provider_registered(self, provider_id, name, auth_type):
|
|
assert provider_id in PROVIDER_REGISTRY
|
|
pconfig = PROVIDER_REGISTRY[provider_id]
|
|
assert pconfig.name == name
|
|
assert pconfig.auth_type == auth_type
|
|
assert pconfig.inference_base_url # must have a default base URL
|
|
|
|
def test_zai_env_vars(self):
|
|
pconfig = PROVIDER_REGISTRY["zai"]
|
|
assert pconfig.api_key_env_vars == ("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY")
|
|
assert pconfig.base_url_env_var == "GLM_BASE_URL"
|
|
|
|
def test_copilot_env_vars(self):
|
|
pconfig = PROVIDER_REGISTRY["copilot"]
|
|
assert pconfig.api_key_env_vars == ("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN")
|
|
assert pconfig.base_url_env_var == ""
|
|
|
|
def test_kimi_env_vars(self):
|
|
pconfig = PROVIDER_REGISTRY["kimi-coding"]
|
|
assert pconfig.api_key_env_vars == ("KIMI_API_KEY",)
|
|
assert pconfig.base_url_env_var == "KIMI_BASE_URL"
|
|
|
|
def test_minimax_env_vars(self):
|
|
pconfig = PROVIDER_REGISTRY["minimax"]
|
|
assert pconfig.api_key_env_vars == ("MINIMAX_API_KEY",)
|
|
assert pconfig.base_url_env_var == "MINIMAX_BASE_URL"
|
|
|
|
def test_minimax_cn_env_vars(self):
|
|
pconfig = PROVIDER_REGISTRY["minimax-cn"]
|
|
assert pconfig.api_key_env_vars == ("MINIMAX_CN_API_KEY",)
|
|
assert pconfig.base_url_env_var == "MINIMAX_CN_BASE_URL"
|
|
|
|
def test_ai_gateway_env_vars(self):
|
|
pconfig = PROVIDER_REGISTRY["ai-gateway"]
|
|
assert pconfig.api_key_env_vars == ("AI_GATEWAY_API_KEY",)
|
|
assert pconfig.base_url_env_var == "AI_GATEWAY_BASE_URL"
|
|
|
|
def test_kilocode_env_vars(self):
|
|
pconfig = PROVIDER_REGISTRY["kilocode"]
|
|
assert pconfig.api_key_env_vars == ("KILOCODE_API_KEY",)
|
|
assert pconfig.base_url_env_var == "KILOCODE_BASE_URL"
|
|
|
|
def test_huggingface_env_vars(self):
|
|
pconfig = PROVIDER_REGISTRY["huggingface"]
|
|
assert pconfig.api_key_env_vars == ("HF_TOKEN",)
|
|
assert pconfig.base_url_env_var == "HF_BASE_URL"
|
|
|
|
def test_base_urls(self):
|
|
assert PROVIDER_REGISTRY["copilot"].inference_base_url == "https://api.githubcopilot.com"
|
|
assert PROVIDER_REGISTRY["copilot-acp"].inference_base_url == "acp://copilot"
|
|
assert PROVIDER_REGISTRY["zai"].inference_base_url == "https://api.z.ai/api/paas/v4"
|
|
assert PROVIDER_REGISTRY["kimi-coding"].inference_base_url == "https://api.moonshot.ai/v1"
|
|
assert PROVIDER_REGISTRY["minimax"].inference_base_url == "https://api.minimax.io/anthropic"
|
|
assert PROVIDER_REGISTRY["minimax-cn"].inference_base_url == "https://api.minimaxi.com/anthropic"
|
|
assert PROVIDER_REGISTRY["ai-gateway"].inference_base_url == "https://ai-gateway.vercel.sh/v1"
|
|
assert PROVIDER_REGISTRY["kilocode"].inference_base_url == "https://api.kilo.ai/api/gateway"
|
|
assert PROVIDER_REGISTRY["huggingface"].inference_base_url == "https://router.huggingface.co/v1"
|
|
|
|
def test_oauth_providers_unchanged(self):
|
|
"""Ensure we didn't break the existing OAuth providers."""
|
|
assert "nous" in PROVIDER_REGISTRY
|
|
assert PROVIDER_REGISTRY["nous"].auth_type == "oauth_device_code"
|
|
assert "openai-codex" in PROVIDER_REGISTRY
|
|
assert PROVIDER_REGISTRY["openai-codex"].auth_type == "oauth_external"
|
|
|
|
|
|
# =============================================================================
|
|
# Provider Resolution tests
|
|
# =============================================================================
|
|
|
|
PROVIDER_ENV_VARS = (
|
|
"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN",
|
|
"CLAUDE_CODE_OAUTH_TOKEN",
|
|
"GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY",
|
|
"KIMI_API_KEY", "KIMI_BASE_URL", "MINIMAX_API_KEY", "MINIMAX_CN_API_KEY",
|
|
"AI_GATEWAY_API_KEY", "AI_GATEWAY_BASE_URL",
|
|
"KILOCODE_API_KEY", "KILOCODE_BASE_URL",
|
|
"DASHSCOPE_API_KEY", "OPENCODE_ZEN_API_KEY", "OPENCODE_GO_API_KEY",
|
|
"NOUS_API_KEY", "GITHUB_TOKEN", "GH_TOKEN",
|
|
"OPENAI_BASE_URL", "HERMES_COPILOT_ACP_COMMAND", "COPILOT_CLI_PATH",
|
|
"HERMES_COPILOT_ACP_ARGS", "COPILOT_ACP_BASE_URL",
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clear_provider_env(monkeypatch):
|
|
for key in PROVIDER_ENV_VARS:
|
|
monkeypatch.delenv(key, raising=False)
|
|
monkeypatch.setattr("hermes_cli.auth._load_auth_store", lambda: {})
|
|
|
|
|
|
class TestResolveProvider:
|
|
"""Test resolve_provider() with new providers."""
|
|
|
|
def test_explicit_zai(self):
|
|
assert resolve_provider("zai") == "zai"
|
|
|
|
def test_explicit_kimi_coding(self):
|
|
assert resolve_provider("kimi-coding") == "kimi-coding"
|
|
|
|
def test_explicit_minimax(self):
|
|
assert resolve_provider("minimax") == "minimax"
|
|
|
|
def test_explicit_minimax_cn(self):
|
|
assert resolve_provider("minimax-cn") == "minimax-cn"
|
|
|
|
def test_explicit_ai_gateway(self):
|
|
assert resolve_provider("ai-gateway") == "ai-gateway"
|
|
|
|
def test_alias_glm(self):
|
|
assert resolve_provider("glm") == "zai"
|
|
|
|
def test_alias_z_ai(self):
|
|
assert resolve_provider("z-ai") == "zai"
|
|
|
|
def test_alias_zhipu(self):
|
|
assert resolve_provider("zhipu") == "zai"
|
|
|
|
def test_alias_kimi(self):
|
|
assert resolve_provider("kimi") == "kimi-coding"
|
|
|
|
def test_alias_moonshot(self):
|
|
assert resolve_provider("moonshot") == "kimi-coding"
|
|
|
|
def test_alias_minimax_underscore(self):
|
|
assert resolve_provider("minimax_cn") == "minimax-cn"
|
|
|
|
def test_alias_aigateway(self):
|
|
assert resolve_provider("aigateway") == "ai-gateway"
|
|
|
|
def test_alias_vercel(self):
|
|
assert resolve_provider("vercel") == "ai-gateway"
|
|
|
|
def test_explicit_kilocode(self):
|
|
assert resolve_provider("kilocode") == "kilocode"
|
|
|
|
def test_alias_kilo(self):
|
|
assert resolve_provider("kilo") == "kilocode"
|
|
|
|
def test_alias_kilo_code(self):
|
|
assert resolve_provider("kilo-code") == "kilocode"
|
|
|
|
def test_alias_kilo_gateway(self):
|
|
assert resolve_provider("kilo-gateway") == "kilocode"
|
|
|
|
def test_alias_case_insensitive(self):
|
|
assert resolve_provider("GLM") == "zai"
|
|
assert resolve_provider("Z-AI") == "zai"
|
|
assert resolve_provider("Kimi") == "kimi-coding"
|
|
|
|
def test_alias_github_copilot(self):
|
|
assert resolve_provider("github-copilot") == "copilot"
|
|
|
|
def test_alias_github_models(self):
|
|
assert resolve_provider("github-models") == "copilot"
|
|
|
|
def test_alias_github_copilot_acp(self):
|
|
assert resolve_provider("github-copilot-acp") == "copilot-acp"
|
|
assert resolve_provider("copilot-acp-agent") == "copilot-acp"
|
|
|
|
def test_explicit_huggingface(self):
|
|
assert resolve_provider("huggingface") == "huggingface"
|
|
|
|
def test_alias_hf(self):
|
|
assert resolve_provider("hf") == "huggingface"
|
|
|
|
def test_alias_hugging_face(self):
|
|
assert resolve_provider("hugging-face") == "huggingface"
|
|
|
|
def test_alias_huggingface_hub(self):
|
|
assert resolve_provider("huggingface-hub") == "huggingface"
|
|
|
|
def test_unknown_provider_raises(self):
|
|
with pytest.raises(AuthError):
|
|
resolve_provider("nonexistent-provider-xyz")
|
|
|
|
def test_auto_detects_glm_key(self, monkeypatch):
|
|
monkeypatch.setenv("GLM_API_KEY", "test-glm-key")
|
|
assert resolve_provider("auto") == "zai"
|
|
|
|
def test_auto_detects_zai_key(self, monkeypatch):
|
|
monkeypatch.setenv("ZAI_API_KEY", "test-zai-key")
|
|
assert resolve_provider("auto") == "zai"
|
|
|
|
def test_auto_detects_z_ai_key(self, monkeypatch):
|
|
monkeypatch.setenv("Z_AI_API_KEY", "test-z-ai-key")
|
|
assert resolve_provider("auto") == "zai"
|
|
|
|
def test_auto_detects_kimi_key(self, monkeypatch):
|
|
monkeypatch.setenv("KIMI_API_KEY", "test-kimi-key")
|
|
assert resolve_provider("auto") == "kimi-coding"
|
|
|
|
def test_auto_detects_minimax_key(self, monkeypatch):
|
|
monkeypatch.setenv("MINIMAX_API_KEY", "test-mm-key")
|
|
assert resolve_provider("auto") == "minimax"
|
|
|
|
def test_auto_detects_minimax_cn_key(self, monkeypatch):
|
|
monkeypatch.setenv("MINIMAX_CN_API_KEY", "test-mm-cn-key")
|
|
assert resolve_provider("auto") == "minimax-cn"
|
|
|
|
def test_auto_detects_ai_gateway_key(self, monkeypatch):
|
|
monkeypatch.setenv("AI_GATEWAY_API_KEY", "test-gw-key")
|
|
assert resolve_provider("auto") == "ai-gateway"
|
|
|
|
def test_auto_detects_kilocode_key(self, monkeypatch):
|
|
monkeypatch.setenv("KILOCODE_API_KEY", "test-kilo-key")
|
|
assert resolve_provider("auto") == "kilocode"
|
|
|
|
def test_auto_detects_hf_token(self, monkeypatch):
|
|
monkeypatch.setenv("HF_TOKEN", "hf_test_token")
|
|
assert resolve_provider("auto") == "huggingface"
|
|
|
|
def test_openrouter_takes_priority_over_glm(self, monkeypatch):
|
|
"""OpenRouter API key should win over GLM in auto-detection."""
|
|
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
|
|
monkeypatch.setenv("GLM_API_KEY", "glm-key")
|
|
assert resolve_provider("auto") == "openrouter"
|
|
|
|
def test_auto_does_not_select_copilot_from_github_token(self, monkeypatch):
|
|
monkeypatch.setenv("GITHUB_TOKEN", "gh-test-token")
|
|
with pytest.raises(AuthError, match="No inference provider configured"):
|
|
resolve_provider("auto")
|
|
|
|
|
|
# =============================================================================
|
|
# API Key Provider Status tests
|
|
# =============================================================================
|
|
|
|
class TestApiKeyProviderStatus:
|
|
|
|
def test_unconfigured_provider(self):
|
|
status = get_api_key_provider_status("zai")
|
|
assert status["configured"] is False
|
|
assert status["logged_in"] is False
|
|
|
|
def test_configured_provider(self, monkeypatch):
|
|
monkeypatch.setenv("GLM_API_KEY", "test-key-123")
|
|
status = get_api_key_provider_status("zai")
|
|
assert status["configured"] is True
|
|
assert status["logged_in"] is True
|
|
assert status["key_source"] == "GLM_API_KEY"
|
|
assert "z.ai" in status["base_url"].lower() or "api.z.ai" in status["base_url"]
|
|
|
|
def test_fallback_env_var(self, monkeypatch):
|
|
"""ZAI_API_KEY should work when GLM_API_KEY is not set."""
|
|
monkeypatch.setenv("ZAI_API_KEY", "zai-fallback-key")
|
|
status = get_api_key_provider_status("zai")
|
|
assert status["configured"] is True
|
|
assert status["key_source"] == "ZAI_API_KEY"
|
|
|
|
def test_custom_base_url(self, monkeypatch):
|
|
monkeypatch.setenv("KIMI_API_KEY", "kimi-key")
|
|
monkeypatch.setenv("KIMI_BASE_URL", "https://custom.kimi.example/v1")
|
|
status = get_api_key_provider_status("kimi-coding")
|
|
assert status["base_url"] == "https://custom.kimi.example/v1"
|
|
|
|
def test_copilot_status_uses_gh_cli_token(self, monkeypatch):
|
|
monkeypatch.setattr("hermes_cli.copilot_auth._try_gh_cli_token", lambda: "gho_gh_cli_token")
|
|
status = get_api_key_provider_status("copilot")
|
|
assert status["configured"] is True
|
|
assert status["logged_in"] is True
|
|
assert status["key_source"] == "gh auth token"
|
|
assert status["base_url"] == "https://api.githubcopilot.com"
|
|
|
|
def test_get_auth_status_dispatches_to_api_key(self, monkeypatch):
|
|
monkeypatch.setenv("MINIMAX_API_KEY", "mm-key")
|
|
status = get_auth_status("minimax")
|
|
assert status["configured"] is True
|
|
assert status["provider"] == "minimax"
|
|
|
|
def test_copilot_acp_status_detects_local_cli(self, monkeypatch):
|
|
monkeypatch.setenv("HERMES_COPILOT_ACP_ARGS", "--acp --stdio --debug")
|
|
monkeypatch.setattr("hermes_cli.auth.shutil.which", lambda command: f"/usr/local/bin/{command}")
|
|
|
|
status = get_external_process_provider_status("copilot-acp")
|
|
|
|
assert status["configured"] is True
|
|
assert status["logged_in"] is True
|
|
assert status["command"] == "copilot"
|
|
assert status["resolved_command"] == "/usr/local/bin/copilot"
|
|
assert status["args"] == ["--acp", "--stdio", "--debug"]
|
|
assert status["base_url"] == "acp://copilot"
|
|
|
|
def test_get_auth_status_dispatches_to_external_process(self, monkeypatch):
|
|
monkeypatch.setattr("hermes_cli.auth.shutil.which", lambda command: f"/opt/bin/{command}")
|
|
|
|
status = get_auth_status("copilot-acp")
|
|
|
|
assert status["configured"] is True
|
|
assert status["provider"] == "copilot-acp"
|
|
|
|
def test_non_api_key_provider(self):
|
|
status = get_api_key_provider_status("nous")
|
|
assert status["configured"] is False
|
|
|
|
|
|
# =============================================================================
|
|
# Credential Resolution tests
|
|
# =============================================================================
|
|
|
|
class TestResolveApiKeyProviderCredentials:
|
|
|
|
def test_resolve_zai_with_key(self, monkeypatch):
|
|
monkeypatch.setenv("GLM_API_KEY", "glm-secret-key")
|
|
creds = resolve_api_key_provider_credentials("zai")
|
|
assert creds["provider"] == "zai"
|
|
assert creds["api_key"] == "glm-secret-key"
|
|
assert creds["base_url"] == "https://api.z.ai/api/paas/v4"
|
|
assert creds["source"] == "GLM_API_KEY"
|
|
|
|
def test_resolve_copilot_with_github_token(self, monkeypatch):
|
|
monkeypatch.setenv("GITHUB_TOKEN", "gh-env-secret")
|
|
creds = resolve_api_key_provider_credentials("copilot")
|
|
assert creds["provider"] == "copilot"
|
|
assert creds["api_key"] == "gh-env-secret"
|
|
assert creds["base_url"] == "https://api.githubcopilot.com"
|
|
assert creds["source"] == "GITHUB_TOKEN"
|
|
|
|
def test_resolve_copilot_with_gh_cli_fallback(self, monkeypatch):
|
|
monkeypatch.setattr("hermes_cli.copilot_auth._try_gh_cli_token", lambda: "gho_cli_secret")
|
|
creds = resolve_api_key_provider_credentials("copilot")
|
|
assert creds["provider"] == "copilot"
|
|
assert creds["api_key"] == "gho_cli_secret"
|
|
assert creds["base_url"] == "https://api.githubcopilot.com"
|
|
assert creds["source"] == "gh auth token"
|
|
|
|
def test_try_gh_cli_token_uses_homebrew_path_when_not_on_path(self, monkeypatch):
|
|
monkeypatch.setattr("hermes_cli.auth.shutil.which", lambda command: None)
|
|
monkeypatch.setattr(
|
|
"hermes_cli.auth.os.path.isfile",
|
|
lambda path: path == "/opt/homebrew/bin/gh",
|
|
)
|
|
monkeypatch.setattr(
|
|
"hermes_cli.auth.os.access",
|
|
lambda path, mode: path == "/opt/homebrew/bin/gh" and mode == os.X_OK,
|
|
)
|
|
|
|
calls = []
|
|
|
|
class _Result:
|
|
returncode = 0
|
|
stdout = "gh-cli-secret\n"
|
|
|
|
def _fake_run(cmd, capture_output, text, timeout):
|
|
calls.append(cmd)
|
|
return _Result()
|
|
|
|
monkeypatch.setattr("hermes_cli.auth.subprocess.run", _fake_run)
|
|
|
|
assert _try_gh_cli_token() == "gh-cli-secret"
|
|
assert calls == [["/opt/homebrew/bin/gh", "auth", "token"]]
|
|
|
|
def test_resolve_copilot_acp_with_local_cli(self, monkeypatch):
|
|
monkeypatch.setenv("HERMES_COPILOT_ACP_ARGS", "--acp --stdio")
|
|
monkeypatch.setattr("hermes_cli.auth.shutil.which", lambda command: f"/usr/local/bin/{command}")
|
|
|
|
creds = resolve_external_process_provider_credentials("copilot-acp")
|
|
|
|
assert creds["provider"] == "copilot-acp"
|
|
assert creds["api_key"] == "copilot-acp"
|
|
assert creds["base_url"] == "acp://copilot"
|
|
assert creds["command"] == "/usr/local/bin/copilot"
|
|
assert creds["args"] == ["--acp", "--stdio"]
|
|
assert creds["source"] == "process"
|
|
|
|
def test_resolve_kimi_with_key(self, monkeypatch):
|
|
monkeypatch.setenv("KIMI_API_KEY", "kimi-secret-key")
|
|
creds = resolve_api_key_provider_credentials("kimi-coding")
|
|
assert creds["provider"] == "kimi-coding"
|
|
assert creds["api_key"] == "kimi-secret-key"
|
|
assert creds["base_url"] == "https://api.moonshot.ai/v1"
|
|
|
|
def test_resolve_minimax_with_key(self, monkeypatch):
|
|
monkeypatch.setenv("MINIMAX_API_KEY", "mm-secret-key")
|
|
creds = resolve_api_key_provider_credentials("minimax")
|
|
assert creds["provider"] == "minimax"
|
|
assert creds["api_key"] == "mm-secret-key"
|
|
assert creds["base_url"] == "https://api.minimax.io/anthropic"
|
|
|
|
def test_resolve_minimax_cn_with_key(self, monkeypatch):
|
|
monkeypatch.setenv("MINIMAX_CN_API_KEY", "mmcn-secret-key")
|
|
creds = resolve_api_key_provider_credentials("minimax-cn")
|
|
assert creds["provider"] == "minimax-cn"
|
|
assert creds["api_key"] == "mmcn-secret-key"
|
|
assert creds["base_url"] == "https://api.minimaxi.com/anthropic"
|
|
|
|
def test_resolve_ai_gateway_with_key(self, monkeypatch):
|
|
monkeypatch.setenv("AI_GATEWAY_API_KEY", "gw-secret-key")
|
|
creds = resolve_api_key_provider_credentials("ai-gateway")
|
|
assert creds["provider"] == "ai-gateway"
|
|
assert creds["api_key"] == "gw-secret-key"
|
|
assert creds["base_url"] == "https://ai-gateway.vercel.sh/v1"
|
|
|
|
def test_resolve_kilocode_with_key(self, monkeypatch):
|
|
monkeypatch.setenv("KILOCODE_API_KEY", "kilo-secret-key")
|
|
creds = resolve_api_key_provider_credentials("kilocode")
|
|
assert creds["provider"] == "kilocode"
|
|
assert creds["api_key"] == "kilo-secret-key"
|
|
assert creds["base_url"] == "https://api.kilo.ai/api/gateway"
|
|
|
|
def test_resolve_kilocode_custom_base_url(self, monkeypatch):
|
|
monkeypatch.setenv("KILOCODE_API_KEY", "kilo-key")
|
|
monkeypatch.setenv("KILOCODE_BASE_URL", "https://custom.kilo.example/v1")
|
|
creds = resolve_api_key_provider_credentials("kilocode")
|
|
assert creds["base_url"] == "https://custom.kilo.example/v1"
|
|
|
|
def test_resolve_with_custom_base_url(self, monkeypatch):
|
|
monkeypatch.setenv("GLM_API_KEY", "glm-key")
|
|
monkeypatch.setenv("GLM_BASE_URL", "https://custom.glm.example/v4")
|
|
creds = resolve_api_key_provider_credentials("zai")
|
|
assert creds["base_url"] == "https://custom.glm.example/v4"
|
|
|
|
def test_resolve_without_key_returns_empty(self):
|
|
creds = resolve_api_key_provider_credentials("zai")
|
|
assert creds["api_key"] == ""
|
|
assert creds["source"] == "default"
|
|
|
|
def test_resolve_invalid_provider_raises(self):
|
|
with pytest.raises(AuthError):
|
|
resolve_api_key_provider_credentials("nous")
|
|
|
|
def test_glm_key_priority(self, monkeypatch):
|
|
"""GLM_API_KEY takes priority over ZAI_API_KEY."""
|
|
monkeypatch.setenv("GLM_API_KEY", "primary")
|
|
monkeypatch.setenv("ZAI_API_KEY", "secondary")
|
|
creds = resolve_api_key_provider_credentials("zai")
|
|
assert creds["api_key"] == "primary"
|
|
assert creds["source"] == "GLM_API_KEY"
|
|
|
|
def test_zai_key_fallback(self, monkeypatch):
|
|
"""ZAI_API_KEY used when GLM_API_KEY not set."""
|
|
monkeypatch.setenv("ZAI_API_KEY", "secondary")
|
|
creds = resolve_api_key_provider_credentials("zai")
|
|
assert creds["api_key"] == "secondary"
|
|
assert creds["source"] == "ZAI_API_KEY"
|
|
|
|
|
|
# =============================================================================
|
|
# Runtime Provider Resolution tests
|
|
# =============================================================================
|
|
|
|
class TestRuntimeProviderResolution:
|
|
|
|
def test_runtime_zai(self, monkeypatch):
|
|
monkeypatch.setenv("GLM_API_KEY", "glm-key")
|
|
from hermes_cli.runtime_provider import resolve_runtime_provider
|
|
result = resolve_runtime_provider(requested="zai")
|
|
assert result["provider"] == "zai"
|
|
assert result["api_mode"] == "chat_completions"
|
|
assert result["api_key"] == "glm-key"
|
|
assert "z.ai" in result["base_url"] or "api.z.ai" in result["base_url"]
|
|
|
|
def test_runtime_kimi(self, monkeypatch):
|
|
monkeypatch.setenv("KIMI_API_KEY", "kimi-key")
|
|
from hermes_cli.runtime_provider import resolve_runtime_provider
|
|
result = resolve_runtime_provider(requested="kimi-coding")
|
|
assert result["provider"] == "kimi-coding"
|
|
assert result["api_mode"] == "chat_completions"
|
|
assert result["api_key"] == "kimi-key"
|
|
|
|
def test_runtime_minimax(self, monkeypatch):
|
|
monkeypatch.setenv("MINIMAX_API_KEY", "mm-key")
|
|
from hermes_cli.runtime_provider import resolve_runtime_provider
|
|
result = resolve_runtime_provider(requested="minimax")
|
|
assert result["provider"] == "minimax"
|
|
assert result["api_key"] == "mm-key"
|
|
|
|
def test_runtime_ai_gateway(self, monkeypatch):
|
|
monkeypatch.setenv("AI_GATEWAY_API_KEY", "gw-key")
|
|
from hermes_cli.runtime_provider import resolve_runtime_provider
|
|
result = resolve_runtime_provider(requested="ai-gateway")
|
|
assert result["provider"] == "ai-gateway"
|
|
assert result["api_mode"] == "chat_completions"
|
|
assert result["api_key"] == "gw-key"
|
|
assert "ai-gateway.vercel.sh" in result["base_url"]
|
|
|
|
def test_runtime_kilocode(self, monkeypatch):
|
|
monkeypatch.setenv("KILOCODE_API_KEY", "kilo-key")
|
|
from hermes_cli.runtime_provider import resolve_runtime_provider
|
|
result = resolve_runtime_provider(requested="kilocode")
|
|
assert result["provider"] == "kilocode"
|
|
assert result["api_mode"] == "chat_completions"
|
|
assert result["api_key"] == "kilo-key"
|
|
assert "kilo.ai" in result["base_url"]
|
|
|
|
def test_runtime_auto_detects_api_key_provider(self, monkeypatch):
|
|
monkeypatch.setenv("KIMI_API_KEY", "auto-kimi-key")
|
|
from hermes_cli.runtime_provider import resolve_runtime_provider
|
|
result = resolve_runtime_provider(requested="auto")
|
|
assert result["provider"] == "kimi-coding"
|
|
assert result["api_key"] == "auto-kimi-key"
|
|
|
|
def test_runtime_copilot_uses_gh_cli_token(self, monkeypatch):
|
|
monkeypatch.setattr("hermes_cli.copilot_auth._try_gh_cli_token", lambda: "gho_cli_secret")
|
|
from hermes_cli.runtime_provider import resolve_runtime_provider
|
|
result = resolve_runtime_provider(requested="copilot")
|
|
assert result["provider"] == "copilot"
|
|
assert result["api_mode"] == "chat_completions"
|
|
assert result["api_key"] == "gho_cli_secret"
|
|
assert result["base_url"] == "https://api.githubcopilot.com"
|
|
|
|
def test_runtime_copilot_uses_responses_for_gpt_5_4(self, monkeypatch):
|
|
monkeypatch.setattr("hermes_cli.copilot_auth._try_gh_cli_token", lambda: "gho_cli_secret")
|
|
monkeypatch.setattr(
|
|
"hermes_cli.runtime_provider._get_model_config",
|
|
lambda: {"provider": "copilot", "default": "gpt-5.4"},
|
|
)
|
|
monkeypatch.setattr(
|
|
"hermes_cli.models.fetch_github_model_catalog",
|
|
lambda api_key=None, timeout=5.0: [
|
|
{
|
|
"id": "gpt-5.4",
|
|
"supported_endpoints": ["/responses"],
|
|
"capabilities": {"type": "chat"},
|
|
}
|
|
],
|
|
)
|
|
from hermes_cli.runtime_provider import resolve_runtime_provider
|
|
|
|
result = resolve_runtime_provider(requested="copilot")
|
|
|
|
assert result["provider"] == "copilot"
|
|
assert result["api_mode"] == "codex_responses"
|
|
|
|
def test_runtime_copilot_acp_uses_process_runtime(self, monkeypatch):
|
|
monkeypatch.setattr("hermes_cli.auth.shutil.which", lambda command: f"/usr/local/bin/{command}")
|
|
monkeypatch.setenv("HERMES_COPILOT_ACP_ARGS", "--acp --stdio --debug")
|
|
|
|
from hermes_cli.runtime_provider import resolve_runtime_provider
|
|
|
|
result = resolve_runtime_provider(requested="copilot-acp")
|
|
|
|
assert result["provider"] == "copilot-acp"
|
|
assert result["api_mode"] == "chat_completions"
|
|
assert result["api_key"] == "copilot-acp"
|
|
assert result["base_url"] == "acp://copilot"
|
|
assert result["command"] == "/usr/local/bin/copilot"
|
|
assert result["args"] == ["--acp", "--stdio", "--debug"]
|
|
|
|
|
|
# =============================================================================
|
|
# _has_any_provider_configured tests
|
|
# =============================================================================
|
|
|
|
class TestHasAnyProviderConfigured:
|
|
|
|
def test_glm_key_counts(self, monkeypatch, tmp_path):
|
|
from hermes_cli import config as config_module
|
|
monkeypatch.setenv("GLM_API_KEY", "test-key")
|
|
hermes_home = tmp_path / ".hermes"
|
|
hermes_home.mkdir()
|
|
monkeypatch.setattr(config_module, "get_env_path", lambda: hermes_home / ".env")
|
|
monkeypatch.setattr(config_module, "get_hermes_home", lambda: hermes_home)
|
|
from hermes_cli.main import _has_any_provider_configured
|
|
assert _has_any_provider_configured() is True
|
|
|
|
def test_minimax_key_counts(self, monkeypatch, tmp_path):
|
|
from hermes_cli import config as config_module
|
|
monkeypatch.setenv("MINIMAX_API_KEY", "test-key")
|
|
hermes_home = tmp_path / ".hermes"
|
|
hermes_home.mkdir()
|
|
monkeypatch.setattr(config_module, "get_env_path", lambda: hermes_home / ".env")
|
|
monkeypatch.setattr(config_module, "get_hermes_home", lambda: hermes_home)
|
|
from hermes_cli.main import _has_any_provider_configured
|
|
assert _has_any_provider_configured() is True
|
|
|
|
def test_gh_cli_token_counts(self, monkeypatch, tmp_path):
|
|
from hermes_cli import config as config_module
|
|
monkeypatch.setattr("hermes_cli.copilot_auth._try_gh_cli_token", lambda: "gho_cli_secret")
|
|
hermes_home = tmp_path / ".hermes"
|
|
hermes_home.mkdir()
|
|
monkeypatch.setattr(config_module, "get_env_path", lambda: hermes_home / ".env")
|
|
monkeypatch.setattr(config_module, "get_hermes_home", lambda: hermes_home)
|
|
from hermes_cli.main import _has_any_provider_configured
|
|
assert _has_any_provider_configured() is True
|
|
|
|
def test_claude_code_creds_ignored_on_fresh_install(self, monkeypatch, tmp_path):
|
|
"""Claude Code credentials should NOT skip the wizard when Hermes is unconfigured."""
|
|
from hermes_cli import config as config_module
|
|
hermes_home = tmp_path / ".hermes"
|
|
hermes_home.mkdir()
|
|
monkeypatch.setattr(config_module, "get_env_path", lambda: hermes_home / ".env")
|
|
monkeypatch.setattr(config_module, "get_hermes_home", lambda: hermes_home)
|
|
# Clear all provider env vars so earlier checks don't short-circuit
|
|
for var in ("OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
|
|
"ANTHROPIC_TOKEN", "OPENAI_BASE_URL"):
|
|
monkeypatch.delenv(var, raising=False)
|
|
# Simulate valid Claude Code credentials
|
|
monkeypatch.setattr(
|
|
"agent.anthropic_adapter.read_claude_code_credentials",
|
|
lambda: {"accessToken": "sk-ant-test", "refreshToken": "ref-tok"},
|
|
)
|
|
monkeypatch.setattr(
|
|
"agent.anthropic_adapter.is_claude_code_token_valid",
|
|
lambda creds: True,
|
|
)
|
|
from hermes_cli.main import _has_any_provider_configured
|
|
assert _has_any_provider_configured() is False
|
|
|
|
def test_claude_code_creds_counted_when_hermes_configured(self, monkeypatch, tmp_path):
|
|
"""Claude Code credentials should count when Hermes has been explicitly configured."""
|
|
import yaml
|
|
from hermes_cli import config as config_module
|
|
hermes_home = tmp_path / ".hermes"
|
|
hermes_home.mkdir()
|
|
# Write a config with a non-default model to simulate explicit configuration
|
|
config_file = hermes_home / "config.yaml"
|
|
config_file.write_text(yaml.dump({"model": {"default": "my-local-model"}}))
|
|
monkeypatch.setattr(config_module, "get_env_path", lambda: hermes_home / ".env")
|
|
monkeypatch.setattr(config_module, "get_hermes_home", lambda: hermes_home)
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
# Clear all provider env vars
|
|
for var in ("OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
|
|
"ANTHROPIC_TOKEN", "OPENAI_BASE_URL"):
|
|
monkeypatch.delenv(var, raising=False)
|
|
# Simulate valid Claude Code credentials
|
|
monkeypatch.setattr(
|
|
"agent.anthropic_adapter.read_claude_code_credentials",
|
|
lambda: {"accessToken": "sk-ant-test", "refreshToken": "ref-tok"},
|
|
)
|
|
monkeypatch.setattr(
|
|
"agent.anthropic_adapter.is_claude_code_token_valid",
|
|
lambda creds: True,
|
|
)
|
|
from hermes_cli.main import _has_any_provider_configured
|
|
assert _has_any_provider_configured() is True
|
|
|
|
|
|
# =============================================================================
|
|
# Kimi Code auto-detection tests
|
|
# =============================================================================
|
|
|
|
MOONSHOT_DEFAULT_URL = "https://api.moonshot.ai/v1"
|
|
|
|
|
|
class TestResolveKimiBaseUrl:
|
|
"""Test _resolve_kimi_base_url() helper for key-prefix auto-detection."""
|
|
|
|
def test_sk_kimi_prefix_routes_to_kimi_code(self):
|
|
url = _resolve_kimi_base_url("sk-kimi-abc123", MOONSHOT_DEFAULT_URL, "")
|
|
assert url == KIMI_CODE_BASE_URL
|
|
|
|
def test_legacy_key_uses_default(self):
|
|
url = _resolve_kimi_base_url("sk-abc123", MOONSHOT_DEFAULT_URL, "")
|
|
assert url == MOONSHOT_DEFAULT_URL
|
|
|
|
def test_empty_key_uses_default(self):
|
|
url = _resolve_kimi_base_url("", MOONSHOT_DEFAULT_URL, "")
|
|
assert url == MOONSHOT_DEFAULT_URL
|
|
|
|
def test_env_override_wins_over_sk_kimi(self):
|
|
"""KIMI_BASE_URL env var should always take priority."""
|
|
custom = "https://custom.example.com/v1"
|
|
url = _resolve_kimi_base_url("sk-kimi-abc123", MOONSHOT_DEFAULT_URL, custom)
|
|
assert url == custom
|
|
|
|
def test_env_override_wins_over_legacy(self):
|
|
custom = "https://custom.example.com/v1"
|
|
url = _resolve_kimi_base_url("sk-abc123", MOONSHOT_DEFAULT_URL, custom)
|
|
assert url == custom
|
|
|
|
|
|
class TestKimiCodeStatusAutoDetect:
|
|
"""Test that get_api_key_provider_status auto-detects sk-kimi- keys."""
|
|
|
|
def test_sk_kimi_key_gets_kimi_code_url(self, monkeypatch):
|
|
monkeypatch.setenv("KIMI_API_KEY", "sk-kimi-test-key-123")
|
|
status = get_api_key_provider_status("kimi-coding")
|
|
assert status["configured"] is True
|
|
assert status["base_url"] == KIMI_CODE_BASE_URL
|
|
|
|
def test_legacy_key_gets_moonshot_url(self, monkeypatch):
|
|
monkeypatch.setenv("KIMI_API_KEY", "sk-legacy-test-key")
|
|
status = get_api_key_provider_status("kimi-coding")
|
|
assert status["configured"] is True
|
|
assert status["base_url"] == MOONSHOT_DEFAULT_URL
|
|
|
|
def test_env_override_wins(self, monkeypatch):
|
|
monkeypatch.setenv("KIMI_API_KEY", "sk-kimi-test-key")
|
|
monkeypatch.setenv("KIMI_BASE_URL", "https://override.example/v1")
|
|
status = get_api_key_provider_status("kimi-coding")
|
|
assert status["base_url"] == "https://override.example/v1"
|
|
|
|
|
|
class TestKimiCodeCredentialAutoDetect:
|
|
"""Test that resolve_api_key_provider_credentials auto-detects sk-kimi- keys."""
|
|
|
|
def test_sk_kimi_key_gets_kimi_code_url(self, monkeypatch):
|
|
monkeypatch.setenv("KIMI_API_KEY", "sk-kimi-secret-key")
|
|
creds = resolve_api_key_provider_credentials("kimi-coding")
|
|
assert creds["api_key"] == "sk-kimi-secret-key"
|
|
assert creds["base_url"] == KIMI_CODE_BASE_URL
|
|
|
|
def test_legacy_key_gets_moonshot_url(self, monkeypatch):
|
|
monkeypatch.setenv("KIMI_API_KEY", "sk-legacy-secret-key")
|
|
creds = resolve_api_key_provider_credentials("kimi-coding")
|
|
assert creds["api_key"] == "sk-legacy-secret-key"
|
|
assert creds["base_url"] == MOONSHOT_DEFAULT_URL
|
|
|
|
def test_env_override_wins(self, monkeypatch):
|
|
monkeypatch.setenv("KIMI_API_KEY", "sk-kimi-secret-key")
|
|
monkeypatch.setenv("KIMI_BASE_URL", "https://override.example/v1")
|
|
creds = resolve_api_key_provider_credentials("kimi-coding")
|
|
assert creds["base_url"] == "https://override.example/v1"
|
|
|
|
def test_non_kimi_providers_unaffected(self, monkeypatch):
|
|
"""Ensure the auto-detect logic doesn't leak to other providers."""
|
|
monkeypatch.setenv("GLM_API_KEY", "sk-kimi-looks-like-kimi-but-isnt")
|
|
creds = resolve_api_key_provider_credentials("zai")
|
|
assert creds["base_url"] == "https://api.z.ai/api/paas/v4"
|
|
|
|
|
|
# =============================================================================
|
|
# Kimi / Moonshot model list isolation tests
|
|
# =============================================================================
|
|
|
|
class TestKimiMoonshotModelListIsolation:
|
|
"""Moonshot (legacy) users must not see Coding Plan-only models."""
|
|
|
|
def test_moonshot_list_excludes_coding_plan_only_models(self):
|
|
from hermes_cli.main import _PROVIDER_MODELS
|
|
moonshot_models = _PROVIDER_MODELS["moonshot"]
|
|
coding_plan_only = {"kimi-for-coding", "kimi-k2-thinking-turbo"}
|
|
leaked = set(moonshot_models) & coding_plan_only
|
|
assert not leaked, f"Moonshot list contains Coding Plan-only models: {leaked}"
|
|
|
|
def test_moonshot_list_contains_shared_models(self):
|
|
from hermes_cli.main import _PROVIDER_MODELS
|
|
moonshot_models = _PROVIDER_MODELS["moonshot"]
|
|
assert "kimi-k2.5" in moonshot_models
|
|
assert "kimi-k2-thinking" in moonshot_models
|
|
|
|
def test_coding_plan_list_contains_plan_specific_models(self):
|
|
from hermes_cli.main import _PROVIDER_MODELS
|
|
coding_models = _PROVIDER_MODELS["kimi-coding"]
|
|
assert "kimi-for-coding" in coding_models
|
|
assert "kimi-k2-thinking-turbo" in coding_models
|
|
|
|
|
|
# =============================================================================
|
|
# Hugging Face provider model list tests
|
|
# =============================================================================
|
|
|
|
class TestHuggingFaceModels:
|
|
"""Verify Hugging Face model lists are consistent across all locations."""
|
|
|
|
def test_main_provider_models_has_huggingface(self):
|
|
from hermes_cli.main import _PROVIDER_MODELS
|
|
assert "huggingface" in _PROVIDER_MODELS
|
|
models = _PROVIDER_MODELS["huggingface"]
|
|
assert len(models) >= 6, "Expected at least 6 curated HF models"
|
|
|
|
def test_models_py_has_huggingface(self):
|
|
from hermes_cli.models import _PROVIDER_MODELS
|
|
assert "huggingface" in _PROVIDER_MODELS
|
|
models = _PROVIDER_MODELS["huggingface"]
|
|
assert len(models) >= 6
|
|
|
|
def test_model_lists_match(self):
|
|
"""Model lists in main.py and models.py should be identical."""
|
|
from hermes_cli.main import _PROVIDER_MODELS as main_models
|
|
from hermes_cli.models import _PROVIDER_MODELS as models_models
|
|
assert main_models["huggingface"] == models_models["huggingface"]
|
|
|
|
def test_model_metadata_has_context_lengths(self):
|
|
"""Every HF model should have a context length entry."""
|
|
from hermes_cli.models import _PROVIDER_MODELS
|
|
from agent.model_metadata import DEFAULT_CONTEXT_LENGTHS
|
|
hf_models = _PROVIDER_MODELS["huggingface"]
|
|
for model in hf_models:
|
|
assert model in DEFAULT_CONTEXT_LENGTHS, (
|
|
f"HF model {model!r} missing from DEFAULT_CONTEXT_LENGTHS"
|
|
)
|
|
|
|
def test_models_use_org_name_format(self):
|
|
"""HF models should use org/name format (e.g. Qwen/Qwen3-235B)."""
|
|
from hermes_cli.models import _PROVIDER_MODELS
|
|
for model in _PROVIDER_MODELS["huggingface"]:
|
|
assert "/" in model, f"HF model {model!r} missing org/ prefix"
|
|
|
|
def test_provider_aliases_in_models_py(self):
|
|
from hermes_cli.models import _PROVIDER_ALIASES
|
|
assert _PROVIDER_ALIASES.get("hf") == "huggingface"
|
|
assert _PROVIDER_ALIASES.get("hugging-face") == "huggingface"
|
|
|
|
def test_provider_label(self):
|
|
from hermes_cli.models import _PROVIDER_LABELS
|
|
assert "huggingface" in _PROVIDER_LABELS
|
|
assert _PROVIDER_LABELS["huggingface"] == "Hugging Face"
|