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>
This commit is contained in:
parent
c09a268ae4
commit
b9a1fa1b1f
66
adapter.py
66
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", "<unknown>"),
|
||||
)
|
||||
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
|
||||
|
||||
55
config.yaml
55
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
|
||||
|
||||
244
tests/test_provider_routing.py
Normal file
244
tests/test_provider_routing.py
Normal file
@ -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}"
|
||||
)
|
||||
Loading…
Reference in New Issue
Block a user