From 1920f61b02a023d9eceab07d55f3a424ff61dbf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BC=BA?= Date: Tue, 14 Apr 2026 21:00:30 +0800 Subject: [PATCH 1/8] fix: add User-Agent claude-code/0.1.0 for Kimi /coding endpoint - Add _is_kimi_coding_endpoint() to detect Kimi coding API - Place Kimi check BEFORE _requires_bearer_auth to ensure User-Agent header is set - Without this header, Kimi returns 403 on /coding/v1/messages - Fixes kimi-2.5, kimi-for-coding, kimi-k2.6-code-preview all returning 403 --- agent/anthropic_adapter.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) 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 From 831373f7e2f5dedb738f5b82fdbc1a6279a3407a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BC=BA?= Date: Sat, 18 Apr 2026 08:01:14 +0800 Subject: [PATCH 2/8] fix: auto-detect anthropic_messages mode for Kimi /coding/v1 endpoints --- hermes_cli/runtime_provider.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index 62f1407cc7a60..4b2b9d33f8242 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 @@ -660,7 +665,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 From 135fb5ca3b711a891a90ba766a2a8f0a15872637 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BC=BA?= Date: Sat, 18 Apr 2026 08:09:41 +0800 Subject: [PATCH 3/8] fix(kimi-coding): add KIMI_CODING_API_KEY fallback + api_mode detection for /coding endpoint --- hermes_cli/auth.py | 2 +- hermes_cli/runtime_provider.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index c82bad3f02fcf..21fd0ef47f36f 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -169,7 +169,7 @@ class ProviderConfig: name="Kimi / Moonshot", auth_type="api_key", 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( diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index 4b2b9d33f8242..922946e2ad06d 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -210,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 From 5a4d4acad89dfbfb85a30d2394dfcfbed01e13fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BC=BA?= Date: Sat, 18 Apr 2026 17:23:12 +0800 Subject: [PATCH 4/8] fix(kimi-coding): set anthropic_messages api_mode for /coding endpoint --- tools/delegate_tool.py | 3 +++ 1 file changed, 3 insertions(+) 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, From 1f08b5368a6d89797450441b446858e653b0e0f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BC=BA?= Date: Sat, 18 Apr 2026 22:55:36 +0800 Subject: [PATCH 5/8] fix: Update Kimi Coding API endpoint and User-Agent --- agent/auxiliary_client.py | 10 +++++----- hermes_cli/auth.py | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) 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 21fd0ef47f36f..137e52d196fb3 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -168,7 +168,7 @@ class ProviderConfig: id="kimi-coding", name="Kimi / Moonshot", auth_type="api_key", - inference_base_url="https://api.moonshot.ai/v1", + inference_base_url="https://api.kimi.com/coding", api_key_env_vars=("KIMI_API_KEY", "KIMI_CODING_API_KEY"), base_url_env_var="KIMI_BASE_URL", ), From 20ae911c40b205dc5071bc22b1ea5f407395bdab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BC=BA?= Date: Sat, 18 Apr 2026 23:01:53 +0800 Subject: [PATCH 6/8] fix: Enhance Kimi Coding API mode detection and User-Agent --- hermes_cli/model_switch.py | 1 + hermes_cli/providers.py | 12 ++++++++++++ run_agent.py | 4 ++-- 3 files changed, 15 insertions(+), 2 deletions(-) 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/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"): From 4c1a25ba809bd5637089fecee04847535b467110 Mon Sep 17 00:00:00 2001 From: Teknium Date: Tue, 21 Apr 2026 19:42:33 -0700 Subject: [PATCH 7/8] fix(kimi): reconcile sk-kimi- routing with Anthropic SDK URL semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups after salvaging xiaoqiang243's kimi-for-coding patches: - KIMI_CODE_BASE_URL: drop trailing /v1 (was /coding/v1). The /coding endpoint speaks Anthropic Messages, and the Anthropic SDK appends /v1/messages internally. /coding/v1 + SDK suffix produced /coding/v1/v1/messages (a 404). /coding + SDK suffix now yields /coding/v1/messages correctly. - kimi-coding ProviderConfig: keep legacy default api.moonshot.ai/v1 so non-sk-kimi- moonshot keys still authenticate. sk-kimi- keys are already redirected to api.kimi.com/coding via _resolve_kimi_base_url. - doctor.py: update Kimi UA to claude-code/0.1.0 (was KimiCLI/1.30.0) and rewrite /coding base URLs to /coding/v1 for the /models health check (Anthropic surface has no /models). - test_kimi_env_vars: accept KIMI_CODING_API_KEY as a secondary env var. E2E verified: sk-kimi- → https://api.kimi.com/coding/v1/messages (Anthropic) sk- → https://api.moonshot.ai/v1/chat/completions (OpenAI) UA: claude-code/0.1.0, x-api-key: --- hermes_cli/auth.py | 17 +++++++++++++---- hermes_cli/doctor.py | 12 ++++++++---- tests/hermes_cli/test_api_key_providers.py | 6 +++++- 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 137e52d196fb3..9f3b3cae94b32 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -168,7 +168,10 @@ class ProviderConfig: id="kimi-coding", name="Kimi / Moonshot", auth_type="api_key", - inference_base_url="https://api.kimi.com/coding", + # 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", "KIMI_CODING_API_KEY"), base_url_env_var="KIMI_BASE_URL", ), @@ -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/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): From ca33a30bea4d00f5d9324cae8ec5c3a0e2e5a682 Mon Sep 17 00:00:00 2001 From: Teknium Date: Tue, 21 Apr 2026 19:42:36 -0700 Subject: [PATCH 8/8] chore(release): map xiaoqiang243 personal email in AUTHOR_MAP --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) 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",