diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index ff1d536b1753a..088f84c33411a 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -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. @@ -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 diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 5195b09520891..f738c8c0f937e 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -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 @@ -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 @@ -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 @@ -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() @@ -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 diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index c82bad3f02fcf..9f3b3cae94b32 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -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( @@ -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: diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index e16f0bf5e64b5..2fc50321f68a6 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -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, diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index 22721f9a42733..5b26f5b8b5f7c 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -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" diff --git a/hermes_cli/providers.py b/hermes_cli/providers.py index 1764474aa9a5a..00c3f64bcf9e8 100644 --- a/hermes_cli/providers.py +++ b/hermes_cli/providers.py @@ -427,6 +427,16 @@ def determine_api_mode(provider: str, base_url: str = "") -> str: """ 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: + return "anthropic_messages" + if "api.openai.com" in url_lower: + return "codex_responses" return TRANSPORT_TO_API_MODE.get(pdef.transport, "chat_completions") # Direct provider checks for providers not in HERMES_OVERLAYS @@ -439,6 +449,8 @@ def determine_api_mode(provider: str, base_url: str = "") -> str: 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"): diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index 62f1407cc7a60..922946e2ad06d 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -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) @@ -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 @@ -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 @@ -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 diff --git a/run_agent.py b/run_agent.py index c5966a173706b..4f431bb6a96c3 100644 --- a/run_agent.py +++ b/run_agent.py @@ -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() @@ -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"): diff --git a/scripts/release.py b/scripts/release.py index f2a72ea0038d4..7050993137cf0 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -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", diff --git a/tests/hermes_cli/test_api_key_providers.py b/tests/hermes_cli/test_api_key_providers.py index 2af003ea08622..7d0674b038583 100644 --- a/tests/hermes_cli/test_api_key_providers.py +++ b/tests/hermes_cli/test_api_key_providers.py @@ -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): diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 093be11c01663..ebf771d1598e7 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -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,