From b9a1fa1b1faa6071e263c8fc27bcadf95efb025a Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Sat, 2 May 2026 22:20:03 -0700 Subject: [PATCH] feat: per-vendor env routing for third-party providers (task #244) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third-party Anthropic-compat providers (MiniMax, GLM, Kimi, DeepSeek) all reuse the Anthropic SDK's wire format, which means the claude CLI and claude-code-sdk read the bearer token from ANTHROPIC_AUTH_TOKEN no matter which vendor is being talked to. Pre-#244: * Canvas surfaced the vendor-specific name (MINIMAX_API_KEY, etc.) to the user — so a user who saved only MINIMAX_API_KEY hit a silent 401 on first call. * The boot audit said `MINIMAX_API_KEY=set`, making it look like an SDK bug rather than a routing gap. * A user with multiple vendor keys could only run one workspace at a time because they all fought over the shared ANTHROPIC_AUTH_TOKEN slot. Diagnostic-only audit logging shipped earlier (#32) but the actual routing was never written — task #244 was mismarked complete. Changes: * config.yaml: third-party model `required_env` now references the per-vendor name (MINIMAX_API_KEY, GLM_API_KEY, KIMI_API_KEY, DEEPSEEK_API_KEY) so canvas asks the user for the right key. First-party Anthropic models still use ANTHROPIC_AUTH_TOKEN / CLAUDE_CODE_OAUTH_TOKEN. * config.yaml: each third-party provider's `auth_env` lists the vendor name FIRST (priority order) so projection picks the vendor key over a stale ANTHROPIC_AUTH_TOKEN. * adapter.py: new `_project_vendor_auth(provider)` helper, called from `setup()` right after `_resolve_provider`. Idempotent — only projects when ANTHROPIC_AUTH_TOKEN is unset (operator override always wins). Logs the projection by NAME, never by VALUE (mirrors `_audit_auth_env_presence`). * tests/test_provider_routing.py: 6 new tests pin the contract — vendor-key-set projects, AUTH_TOKEN-already-set is never clobbered, first-party providers skip projection, secret value never leaks into a log record, empty-string vendor env doesn't trigger projection, and the same routing fires for GLM / Kimi / DeepSeek. Mirrors the parallel hermes-side fix from task #249 / hermes PR #38; keeps the two runtimes' multi-vendor UX in lockstep. Co-Authored-By: Claude Opus 4.7 (1M context) --- adapter.py | 66 +++++++++ config.yaml | 55 ++++---- tests/test_provider_routing.py | 244 +++++++++++++++++++++++++++++++++ 3 files changed, 341 insertions(+), 24 deletions(-) create mode 100644 tests/test_provider_routing.py diff --git a/adapter.py b/adapter.py index e82f2e4..57357f8 100644 --- a/adapter.py +++ b/adapter.py @@ -223,6 +223,63 @@ def _strip_provider_prefix(model: str) -> str: return model +# Vendor-specific env names that are SAFE to copy into ANTHROPIC_AUTH_TOKEN +# at boot. Limited to per-vendor names so a stray ANTHROPIC_API_KEY (which +# the SDK reads on its own path) is never misrouted into the AUTH_TOKEN +# slot. Keep in sync with the canvas-side env name suggestions. +_VENDOR_KEY_NAMES = frozenset({ + "MINIMAX_API_KEY", + "GLM_API_KEY", + "KIMI_API_KEY", + "DEEPSEEK_API_KEY", +}) + + +def _project_vendor_auth(provider: dict) -> None: + """Project a per-vendor API key onto ANTHROPIC_AUTH_TOKEN at boot. + + 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 + ``MINIMAX_API_KEY=set``. Mirrors the hermes-side fix from task #249 + / hermes PR #38. + + Behavior: + * 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 + 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"): + # Operator override wins — never clobber an explicit value. + return + for name in auth_env: + if name not in _VENDOR_KEY_NAMES: + continue + value = os.environ.get(name) + if not value: + continue + os.environ["ANTHROPIC_AUTH_TOKEN"] = value + logger.info( + "auth env projection: %s -> ANTHROPIC_AUTH_TOKEN (provider=%s)", + name, provider.get("name", ""), + ) + return + + def _resolve_provider(model: str, providers: tuple) -> dict: """Return the provider entry matching this model id. @@ -357,6 +414,15 @@ class ClaudeCodeAdapter(BaseAdapter): provider = _resolve_provider(picked_model, providers) auth_env_options = provider["auth_env"] + # Project the per-vendor API key (MINIMAX_API_KEY, GLM_API_KEY, + # KIMI_API_KEY, DEEPSEEK_API_KEY) onto ANTHROPIC_AUTH_TOKEN so the + # claude-code-sdk finds the bearer token. Idempotent: explicit + # ANTHROPIC_AUTH_TOKEN (operator override) is never clobbered. + # Must run BEFORE the auth audit + auth check below so the audit + # reflects the post-projection state and the check sees the right + # value. Task #244; mirrors hermes PR #38 (task #249). + _project_vendor_auth(provider) + # Endpoint precedence: operator-set ANTHROPIC_BASE_URL wins (escape # hatch for custom regional endpoints — e.g. token-plan-sgp.* for # Xiaomi MiMo, api.minimaxi.com for MiniMax China). Otherwise the diff --git a/config.yaml b/config.yaml index 11b74e9..47dfed3 100644 --- a/config.yaml +++ b/config.yaml @@ -58,7 +58,10 @@ providers: model_prefixes: [minimax-] model_aliases: [] base_url: https://api.minimax.io/anthropic - auth_env: [ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY] + # Vendor-specific name FIRST so the boot-time projection helper + # (_project_vendor_auth in adapter.py) picks it over a stale + # ANTHROPIC_AUTH_TOKEN belonging to a sibling vendor. + auth_env: [MINIMAX_API_KEY, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY] # Z.ai — GLM family. docs.z.ai/scenario-example/develop-tools/claude. # Model ids are uppercase (GLM-4.6) but the registry lowercases for @@ -68,7 +71,7 @@ providers: model_prefixes: [glm-] model_aliases: [] base_url: https://api.z.ai/api/anthropic - auth_env: [ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY] + auth_env: [GLM_API_KEY, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY] # Moonshot AI — Kimi family. platform.kimi.ai/docs/guide/agent-support. - name: moonshot @@ -76,7 +79,7 @@ providers: model_prefixes: [kimi-] model_aliases: [] base_url: https://api.moonshot.ai/anthropic - auth_env: [ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY] + auth_env: [KIMI_API_KEY, ANTHROPIC_AUTH_TOKEN, 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 @@ -87,7 +90,7 @@ providers: model_prefixes: [deepseek-] model_aliases: [] base_url: https://api.deepseek.com/anthropic - auth_env: [ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY] + auth_env: [DEEPSEEK_API_KEY, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY] runtime: claude-code runtime_config: @@ -145,50 +148,54 @@ runtime_config: required_env: [ANTHROPIC_API_KEY] # --- MiniMax (third-party, Anthropic-API-compatible) --- - # Routed via the `minimax` provider entry above. MiniMax docs prefer - # ANTHROPIC_AUTH_TOKEN (Bearer-style) — see platform.minimax.io/docs/token-plan/claude-code. - # ANTHROPIC_API_KEY also works (the claude CLI accepts both). + # Vendor-specific env var so a user with multiple third-party Anthropic- + # compat keys can run multiple workspaces simultaneously without them + # fighting over a shared ANTHROPIC_AUTH_TOKEN slot. The adapter projects + # MINIMAX_API_KEY → ANTHROPIC_AUTH_TOKEN at boot (only when + # ANTHROPIC_AUTH_TOKEN is unset, so an explicit operator override wins). + # Mirrors hermes-side fix from task #249 / hermes PR #38. - id: MiniMax-M2 name: MiniMax M2 (third-party, Anthropic-API-compatible) - required_env: [ANTHROPIC_AUTH_TOKEN] + required_env: [MINIMAX_API_KEY] - id: MiniMax-M2.7 name: MiniMax M2.7 (third-party, Anthropic-API-compatible) - required_env: [ANTHROPIC_AUTH_TOKEN] + required_env: [MINIMAX_API_KEY] - id: MiniMax-M2.7-highspeed name: MiniMax M2.7 High-Speed (third-party, Anthropic-API-compatible) - required_env: [ANTHROPIC_AUTH_TOKEN] + required_env: [MINIMAX_API_KEY] # --- Z.ai GLM family (third-party, Anthropic-API-compatible) --- - # Routed via the `zai` provider entry. docs.z.ai for the full - # Anthropic-compat docs. GLM-4.6 is the current-gen flagship; 4.5 - # remains for users on legacy quotas. + # GLM_API_KEY → ANTHROPIC_AUTH_TOKEN projection at boot. docs.z.ai for + # the full Anthropic-compat docs. GLM-4.6 is the current-gen flagship; + # 4.5 remains for users on legacy quotas. - id: GLM-4.6 name: Z.ai GLM-4.6 (third-party, Anthropic-API-compatible) - required_env: [ANTHROPIC_AUTH_TOKEN] + required_env: [GLM_API_KEY] - id: GLM-4.5 name: Z.ai GLM-4.5 (third-party, Anthropic-API-compatible) - required_env: [ANTHROPIC_AUTH_TOKEN] + required_env: [GLM_API_KEY] # --- Moonshot AI Kimi family (third-party, Anthropic-API-compatible) --- - # Routed via the `moonshot` provider entry. platform.kimi.ai for docs. - # K2.5 is the latest agentic-coding tier; K2 stays as a cheaper option. + # 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. - id: kimi-k2.5 name: Moonshot Kimi K2.5 (third-party, Anthropic-API-compatible) - required_env: [ANTHROPIC_AUTH_TOKEN] + required_env: [KIMI_API_KEY] - id: kimi-k2 name: Moonshot Kimi K2 (third-party, Anthropic-API-compatible) - required_env: [ANTHROPIC_AUTH_TOKEN] + required_env: [KIMI_API_KEY] # --- DeepSeek (third-party, Anthropic-API-compatible) --- - # Routed via the `deepseek` provider entry. api-docs.deepseek.com. - # Note: unknown deepseek-* ids silently fall back to v4-flash on - # DeepSeek's side — pick the exact tier you mean. + # DEEPSEEK_API_KEY → ANTHROPIC_AUTH_TOKEN projection at boot. + # api-docs.deepseek.com. Note: unknown deepseek-* ids silently fall + # back to v4-flash on DeepSeek's side — pick the exact tier you mean. - id: deepseek-v4-pro name: DeepSeek V4 Pro (third-party, Anthropic-API-compatible) - required_env: [ANTHROPIC_AUTH_TOKEN] + required_env: [DEEPSEEK_API_KEY] - id: deepseek-v4-flash name: DeepSeek V4 Flash (third-party, Anthropic-API-compatible) - required_env: [ANTHROPIC_AUTH_TOKEN] + required_env: [DEEPSEEK_API_KEY] # Default required_env — per-model entries above override this once a # model is picked. Keep CLAUDE_CODE_OAUTH_TOKEN as the default so diff --git a/tests/test_provider_routing.py b/tests/test_provider_routing.py new file mode 100644 index 0000000..a978d5e --- /dev/null +++ b/tests/test_provider_routing.py @@ -0,0 +1,244 @@ +"""Tests for the per-vendor env routing helper (_project_vendor_auth). + +Task #244 — third-party Anthropic-compat providers (MiniMax, GLM, Kimi, +DeepSeek) used to share ANTHROPIC_AUTH_TOKEN, so a user with multiple +vendor keys could only run one workspace at a time, AND a user who saved +only the canvas-shown vendor name (e.g. MINIMAX_API_KEY) hit a silent +401 on first call. The boot audit log even said ``MINIMAX_API_KEY=set`` +which made root-causing this look like an SDK bug. + +This file pins the projection contract: + 1. Vendor key set + AUTH_TOKEN unset -> projection happens + 2. AUTH_TOKEN already set -> never clobbered (operator override wins) + 3. First-party (oauth / anthropic-api) provider picked -> no + projection (vendor names ignored even if set) + 4. The secret VALUE is never logged (mirrors the + _audit_auth_env_presence guarantee from PR #32). +""" +from __future__ import annotations + +import importlib.util +import logging +import sys +import types +from pathlib import Path + +import pytest + + +@pytest.fixture +def adapter_module(monkeypatch): + """Load adapter.py with molecule_runtime + a2a stubbed. + + Same isolation strategy as test_adapter_logging.py — see that file's + fixture comment for the rationale. We stub the heavy import deps so + the module-level helpers can be exercised without installing the + runtime wheel. + """ + pkg = types.ModuleType("molecule_runtime") + sub = types.ModuleType("molecule_runtime.adapters") + base = types.ModuleType("molecule_runtime.adapters.base") + base.BaseAdapter = type("BaseAdapter", (), {}) + base.AdapterConfig = type("AdapterConfig", (), {}) + base.RuntimeCapabilities = type("RuntimeCapabilities", (), {}) + monkeypatch.setitem(sys.modules, "molecule_runtime", pkg) + monkeypatch.setitem(sys.modules, "molecule_runtime.adapters", sub) + monkeypatch.setitem(sys.modules, "molecule_runtime.adapters.base", base) + + a2a = types.ModuleType("a2a") + a2a_server = types.ModuleType("a2a.server") + a2a_ax = types.ModuleType("a2a.server.agent_execution") + a2a_ax.AgentExecutor = type("AgentExecutor", (), {}) + monkeypatch.setitem(sys.modules, "a2a", a2a) + monkeypatch.setitem(sys.modules, "a2a.server", a2a_server) + monkeypatch.setitem(sys.modules, "a2a.server.agent_execution", a2a_ax) + + template_dir = Path(__file__).resolve().parent.parent + monkeypatch.syspath_prepend(str(template_dir)) + + sys.modules.pop("adapter", None) + spec = importlib.util.spec_from_file_location("adapter", template_dir / "adapter.py") + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +# Sentinel used across tests to verify the secret value never leaks +# into a log record. Distinctive enough that any substring match +# unambiguously means a regression. +_SENTINEL = "fake-vendor-secret-MUST-NOT-LEAK-244" + + +def _minimax_provider(): + """Return a minimax-shaped provider dict matching config.yaml's entry. + + Built inline (not loaded from YAML) so the test doesn't depend on + config.yaml's exact contents — that keeps the test green if a + reviewer reorders the YAML or renames the provider entry, while + still pinning the routing contract on the helper itself. + """ + return { + "name": "minimax", + "auth_mode": "third_party_anthropic_compat", + "model_prefixes": ("minimax-",), + "model_aliases": (), + "base_url": "https://api.minimax.io/anthropic", + "auth_env": ("MINIMAX_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"), + } + + +def _oauth_provider(): + return { + "name": "anthropic-oauth", + "auth_mode": "oauth", + "model_prefixes": (), + "model_aliases": ("sonnet", "opus", "haiku"), + "base_url": None, + "auth_env": ("CLAUDE_CODE_OAUTH_TOKEN",), + } + + +def _clear_all_auth_env(monkeypatch, adapter_module): + """Strip every auth-relevant env var so the test starts from a clean slate.""" + for name in adapter_module._AUTH_ENV_AUDIT: + monkeypatch.delenv(name, raising=False) + + +def test_vendor_key_projects_when_auth_token_unset(adapter_module, monkeypatch): + """The headline #244 fix: MINIMAX_API_KEY set, AUTH_TOKEN unset -> projection.""" + _clear_all_auth_env(monkeypatch, adapter_module) + monkeypatch.setenv("MINIMAX_API_KEY", _SENTINEL) + + adapter_module._project_vendor_auth(_minimax_provider()) + + import os + assert os.environ.get("ANTHROPIC_AUTH_TOKEN") == _SENTINEL, ( + "MINIMAX_API_KEY value must be projected onto ANTHROPIC_AUTH_TOKEN " + "so the claude-code-sdk finds the bearer token" + ) + + +def test_existing_auth_token_not_clobbered(adapter_module, monkeypatch): + """Idempotency: an explicit ANTHROPIC_AUTH_TOKEN is the operator override and wins.""" + _clear_all_auth_env(monkeypatch, adapter_module) + monkeypatch.setenv("MINIMAX_API_KEY", "vendor-value") + monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "operator-value") + + adapter_module._project_vendor_auth(_minimax_provider()) + + import os + assert os.environ.get("ANTHROPIC_AUTH_TOKEN") == "operator-value", ( + "operator-set ANTHROPIC_AUTH_TOKEN must NEVER be overwritten by the " + "vendor-key projection — that's the explicit-override escape hatch" + ) + + +def test_first_party_provider_skips_projection(adapter_module, monkeypatch): + """OAuth/anthropic-api providers don't project even if a vendor key is set. + + A workspace running on Claude Code OAuth that *also* happens to have + MINIMAX_API_KEY exported (e.g. a multi-vendor power user) must NOT + have that vendor key bleed into ANTHROPIC_AUTH_TOKEN — the OAuth + path uses a totally different token and projection would only cause + confusion (and a confusing audit-log line). + """ + _clear_all_auth_env(monkeypatch, adapter_module) + monkeypatch.setenv("MINIMAX_API_KEY", _SENTINEL) + monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "oauth-token") + + adapter_module._project_vendor_auth(_oauth_provider()) + + import os + assert os.environ.get("ANTHROPIC_AUTH_TOKEN") is None, ( + "first-party provider (oauth) must not consume vendor-specific " + "env keys — projection should be a no-op for non-third-party paths" + ) + + +def test_projection_logs_name_not_value(adapter_module, monkeypatch, caplog): + """The secret value must NEVER appear in any log record. + + Mirrors the safety guarantee on _audit_auth_env_presence (pinned by + test_adapter_logging.py::test_audit_lists_every_name_with_presence). + Same threat model: docker logs + central log aggregator must not + leak the bearer token. + """ + _clear_all_auth_env(monkeypatch, adapter_module) + monkeypatch.setenv("MINIMAX_API_KEY", _SENTINEL) + + with caplog.at_level(logging.INFO, logger="adapter"): + adapter_module._project_vendor_auth(_minimax_provider()) + + # The projection happened (precondition for the leak check to be meaningful). + import os + assert os.environ.get("ANTHROPIC_AUTH_TOKEN") == _SENTINEL + + for record in caplog.records: + msg = record.getMessage() + assert _SENTINEL not in msg, ( + f"projection logged the secret VALUE: {msg!r} — must log the " + "env NAME only (mirrors _audit_auth_env_presence contract)" + ) + + # Sanity: at least one log record mentioned the projection by NAME. + assert any( + "MINIMAX_API_KEY" in r.getMessage() and "ANTHROPIC_AUTH_TOKEN" in r.getMessage() + for r in caplog.records + ), "expected an INFO log line documenting the MINIMAX_API_KEY -> ANTHROPIC_AUTH_TOKEN projection" + + +def test_empty_vendor_key_treated_as_unset(adapter_module, monkeypatch): + """Empty-string vendor env doesn't trigger projection. + + workspace-server's nil/empty handling can plausibly export + MINIMAX_API_KEY="" instead of omitting it (matches the audit + helper's empty-string handling — see test_adapter_logging.py). + Projecting an empty string would silently corrupt + ANTHROPIC_AUTH_TOKEN and turn a missing-key error into a 401 with + no diagnostic trail. + """ + _clear_all_auth_env(monkeypatch, adapter_module) + monkeypatch.setenv("MINIMAX_API_KEY", "") + + adapter_module._project_vendor_auth(_minimax_provider()) + + import os + assert os.environ.get("ANTHROPIC_AUTH_TOKEN") is None, ( + "empty-string vendor env must not trigger projection — the right " + "failure mode is the existing 'no auth env set' warning, not a " + "silently-projected empty bearer token" + ) + + +def test_glm_kimi_deepseek_also_project(adapter_module, monkeypatch): + """The other three vendor names project too — not just MiniMax. + + Parametrize-style coverage in one test so a future contributor adding + a new vendor sees the pattern in one place. Each iteration uses an + isolated provider dict + a freshly-cleared env. + """ + cases = [ + ("zai", "GLM_API_KEY"), + ("moonshot", "KIMI_API_KEY"), + ("deepseek", "DEEPSEEK_API_KEY"), + ] + for provider_name, env_name in cases: + _clear_all_auth_env(monkeypatch, adapter_module) + sentinel = f"{env_name}-sentinel" + monkeypatch.setenv(env_name, sentinel) + provider = { + "name": provider_name, + "auth_mode": "third_party_anthropic_compat", + "model_prefixes": (), + "model_aliases": (), + "base_url": "https://example.invalid/anthropic", + "auth_env": (env_name, "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"), + } + + adapter_module._project_vendor_auth(provider) + + import os + assert os.environ.get("ANTHROPIC_AUTH_TOKEN") == sentinel, ( + f"{env_name} must project onto ANTHROPIC_AUTH_TOKEN for " + f"provider={provider_name}" + )