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) <noreply@anthropic.com>
245 lines
9.6 KiB
Python
245 lines
9.6 KiB
Python
"""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}"
|
|
)
|