diff --git a/adapter.py b/adapter.py index fd4efa4..7909851 100644 --- a/adapter.py +++ b/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", ""), + "auth env projection: %s -> %s (provider=%s)", + name, target, provider.get("name", ""), ) return diff --git a/config.yaml b/config.yaml index 47dfed3..7b7fcfb 100644 --- a/config.yaml +++ b/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= (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) --- diff --git a/tests/test_adapter_prevalidate.py b/tests/test_adapter_prevalidate.py index 47c8154..87b921a 100644 --- a/tests/test_adapter_prevalidate.py +++ b/tests/test_adapter_prevalidate.py @@ -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. diff --git a/tests/test_provider_routing.py b/tests/test_provider_routing.py index a978d5e..c880a45 100644 --- a/tests/test_provider_routing.py +++ b/tests/test_provider_routing.py @@ -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"