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)
This commit is contained in:
parent
7bccd904c7
commit
4447e7d71a
6 changed files with 161 additions and 15 deletions
10
.env.example
10
.env.example
|
|
@ -24,10 +24,14 @@ GLM_API_KEY=
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# LLM PROVIDER (Kimi / Moonshot)
|
# LLM PROVIDER (Kimi / Moonshot)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Kimi/Moonshot provides access to Moonshot AI coding models
|
# Kimi Code provides access to Moonshot AI coding models (kimi-k2.5, etc.)
|
||||||
# Get your key at: https://platform.moonshot.ai
|
# 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_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)
|
# LLM PROVIDER (MiniMax)
|
||||||
|
|
|
||||||
|
|
@ -317,14 +317,22 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||||
if not api_key:
|
if not api_key:
|
||||||
continue
|
continue
|
||||||
# Resolve base URL (with optional env-var override)
|
# 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:
|
if pconfig.base_url_env_var:
|
||||||
env_url = os.getenv(pconfig.base_url_env_var, "").strip()
|
env_url = os.getenv(pconfig.base_url_env_var, "").strip()
|
||||||
if env_url:
|
if env_url:
|
||||||
base_url = env_url.rstrip("/")
|
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")
|
model = _API_KEY_PROVIDER_AUX_MODELS.get(provider_id, "default")
|
||||||
logger.debug("Auxiliary text client: %s (%s)", pconfig.name, model)
|
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
|
return None, None
|
||||||
|
|
||||||
|
|
@ -403,6 +411,8 @@ def get_async_text_auxiliary_client():
|
||||||
}
|
}
|
||||||
if "openrouter" in str(sync_client.base_url).lower():
|
if "openrouter" in str(sync_client.base_url).lower():
|
||||||
async_kwargs["default_headers"] = dict(_OR_HEADERS)
|
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
|
return AsyncOpenAI(**async_kwargs), model
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
# Z.AI Endpoint Detection
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -1351,11 +1375,16 @@ def get_api_key_provider_status(provider_id: str) -> Dict[str, Any]:
|
||||||
key_source = env_var
|
key_source = env_var
|
||||||
break
|
break
|
||||||
|
|
||||||
base_url = pconfig.inference_base_url
|
env_url = ""
|
||||||
if pconfig.base_url_env_var:
|
if pconfig.base_url_env_var:
|
||||||
env_url = os.getenv(pconfig.base_url_env_var, "").strip()
|
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 {
|
return {
|
||||||
"configured": bool(api_key),
|
"configured": bool(api_key),
|
||||||
|
|
@ -1403,11 +1432,16 @@ def resolve_api_key_provider_credentials(provider_id: str) -> Dict[str, Any]:
|
||||||
key_source = env_var
|
key_source = env_var
|
||||||
break
|
break
|
||||||
|
|
||||||
base_url = pconfig.inference_base_url
|
env_url = ""
|
||||||
if pconfig.base_url_env_var:
|
if pconfig.base_url_env_var:
|
||||||
env_url = os.getenv(pconfig.base_url_env_var, "").strip()
|
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 {
|
return {
|
||||||
"provider": provider_id,
|
"provider": provider_id,
|
||||||
|
|
|
||||||
|
|
@ -508,10 +508,16 @@ def run_doctor(args):
|
||||||
try:
|
try:
|
||||||
import httpx
|
import httpx
|
||||||
_base = os.getenv(_base_env, "")
|
_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
|
_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(
|
_resp = httpx.get(
|
||||||
_url,
|
_url,
|
||||||
headers={"Authorization": f"Bearer {_key}"},
|
headers=_headers,
|
||||||
timeout=10,
|
timeout=10,
|
||||||
)
|
)
|
||||||
if _resp.status_code == 200:
|
if _resp.status_code == 200:
|
||||||
|
|
|
||||||
|
|
@ -389,6 +389,12 @@ class AIAgent:
|
||||||
"X-OpenRouter-Title": "Hermes Agent",
|
"X-OpenRouter-Title": "Hermes Agent",
|
||||||
"X-OpenRouter-Categories": "productivity,cli-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
|
self._client_kwargs = client_kwargs # stored for rebuilding after interrupt
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ from hermes_cli.auth import (
|
||||||
resolve_api_key_provider_credentials,
|
resolve_api_key_provider_credentials,
|
||||||
get_auth_status,
|
get_auth_status,
|
||||||
AuthError,
|
AuthError,
|
||||||
|
KIMI_CODE_BASE_URL,
|
||||||
|
_resolve_kimi_base_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -84,7 +86,7 @@ class TestProviderRegistry:
|
||||||
PROVIDER_ENV_VARS = (
|
PROVIDER_ENV_VARS = (
|
||||||
"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
|
"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
|
||||||
"GLM_API_KEY", "ZAI_API_KEY", "Z_AI_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",
|
"OPENAI_BASE_URL",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -340,3 +342,87 @@ class TestHasAnyProviderConfigured:
|
||||||
monkeypatch.setattr(config_module, "get_hermes_home", lambda: hermes_home)
|
monkeypatch.setattr(config_module, "get_hermes_home", lambda: hermes_home)
|
||||||
from hermes_cli.main import _has_any_provider_configured
|
from hermes_cli.main import _has_any_provider_configured
|
||||||
assert _has_any_provider_configured() is True
|
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"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue