Skip to content
21 changes: 19 additions & 2 deletions agent/anthropic_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,14 @@ def _is_third_party_anthropic_endpoint(base_url: str | None) -> bool:
return True # Any other endpoint is a third-party proxy


def _is_kimi_coding_endpoint(base_url: str | None) -> bool:
"""Return True for Kimi's /coding endpoint that requires claude-code UA."""
normalized = _normalize_base_url_text(base_url)
if not normalized:
return False
return normalized.rstrip("/").lower().startswith("https://api.kimi.com/coding")


def _requires_bearer_auth(base_url: str | None) -> bool:
"""Return True for Anthropic-compatible providers that require Bearer auth.

Expand Down Expand Up @@ -323,9 +331,18 @@ def build_anthropic_client(api_key: str, base_url: str = None, timeout: float =
kwargs["base_url"] = normalized_base_url
common_betas = _common_betas_for_base_url(normalized_base_url)

if _requires_bearer_auth(normalized_base_url):
if _is_kimi_coding_endpoint(base_url):
# Kimi's /coding endpoint requires User-Agent: claude-code/0.1.0
# to be recognized as a valid Coding Agent. Without it, returns 403.
# Check this BEFORE _requires_bearer_auth since both match api.kimi.com/coding.
kwargs["api_key"] = api_key
kwargs["default_headers"] = {
"User-Agent": "claude-code/0.1.0",
**( {"anthropic-beta": ",".join(common_betas)} if common_betas else {} )
}
elif _requires_bearer_auth(normalized_base_url):
# Some Anthropic-compatible providers (e.g. MiniMax) expect the API key in
# Authorization: Bearer even for regular API keys. Route those endpoints
# Authorization: Bearer *** for regular API keys. Route those endpoints
# through auth_token so the SDK sends Bearer auth instead of x-api-key.
# Check this before OAuth token shape detection because MiniMax secrets do
# not use Anthropic's sk-ant-api prefix and would otherwise be misread as
Expand Down
10 changes: 5 additions & 5 deletions agent/auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -845,7 +845,7 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
return GeminiNativeClient(api_key=api_key, base_url=base_url), model
extra = {}
if base_url_host_matches(base_url, "api.kimi.com"):
extra["default_headers"] = {"User-Agent": "KimiCLI/1.30.0"}
extra["default_headers"] = {"User-Agent": "claude-code/0.1.0"}
elif base_url_host_matches(base_url, "api.githubcopilot.com"):
from hermes_cli.models import copilot_default_headers

Expand All @@ -871,7 +871,7 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
return GeminiNativeClient(api_key=api_key, base_url=base_url), model
extra = {}
if base_url_host_matches(base_url, "api.kimi.com"):
extra["default_headers"] = {"User-Agent": "KimiCLI/1.30.0"}
extra["default_headers"] = {"User-Agent": "claude-code/0.1.0"}
elif base_url_host_matches(base_url, "api.githubcopilot.com"):
from hermes_cli.models import copilot_default_headers

Expand Down Expand Up @@ -1487,7 +1487,7 @@ def _to_async_client(sync_client, model: str):

async_kwargs["default_headers"] = copilot_default_headers()
elif base_url_host_matches(sync_base_url, "api.kimi.com"):
async_kwargs["default_headers"] = {"User-Agent": "KimiCLI/1.30.0"}
async_kwargs["default_headers"] = {"User-Agent": "claude-code/0.1.0"}
return AsyncOpenAI(**async_kwargs), model


Expand Down Expand Up @@ -1674,7 +1674,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = ""):
)
extra = {}
if base_url_host_matches(custom_base, "api.kimi.com"):
extra["default_headers"] = {"User-Agent": "KimiCLI/1.30.0"}
extra["default_headers"] = {"User-Agent": "claude-code/0.1.0"}
elif base_url_host_matches(custom_base, "api.githubcopilot.com"):
from hermes_cli.models import copilot_default_headers
extra["default_headers"] = copilot_default_headers()
Expand Down Expand Up @@ -1781,7 +1781,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = ""):
# Provider-specific headers
headers = {}
if base_url_host_matches(base_url, "api.kimi.com"):
headers["User-Agent"] = "KimiCLI/1.30.0"
headers["User-Agent"] = "claude-code/0.1.0"
elif base_url_host_matches(base_url, "api.githubcopilot.com"):
from hermes_cli.models import copilot_default_headers

Expand Down
17 changes: 13 additions & 4 deletions hermes_cli/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,11 @@ class ProviderConfig:
id="kimi-coding",
name="Kimi / Moonshot",
auth_type="api_key",
# Legacy platform.moonshot.ai keys use this endpoint (OpenAI-compat).
# sk-kimi- (Kimi Code) keys are auto-redirected to api.kimi.com/coding
# by _resolve_kimi_base_url() below.
inference_base_url="https://api.moonshot.ai/v1",
api_key_env_vars=("KIMI_API_KEY",),
api_key_env_vars=("KIMI_API_KEY", "KIMI_CODING_API_KEY"),
base_url_env_var="KIMI_BASE_URL",
),
"kimi-coding-cn": ProviderConfig(
Expand Down Expand Up @@ -340,10 +343,16 @@ def get_anthropic_key() -> str:
# =============================================================================

# Kimi Code (kimi.com/code) 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
# on api.kimi.com/coding. Legacy keys from platform.moonshot.ai work on
# api.moonshot.ai/v1 (the old default). Auto-detect when user hasn't set
# KIMI_BASE_URL explicitly.
KIMI_CODE_BASE_URL = "https://api.kimi.com/coding/v1"
#
# Note: the base URL intentionally has NO /v1 suffix. The /coding endpoint
# speaks the Anthropic Messages protocol, and the anthropic SDK appends
# "/v1/messages" internally — so "/coding" + SDK suffix → "/coding/v1/messages"
# (the correct target). Using "/coding/v1" here would produce
# "/coding/v1/v1/messages" (a 404).
KIMI_CODE_BASE_URL = "https://api.kimi.com/coding"


def _resolve_kimi_base_url(api_key: str, default_url: str, env_override: str) -> str:
Expand Down
12 changes: 8 additions & 4 deletions hermes_cli/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -943,18 +943,22 @@ def run_doctor(args):
try:
import httpx
_base = os.getenv(_base_env, "") if _base_env else ""
# Auto-detect Kimi Code keys (sk-kimi-) → api.kimi.com
# Auto-detect Kimi Code keys (sk-kimi-) → api.kimi.com/coding/v1
# (OpenAI-compat surface, which exposes /models for health check).
if not _base and _key.startswith("sk-kimi-"):
_base = "https://api.kimi.com/coding/v1"
# Anthropic-compat endpoints (/anthropic) don't support /models.
# Rewrite to the OpenAI-compat /v1 surface for health checks.
# Anthropic-compat endpoints (/anthropic, api.kimi.com/coding
# with no /v1) don't support /models. Rewrite to the OpenAI-compat
# /v1 surface for health checks.
if _base and _base.rstrip("/").endswith("/anthropic"):
from agent.auxiliary_client import _to_openai_base_url
_base = _to_openai_base_url(_base)
if base_url_host_matches(_base, "api.kimi.com") and _base.rstrip("/").endswith("/coding"):
_base = _base.rstrip("/") + "/v1"
_url = (_base.rstrip("/") + "/models") if _base else _default_url
_headers = {"Authorization": f"Bearer {_key}"}
if base_url_host_matches(_base, "api.kimi.com"):
_headers["User-Agent"] = "KimiCLI/1.30.0"
_headers["User-Agent"] = "claude-code/0.1.0"
_resp = httpx.get(
_url,
headers=_headers,
Expand Down
1 change: 1 addition & 0 deletions hermes_cli/model_switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -678,6 +678,7 @@ def switch_model(
_da = DIRECT_ALIASES.get(resolved_alias)
if _da is not None and _da.base_url:
base_url = _da.base_url
api_mode = "" # clear so determine_api_mode re-detects from URL
if not api_key:
api_key = "no-key-required"

Expand Down
12 changes: 12 additions & 0 deletions hermes_cli/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,16 @@
"""
pdef = get_provider(provider)
if pdef is not None:
# Even for known providers, check URL heuristics for special endpoints
# (e.g. kimi /coding endpoint needs anthropic_messages even on 'custom')
if base_url:
url_lower = base_url.rstrip("/").lower()
if "api.kimi.com/coding" in url_lower:
return "anthropic_messages"
if url_lower.endswith("/anthropic") or "api.anthropic.com" in url_lower:
Comment thread
ethernet8023 marked this conversation as resolved.
Dismissed
return "anthropic_messages"
if "api.openai.com" in url_lower:
Comment thread
ethernet8023 marked this conversation as resolved.
Dismissed
return "codex_responses"
return TRANSPORT_TO_API_MODE.get(pdef.transport, "chat_completions")

# Direct provider checks for providers not in HERMES_OVERLAYS
Expand All @@ -439,6 +449,8 @@
hostname = base_url_hostname(base_url)
if url_lower.endswith("/anthropic") or hostname == "api.anthropic.com":
return "anthropic_messages"
if hostname == "api.kimi.com" and "/coding" in url_lower:
return "anthropic_messages"
if hostname == "api.openai.com":
return "codex_responses"
if hostname.startswith("bedrock-runtime.") and base_url_host_matches(base_url, "amazonaws.com"):
Expand Down
11 changes: 9 additions & 2 deletions hermes_cli/runtime_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ def _detect_api_mode_for_url(base_url: str) -> Optional[str]:
protocol under a ``/anthropic`` suffix — treat those as
``anthropic_messages`` transport instead of the default
``chat_completions``.
- Kimi Code's ``api.kimi.com/coding`` endpoint also speaks the
Anthropic Messages protocol (the /coding route accepts Claude
Code's native request shape).
"""
normalized = (base_url or "").strip().lower().rstrip("/")
hostname = base_url_hostname(base_url)
Expand All @@ -55,6 +58,8 @@ def _detect_api_mode_for_url(base_url: str) -> Optional[str]:
return "codex_responses"
if normalized.endswith("/anthropic"):
return "anthropic_messages"
if hostname == "api.kimi.com" and "/coding" in normalized:
return "anthropic_messages"
return None


Expand Down Expand Up @@ -205,7 +210,8 @@ def _resolve_runtime_from_pool_entry(
api_mode = opencode_model_api_mode(provider, model_cfg.get("default", ""))
else:
# Auto-detect Anthropic-compatible endpoints (/anthropic suffix,
# api.openai.com → codex_responses, api.x.ai → codex_responses).
# Kimi /coding, api.openai.com → codex_responses, api.x.ai →
# codex_responses).
detected = _detect_api_mode_for_url(base_url)
if detected:
api_mode = detected
Expand Down Expand Up @@ -660,7 +666,8 @@ def _resolve_explicit_runtime(
if configured_mode:
api_mode = configured_mode
else:
# Auto-detect Anthropic-compatible endpoints (/anthropic suffix).
# Auto-detect from URL (Anthropic /anthropic suffix,
# api.openai.com → Responses, Kimi /coding, etc.).
detected = _detect_api_mode_for_url(base_url)
if detected:
api_mode = detected
Expand Down
4 changes: 2 additions & 2 deletions run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -1175,7 +1175,7 @@ def __init__(
client_kwargs["default_headers"] = copilot_default_headers()
elif base_url_host_matches(effective_base, "api.kimi.com"):
client_kwargs["default_headers"] = {
"User-Agent": "KimiCLI/1.30.0",
"User-Agent": "claude-code/0.1.0",
}
elif base_url_host_matches(effective_base, "portal.qwen.ai"):
client_kwargs["default_headers"] = _qwen_portal_headers()
Expand Down Expand Up @@ -5049,7 +5049,7 @@ def _apply_client_headers_for_base_url(self, base_url: str) -> None:

self._client_kwargs["default_headers"] = copilot_default_headers()
elif base_url_host_matches(base_url, "api.kimi.com"):
self._client_kwargs["default_headers"] = {"User-Agent": "KimiCLI/1.30.0"}
self._client_kwargs["default_headers"] = {"User-Agent": "claude-code/0.1.0"}
elif base_url_host_matches(base_url, "portal.qwen.ai"):
self._client_kwargs["default_headers"] = _qwen_portal_headers()
elif base_url_host_matches(base_url, "chatgpt.com"):
Expand Down
1 change: 1 addition & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"teknium@nousresearch.com": "teknium1",
"127238744+teknium1@users.noreply.github.com": "teknium1",
# contributors (from noreply pattern)
"wangqiang@wangqiangdeMac-mini.local": "xiaoqiang243",
"snreynolds2506@gmail.com": "snreynolds",
"35742124+0xbyt4@users.noreply.github.com": "0xbyt4",
"71184274+MassiveMassimo@users.noreply.github.com": "MassiveMassimo",
Expand Down
6 changes: 5 additions & 1 deletion tests/hermes_cli/test_api_key_providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,11 @@ def test_copilot_env_vars(self):

def test_kimi_env_vars(self):
pconfig = PROVIDER_REGISTRY["kimi-coding"]
assert pconfig.api_key_env_vars == ("KIMI_API_KEY",)
# KIMI_API_KEY is the primary env var; KIMI_CODING_API_KEY is a
# secondary fallback for Kimi Code sk-kimi- keys so users don't
# have to overload the same variable.
assert "KIMI_API_KEY" in pconfig.api_key_env_vars
assert "KIMI_CODING_API_KEY" in pconfig.api_key_env_vars
assert pconfig.base_url_env_var == "KIMI_BASE_URL"

def test_minimax_env_vars(self):
Expand Down
3 changes: 3 additions & 0 deletions tools/delegate_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -1322,6 +1322,9 @@ def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict:
elif base_url_hostname(configured_base_url) == "api.anthropic.com":
provider = "anthropic"
api_mode = "anthropic_messages"
elif "api.kimi.com/coding" in base_lower:
provider = "custom"
api_mode = "anthropic_messages"

return {
"model": configured_model,
Expand Down
Loading