From 4447e7d71afaa9840e02469c6296c7e2604b3ea5 Mon Sep 17 00:00:00 2001 From: Christo Mitov Date: Sat, 7 Mar 2026 20:43:34 -0500 Subject: [PATCH] fix: add Kimi Code API support (api.kimi.com/coding/v1) Kimi Code (platform.kimi.ai) issues API keys prefixed sk-kimi- that require: 1. A different base URL: api.kimi.com/coding/v1 (not api.moonshot.ai/v1) 2. A User-Agent header identifying a recognized coding agent Without this fix, sk-kimi- keys fail with 401 (wrong endpoint) or 403 ('only available for Coding Agents') errors. Changes: - Auto-detect sk-kimi- key prefix and route to api.kimi.com/coding/v1 - Send User-Agent: KimiCLI/1.0 header for Kimi Code endpoints - Legacy Moonshot keys (api.moonshot.ai) continue to work unchanged - KIMI_BASE_URL env var override still takes priority over auto-detection - Updated .env.example with correct docs and all endpoint options - Fixed doctor.py health check for Kimi Code keys Reference: https://github.com/MoonshotAI/kimi-cli (platforms.py) --- .env.example | 10 ++-- agent/auxiliary_client.py | 18 +++++-- hermes_cli/auth.py | 46 ++++++++++++++--- hermes_cli/doctor.py | 8 ++- run_agent.py | 6 +++ tests/test_api_key_providers.py | 88 ++++++++++++++++++++++++++++++++- 6 files changed, 161 insertions(+), 15 deletions(-) diff --git a/.env.example b/.env.example index e43f5a9b6..c4c684cde 100644 --- a/.env.example +++ b/.env.example @@ -24,10 +24,14 @@ GLM_API_KEY= # ============================================================================= # LLM PROVIDER (Kimi / Moonshot) # ============================================================================= -# Kimi/Moonshot provides access to Moonshot AI coding models -# Get your key at: https://platform.moonshot.ai +# Kimi Code provides access to Moonshot AI coding models (kimi-k2.5, etc.) +# Get your key at: https://platform.kimi.ai (Kimi Code console) +# Keys prefixed sk-kimi- use the Kimi Code API (api.kimi.com) by default. +# Legacy keys from platform.moonshot.ai need KIMI_BASE_URL override below. KIMI_API_KEY= -# KIMI_BASE_URL=https://api.moonshot.ai/v1 # Override default base URL +# KIMI_BASE_URL=https://api.kimi.com/coding/v1 # Default for sk-kimi- keys +# KIMI_BASE_URL=https://api.moonshot.ai/v1 # For legacy Moonshot keys +# KIMI_BASE_URL=https://api.moonshot.cn/v1 # For Moonshot China keys # ============================================================================= # LLM PROVIDER (MiniMax) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 21510cbfa..841bb6166 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -317,14 +317,22 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]: if not api_key: continue # Resolve base URL (with optional env-var override) - base_url = pconfig.inference_base_url + # Kimi Code keys (sk-kimi-) need api.kimi.com/coding/v1 + env_url = "" if pconfig.base_url_env_var: env_url = os.getenv(pconfig.base_url_env_var, "").strip() - if env_url: - base_url = env_url.rstrip("/") + if env_url: + base_url = env_url.rstrip("/") + elif provider_id == "kimi-coding" and api_key.startswith("sk-kimi-"): + base_url = "https://api.kimi.com/coding/v1" + else: + base_url = pconfig.inference_base_url model = _API_KEY_PROVIDER_AUX_MODELS.get(provider_id, "default") logger.debug("Auxiliary text client: %s (%s)", pconfig.name, model) - return OpenAI(api_key=api_key, base_url=base_url), model + extra = {} + if "api.kimi.com" in base_url.lower(): + extra["default_headers"] = {"User-Agent": "KimiCLI/1.0"} + return OpenAI(api_key=api_key, base_url=base_url, **extra), model return None, None @@ -403,6 +411,8 @@ def get_async_text_auxiliary_client(): } if "openrouter" in str(sync_client.base_url).lower(): async_kwargs["default_headers"] = dict(_OR_HEADERS) + elif "api.kimi.com" in str(sync_client.base_url).lower(): + async_kwargs["default_headers"] = {"User-Agent": "KimiCLI/1.0"} return AsyncOpenAI(**async_kwargs), model diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 04a0736e4..209f72959 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -138,6 +138,30 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = { } +# ============================================================================= +# Kimi Code Endpoint Detection +# ============================================================================= + +# Kimi Code (platform.kimi.ai) issues keys prefixed "sk-kimi-" that only work +# on api.kimi.com/coding/v1. Legacy keys from platform.moonshot.ai work on +# api.moonshot.ai/v1 (the default). Auto-detect when user hasn't set +# KIMI_BASE_URL explicitly. +KIMI_CODE_BASE_URL = "https://api.kimi.com/coding/v1" + + +def _resolve_kimi_base_url(api_key: str, default_url: str, env_override: str) -> str: + """Return the correct Kimi base URL based on the API key prefix. + + If the user has explicitly set KIMI_BASE_URL, that always wins. + Otherwise, sk-kimi- prefixed keys route to api.kimi.com/coding/v1. + """ + if env_override: + return env_override + if api_key.startswith("sk-kimi-"): + return KIMI_CODE_BASE_URL + return default_url + + # ============================================================================= # Z.AI Endpoint Detection # ============================================================================= @@ -1351,11 +1375,16 @@ def get_api_key_provider_status(provider_id: str) -> Dict[str, Any]: key_source = env_var break - base_url = pconfig.inference_base_url + env_url = "" if pconfig.base_url_env_var: env_url = os.getenv(pconfig.base_url_env_var, "").strip() - if env_url: - base_url = env_url + + if provider_id == "kimi-coding": + base_url = _resolve_kimi_base_url(api_key, pconfig.inference_base_url, env_url) + elif env_url: + base_url = env_url + else: + base_url = pconfig.inference_base_url return { "configured": bool(api_key), @@ -1403,11 +1432,16 @@ def resolve_api_key_provider_credentials(provider_id: str) -> Dict[str, Any]: key_source = env_var break - base_url = pconfig.inference_base_url + env_url = "" if pconfig.base_url_env_var: env_url = os.getenv(pconfig.base_url_env_var, "").strip() - if env_url: - base_url = env_url.rstrip("/") + + if provider_id == "kimi-coding": + base_url = _resolve_kimi_base_url(api_key, pconfig.inference_base_url, env_url) + elif env_url: + base_url = env_url.rstrip("/") + else: + base_url = pconfig.inference_base_url return { "provider": provider_id, diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index f1ef09dc8..de55bdff9 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -508,10 +508,16 @@ def run_doctor(args): try: import httpx _base = os.getenv(_base_env, "") + # Auto-detect Kimi Code keys (sk-kimi-) → api.kimi.com + if not _base and _key.startswith("sk-kimi-"): + _base = "https://api.kimi.com/coding/v1" _url = (_base.rstrip("/") + "/models") if _base else _default_url + _headers = {"Authorization": f"Bearer {_key}"} + if "api.kimi.com" in _url.lower(): + _headers["User-Agent"] = "KimiCLI/1.0" _resp = httpx.get( _url, - headers={"Authorization": f"Bearer {_key}"}, + headers=_headers, timeout=10, ) if _resp.status_code == 200: diff --git a/run_agent.py b/run_agent.py index 0eee82fbd..89e1ad00e 100644 --- a/run_agent.py +++ b/run_agent.py @@ -389,6 +389,12 @@ class AIAgent: "X-OpenRouter-Title": "Hermes Agent", "X-OpenRouter-Categories": "productivity,cli-agent", } + elif "api.kimi.com" in effective_base.lower(): + # Kimi Code API requires a recognized coding-agent User-Agent + # (see https://github.com/MoonshotAI/kimi-cli) + client_kwargs["default_headers"] = { + "User-Agent": "KimiCLI/1.0", + } self._client_kwargs = client_kwargs # stored for rebuilding after interrupt try: diff --git a/tests/test_api_key_providers.py b/tests/test_api_key_providers.py index a6be4d99f..8df2d6327 100644 --- a/tests/test_api_key_providers.py +++ b/tests/test_api_key_providers.py @@ -20,6 +20,8 @@ from hermes_cli.auth import ( resolve_api_key_provider_credentials, get_auth_status, AuthError, + KIMI_CODE_BASE_URL, + _resolve_kimi_base_url, ) @@ -84,7 +86,7 @@ class TestProviderRegistry: PROVIDER_ENV_VARS = ( "OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY", - "KIMI_API_KEY", "MINIMAX_API_KEY", "MINIMAX_CN_API_KEY", + "KIMI_API_KEY", "KIMI_BASE_URL", "MINIMAX_API_KEY", "MINIMAX_CN_API_KEY", "OPENAI_BASE_URL", ) @@ -340,3 +342,87 @@ class TestHasAnyProviderConfigured: 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 + + +# ============================================================================= +# 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"