molecule-ai-workspace-templ.../tests/test_provider_routing.py
Hongming Wang b9a1fa1b1f
Some checks failed
CI / validate (push) Failing after 0s
CI / Adapter unit tests (push) Failing after 6s
feat: per-vendor env routing for third-party providers (task #244)
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>
2026-05-02 22:20:03 -07:00

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}"
)