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
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:
parent
5bc87ea75d
commit
66e3b7edb3
53
adapter.py
53
adapter.py
@ -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
|
||||
|
||||
|
||||
52
config.yaml
52
config.yaml
@ -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) ---
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user