feat(claude-code): route Kimi K2.6 to api.kimi.com/coding per official spec
All checks were successful
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 16s
CI / Adapter unit tests (push) Successful in 1m23s
CI / Template validation (static) (push) Successful in 1m27s
CI / Adapter unit tests (pull_request) Successful in 1m25s
CI / Template validation (static) (pull_request) Successful in 1m30s
CI / Template validation (runtime) (push) Successful in 10m27s
CI / Template validation (runtime) (pull_request) Successful in 9m52s
CI / validate (pull_request) Successful in 6s
CI / validate (push) Successful in 5s

Kimi (Kimi-For-Coding / K2.6) was structurally unreachable from the
claude-code runtime: the `kimi-` model prefix matched the `moonshot`
provider, which set ANTHROPIC_BASE_URL=https://api.moonshot.ai/anthropic
and projected KIMI_API_KEY -> ANTHROPIC_AUTH_TOKEN. Both are wrong per
kimi.com's official Claude Code integration doc
(kimi.com/code/docs/en/third-party-tools/other-coding-agents.html):
  - the sk-kimi-* key (KIMI_API_KEY in SSOT) authenticates ONLY against
    https://api.kimi.com/coding/ — the legacy api.moonshot.ai/anthropic
    surface 401s it (invalid_authentication_error);
  - that gateway authenticates with the x-api-key header, which the
    Anthropic SDK / claude CLI emits from ANTHROPIC_API_KEY, NOT the
    Bearer ANTHROPIC_AUTH_TOKEN.

So a Kimi pick on claude-code 401'd every LLM call.

Fix (config + minimal adapter, scoped to this template — adapter.py and
config.yaml are template-local, COPY'd in the Dockerfile; zero blast
radius on other runtimes):

- config.yaml: repoint the existing kimi- provider entry (renamed
  moonshot -> kimi-coding) to base_url https://api.kimi.com/coding/
  (trailing slash, per the doc) and add a new optional per-provider
  field `auth_token_env: ANTHROPIC_API_KEY` so the boot-time vendor-key
  projection writes KIMI_API_KEY into ANTHROPIC_API_KEY (x-api-key)
  instead of the default ANTHROPIC_AUTH_TOKEN (Bearer). Renaming the
  existing entry (vs adding a parallel one) keeps the kimi- model-prefix
  matcher working with the least change; still 7 providers total.
- config.yaml: add a selectable "Kimi K2.6" model catalog entry
  (id kimi-for-coding — the gateway's own served-model name, mirroring
  the proven OpenClaw kimi-for-coding route; the gateway routes to K2.6
  regardless of the wire model id). kimi-k2.5 / kimi-k2 retained as
  aliases hitting the same gateway for back-compat.
- adapter.py: _normalize_provider parses the optional `auth_token_env`
  (default ANTHROPIC_AUTH_TOKEN — preserves MiniMax/GLM/DeepSeek
  behavior bit-for-bit); _project_vendor_auth projects into that
  per-provider target and is idempotent on it (explicit operator value
  still wins).

Wire-verified before commit: POST https://api.kimi.com/coding/v1/messages
with x-api-key=<SSOT KIMI_API_KEY> + anthropic-version + claude-cli UA
-> HTTP 200, model=kimi-for-coding, real completion. The shipped routing
produces exactly this wire shape.

Tests: added 4 tests (Kimi -> ANTHROPIC_API_KEY projection, operator
override idempotency, _normalize_provider auth_token_env parse,
prevalidate routing matrix incl. kimi-for-coding); updated the
moonshot-named fixtures/assertions to the new kimi-coding contract.
Full suite 85 passed.
This commit is contained in:
Molecule AI · infra-runtime-be 2026-05-16 04:56:49 -07:00
parent 5bc87ea75d
commit 66e3b7edb3
4 changed files with 169 additions and 33 deletions

View File

@ -144,6 +144,20 @@ def _normalize_provider(entry: dict):
"model_aliases": _coerce_string_list(entry.get("model_aliases"), lowercase=True),
"base_url": entry.get("base_url") or None,
"auth_env": _coerce_string_list(entry.get("auth_env"), lowercase=False),
# Which env var the boot-time vendor-key projection writes the
# vendor key INTO. Defaults to ANTHROPIC_AUTH_TOKEN (Bearer-style
# — correct for MiniMax/GLM/DeepSeek Anthropic-compat shims).
# Kimi For Coding's gateway authenticates with the x-api-key
# header (per kimi.com's official Claude Code doc), which the
# Anthropic SDK / claude CLI emits from ANTHROPIC_API_KEY — so
# that provider's entry sets auth_token_env: ANTHROPIC_API_KEY.
# Env-var names are case-sensitive; preserve case.
"auth_token_env": (
entry.get("auth_token_env")
if isinstance(entry.get("auth_token_env"), str)
and entry.get("auth_token_env").strip()
else "ANTHROPIC_AUTH_TOKEN"
),
}
@ -446,12 +460,18 @@ _VENDOR_KEY_NAMES = frozenset({
def _project_vendor_auth(provider: dict) -> None:
"""Project a per-vendor API key onto ANTHROPIC_AUTH_TOKEN at boot.
"""Project a per-vendor API key onto the provider's auth-token env at boot.
Third-party Anthropic-compat providers (MiniMax, Z.ai, DeepSeek)
reuse the Anthropic SDK's wire format with a Bearer token, which the
``claude`` CLI / claude-code-sdk reads from ``ANTHROPIC_AUTH_TOKEN``.
Kimi For Coding's gateway instead authenticates with the
``x-api-key`` header (per kimi.com's official Claude Code
integration doc), which the SDK emits from ``ANTHROPIC_API_KEY``
so the projection target is per-provider, declared as
``auth_token_env`` in the registry (default ``ANTHROPIC_AUTH_TOKEN``
preserves the existing MiniMax/GLM/DeepSeek behavior unchanged).
Third-party Anthropic-compat providers (MiniMax, Z.ai, Moonshot,
DeepSeek) all reuse the Anthropic SDK's wire format, which means the
``claude`` CLI / claude-code-sdk reads the bearer token from
``ANTHROPIC_AUTH_TOKEN`` no matter which vendor is being talked to.
Pre-#244 the canvas surfaced the vendor-specific name
(``MINIMAX_API_KEY``, etc.) to the user so a user who saved only
that name hit a silent 401 on first call while the boot audit said
@ -459,21 +479,24 @@ def _project_vendor_auth(provider: dict) -> None:
/ hermes PR #38.
Behavior:
* Let ``target`` = the provider's ``auth_token_env`` (default
``ANTHROPIC_AUTH_TOKEN``).
* If the matched provider's ``auth_env`` lists any of
``_VENDOR_KEY_NAMES`` and that var is set, copy its value into
``ANTHROPIC_AUTH_TOKEN`` so the SDK finds it.
* **Idempotent**: if ``ANTHROPIC_AUTH_TOKEN`` is already set we
do NOT overwrite an explicit operator value (workspace
secret) always wins over auto-projection.
* Logs the projection by NAME (e.g. ``MINIMAX_API_KEY ->
ANTHROPIC_AUTH_TOKEN``); never logs the secret VALUE. Same
``target`` so the SDK finds it.
* **Idempotent**: if ``target`` is already set we do NOT
overwrite an explicit operator value (workspace secret)
always wins over auto-projection.
* Logs the projection by NAME (e.g. ``KIMI_API_KEY ->
ANTHROPIC_API_KEY``); never logs the secret VALUE. Same
contract as ``_audit_auth_env_presence``.
* No-op for providers whose ``auth_env`` doesn't reference a
vendor-specific name (oauth, anthropic-api, or a third-party
entry that hasn't been added to the registry yet).
"""
auth_env = provider.get("auth_env") or ()
if os.environ.get("ANTHROPIC_AUTH_TOKEN"):
target = provider.get("auth_token_env") or "ANTHROPIC_AUTH_TOKEN"
if os.environ.get(target):
# Operator override wins — never clobber an explicit value.
return
for name in auth_env:
@ -482,10 +505,10 @@ def _project_vendor_auth(provider: dict) -> None:
value = os.environ.get(name)
if not value:
continue
os.environ["ANTHROPIC_AUTH_TOKEN"] = value
os.environ[target] = value
logger.info(
"auth env projection: %s -> ANTHROPIC_AUTH_TOKEN (provider=%s)",
name, provider.get("name", "<unknown>"),
"auth env projection: %s -> %s (provider=%s)",
name, target, provider.get("name", "<unknown>"),
)
return

View File

@ -31,6 +31,16 @@ tier: 2
# model_aliases : exact lowercase ids (e.g. ["sonnet", "opus"])
# base_url : ANTHROPIC_BASE_URL to set; null = CLI default (anthropic-native)
# auth_env : env vars accepted; any one being set satisfies auth
# auth_token_env : (optional) the env var the boot-time vendor-key
# projection writes the vendor key INTO. Defaults to
# ANTHROPIC_AUTH_TOKEN (Bearer-style; correct for
# MiniMax/GLM/DeepSeek Anthropic-compat shims). Kimi
# For Coding's gateway authenticates with the
# x-api-key header per kimi.com's official Claude Code
# integration doc, which the Anthropic SDK / claude
# CLI emits from ANTHROPIC_API_KEY (NOT the Bearer
# ANTHROPIC_AUTH_TOKEN) — so its entry sets
# auth_token_env: ANTHROPIC_API_KEY.
providers:
- name: anthropic-oauth
auth_mode: oauth
@ -73,13 +83,27 @@ providers:
base_url: https://api.z.ai/api/anthropic
auth_env: [GLM_API_KEY, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY]
# Moonshot AI — Kimi family. platform.kimi.ai/docs/guide/agent-support.
- name: moonshot
# Kimi For Coding — Moonshot's coding-agent tier (K2.6 / "Kimi for
# Coding"). Per kimi.com's OFFICIAL Claude Code integration doc
# (kimi.com/code/docs/en/third-party-tools/other-coding-agents.html,
# "Claude Code" section) the contract is:
# ANTHROPIC_BASE_URL=https://api.kimi.com/coding/ (trailing slash)
# ANTHROPIC_API_KEY=<the Kimi key> (x-api-key header)
# The `sk-kimi-*` key (KIMI_API_KEY in SSOT) authenticates ONLY against
# this gateway — the legacy api.moonshot.ai/anthropic surface 401s it.
# The gateway routes to the served K2.6 model regardless of the Claude
# model name on the wire (proven end-to-end via the OpenClaw template's
# api.kimi.com/coding path, winnerProvider=custom-api-kimi-com).
# auth_token_env pins the projection to ANTHROPIC_API_KEY (x-api-key)
# rather than the default ANTHROPIC_AUTH_TOKEN (Bearer), which this
# gateway rejects.
- name: kimi-coding
auth_mode: third_party_anthropic_compat
model_prefixes: [kimi-]
model_aliases: []
base_url: https://api.moonshot.ai/anthropic
auth_env: [KIMI_API_KEY, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY]
base_url: https://api.kimi.com/coding/
auth_env: [KIMI_API_KEY, ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN]
auth_token_env: ANTHROPIC_API_KEY
# DeepSeek — api-docs.deepseek.com/guides/anthropic_api. Note: their
# endpoint silently maps unknown model ids to deepseek-v4-flash, so a
@ -175,15 +199,23 @@ runtime_config:
name: Z.ai GLM-4.5 (third-party, Anthropic-API-compatible)
required_env: [GLM_API_KEY]
# --- Moonshot AI Kimi family (third-party, Anthropic-API-compatible) ---
# KIMI_API_KEY → ANTHROPIC_AUTH_TOKEN projection at boot.
# platform.kimi.ai for docs. K2.5 is the latest agentic-coding tier;
# K2 stays as a cheaper option.
# --- Kimi For Coding (third-party, Anthropic-API-compatible) ---
# Routed via the `kimi-coding` provider entry above: the adapter
# auto-sets ANTHROPIC_BASE_URL=https://api.kimi.com/coding/ and
# projects KIMI_API_KEY → ANTHROPIC_API_KEY (x-api-key) per
# kimi.com's official Claude Code integration doc. The gateway
# serves the K2.6 model regardless of the wire model id; the id
# below is the gateway's own served-model name (mirrors the proven
# OpenClaw `kimi-for-coding` route). K2.5 / K2 stay as aliases for
# workspaces pinned to the older labels — they hit the same gateway.
- id: kimi-for-coding
name: Kimi K2.6 (Kimi For Coding, third-party Anthropic-API-compatible)
required_env: [KIMI_API_KEY]
- id: kimi-k2.5
name: Moonshot Kimi K2.5 (third-party, Anthropic-API-compatible)
name: Kimi K2.5 (Kimi For Coding, third-party Anthropic-API-compatible)
required_env: [KIMI_API_KEY]
- id: kimi-k2
name: Moonshot Kimi K2 (third-party, Anthropic-API-compatible)
name: Kimi K2 (Kimi For Coding, third-party Anthropic-API-compatible)
required_env: [KIMI_API_KEY]
# --- DeepSeek (third-party, Anthropic-API-compatible) ---

View File

@ -129,12 +129,13 @@ _FIXTURE_PROVIDERS_YAML = textwrap.dedent("""
base_url: https://api.z.ai/api/anthropic
auth_env: [ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY]
- name: moonshot
- name: kimi-coding
auth_mode: third_party_anthropic_compat
model_prefixes: [kimi-]
model_aliases: []
base_url: https://api.moonshot.ai/anthropic
auth_env: [ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY]
base_url: https://api.kimi.com/coding/
auth_env: [KIMI_API_KEY, ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN]
auth_token_env: ANTHROPIC_API_KEY
- name: deepseek
auth_mode: third_party_anthropic_compat
@ -554,7 +555,7 @@ def test_load_providers_parses_yaml_and_normalizes(tmp_path):
names = [p["name"] for p in result]
assert names == [
"anthropic-oauth", "anthropic-api", "xiaomi-mimo", "minimax",
"zai", "moonshot", "deepseek",
"zai", "kimi-coding", "deepseek",
]
# YAML lists must be normalized to tuples for downstream lookup ergonomics.
assert isinstance(result[0]["model_aliases"], tuple)
@ -564,15 +565,16 @@ def test_load_providers_parses_yaml_and_normalizes(tmp_path):
@pytest.mark.parametrize("model,expected_provider,expected_url", [
("GLM-4.6", "zai", "https://api.z.ai/api/anthropic"),
("glm-4.5", "zai", "https://api.z.ai/api/anthropic"),
("kimi-k2.5", "moonshot", "https://api.moonshot.ai/anthropic"),
("kimi-k2.5", "kimi-coding", "https://api.kimi.com/coding/"),
("kimi-for-coding", "kimi-coding", "https://api.kimi.com/coding/"),
("deepseek-v4-pro", "deepseek", "https://api.deepseek.com/anthropic"),
])
@pytest.mark.asyncio
async def test_setup_routes_extra_providers(
adapter, monkeypatch, configs_dir, model, expected_provider, expected_url
):
"""The Z.ai / Moonshot / DeepSeek providers added in this PR must
route correctly: model id provider entry ANTHROPIC_BASE_URL.
"""The Z.ai / Kimi-For-Coding / DeepSeek providers must route
correctly: model id provider entry ANTHROPIC_BASE_URL.
Parametrized to keep the matrix coverage tight without 3 near-identical
test bodies. Locks in the per-vendor base_url so a future YAML edit
that mistypes z.ai's `/api/anthropic` suffix gets caught.

View File

@ -219,7 +219,6 @@ def test_glm_kimi_deepseek_also_project(adapter_module, monkeypatch):
"""
cases = [
("zai", "GLM_API_KEY"),
("moonshot", "KIMI_API_KEY"),
("deepseek", "DEEPSEEK_API_KEY"),
]
for provider_name, env_name in cases:
@ -242,3 +241,83 @@ def test_glm_kimi_deepseek_also_project(adapter_module, monkeypatch):
f"{env_name} must project onto ANTHROPIC_AUTH_TOKEN for "
f"provider={provider_name}"
)
def test_kimi_coding_projects_into_anthropic_api_key(adapter_module, monkeypatch):
"""Kimi For Coding's gateway authenticates with the x-api-key header
(kimi.com official Claude Code doc), which the Anthropic SDK / claude
CLI emits from ANTHROPIC_API_KEY NOT the Bearer ANTHROPIC_AUTH_TOKEN
used by MiniMax/GLM/DeepSeek. The kimi-coding provider sets
auth_token_env: ANTHROPIC_API_KEY so KIMI_API_KEY projects there.
Regression guard for the original mis-route: KIMI_API_KEY landing in
ANTHROPIC_AUTH_TOKEN against api.kimi.com/coding 401s.
"""
import os
_clear_all_auth_env(monkeypatch, adapter_module)
monkeypatch.setenv("KIMI_API_KEY", "sk-kimi-sentinel")
provider = {
"name": "kimi-coding",
"auth_mode": "third_party_anthropic_compat",
"model_prefixes": ("kimi-",),
"model_aliases": (),
"base_url": "https://api.kimi.com/coding/",
"auth_env": ("KIMI_API_KEY", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"),
"auth_token_env": "ANTHROPIC_API_KEY",
}
adapter_module._project_vendor_auth(provider)
assert os.environ.get("ANTHROPIC_API_KEY") == "sk-kimi-sentinel", (
"KIMI_API_KEY must project onto ANTHROPIC_API_KEY (x-api-key) for "
"the kimi-coding provider per kimi.com's official Claude Code doc"
)
assert os.environ.get("ANTHROPIC_AUTH_TOKEN") is None, (
"KIMI_API_KEY must NOT land in ANTHROPIC_AUTH_TOKEN — the Bearer "
"header 401s against api.kimi.com/coding (the original mis-route)"
)
def test_kimi_coding_operator_anthropic_api_key_wins(adapter_module, monkeypatch):
"""Idempotency holds for the per-provider target too: an explicit
operator ANTHROPIC_API_KEY is never clobbered by the projection."""
import os
_clear_all_auth_env(monkeypatch, adapter_module)
monkeypatch.setenv("KIMI_API_KEY", "sk-kimi-sentinel")
monkeypatch.setenv("ANTHROPIC_API_KEY", "operator-value")
provider = {
"name": "kimi-coding",
"auth_mode": "third_party_anthropic_compat",
"model_prefixes": ("kimi-",),
"model_aliases": (),
"base_url": "https://api.kimi.com/coding/",
"auth_env": ("KIMI_API_KEY", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"),
"auth_token_env": "ANTHROPIC_API_KEY",
}
adapter_module._project_vendor_auth(provider)
assert os.environ.get("ANTHROPIC_API_KEY") == "operator-value", (
"explicit operator ANTHROPIC_API_KEY must win over auto-projection"
)
def test_normalize_provider_parses_auth_token_env(adapter_module):
"""_normalize_provider surfaces auth_token_env; absent → the
ANTHROPIC_AUTH_TOKEN default (preserves MiniMax/GLM/DeepSeek)."""
with_override = adapter_module._normalize_provider({
"name": "kimi-coding",
"auth_mode": "third_party_anthropic_compat",
"base_url": "https://api.kimi.com/coding/",
"auth_env": ["KIMI_API_KEY", "ANTHROPIC_API_KEY"],
"auth_token_env": "ANTHROPIC_API_KEY",
})
assert with_override["auth_token_env"] == "ANTHROPIC_API_KEY"
default = adapter_module._normalize_provider({
"name": "minimax",
"auth_mode": "third_party_anthropic_compat",
"base_url": "https://api.minimax.io/anthropic",
"auth_env": ["MINIMAX_API_KEY"],
})
assert default["auth_token_env"] == "ANTHROPIC_AUTH_TOKEN"