Move the model→endpoint→auth-env mapping out of hardcoded constants in adapter.py + entrypoint.sh into a single `providers:` list at the top of config.yaml. The adapter loads it at boot via _load_providers; canvas Config tab will read the same YAML for its Provider dropdown so UI and adapter never disagree on what's available. Adding a new provider becomes a one-line YAML edit — no Python or shell changes. Includes 5 third-party providers ready out of the box (Anthropic-compat endpoints, Bearer-style ANTHROPIC_AUTH_TOKEN OR ANTHROPIC_API_KEY auth): xiaomi-mimo https://api.xiaomimimo.com/anthropic minimax https://api.minimax.io/anthropic zai https://api.z.ai/api/anthropic (NEW) moonshot https://api.moonshot.ai/anthropic (NEW) deepseek https://api.deepseek.com/anthropic (NEW) Plus 7 new model entries in runtime_config.models (mimo-v2.5, MiniMax-M2, MiniMax-M2.7, GLM-4.6, GLM-4.5, kimi-k2.5, kimi-k2, deepseek-v4-pro, deepseek-v4-flash) so they show up in the Canvas Config dropdown. Operator override unchanged: ANTHROPIC_BASE_URL set as a workspace secret still wins over the registry default — the escape hatch for regional endpoints (Xiaomi token-plan-sgp, MiniMax api.minimaxi.com). entrypoint.sh: drops the `mimo-*` case mapping (adapter handles routing now). _BUILTIN_PROVIDERS retained as malformed-YAML fallback so a bare-bones workspace still boots with oauth + anthropic-api defaults. Tests: 25 passing. New coverage: - YAML parses + normalizes to expected shape - Malformed YAML falls back to builtins (warning, not raise) - Each new provider routes its model id to the right base_url - ANTHROPIC_AUTH_TOKEN alone satisfies third-party auth check - Operator-set ANTHROPIC_BASE_URL overrides registry default - Case-insensitive prefix match (MiniMax-M2 / minimax-m2.7 / GLM-4.6) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
646 lines
24 KiB
Python
646 lines
24 KiB
Python
"""Unit tests for ClaudeCodeAdapter.setup + create_executor.
|
|
|
|
Two surfaces under test:
|
|
1. setup() — provider-registry loading + auth-env validation +
|
|
base_url resolution. Pins the post-2026-04-30 architecture where
|
|
the model→provider mapping lives in /configs/config.yaml's
|
|
`providers:` list (canonical) with `_BUILTIN_PROVIDERS` as the
|
|
malformed-YAML fallback.
|
|
2. create_executor() — the 2026-04-30 hang fix (custom upstream + no
|
|
model = raise instead of silently passing 'sonnet' to the SDK).
|
|
|
|
These tests stub the import dependencies (molecule_runtime, a2a,
|
|
claude_sdk_executor) so they can run without the real packages installed.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import textwrap
|
|
import types
|
|
from dataclasses import dataclass
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
|
|
# ---- Test scaffolding ----
|
|
#
|
|
# adapter.py imports at module load:
|
|
# - molecule_runtime.adapters.base (BaseAdapter, AdapterConfig, RuntimeCapabilities)
|
|
# - a2a.server.agent_execution (AgentExecutor)
|
|
# create_executor lazily imports claude_sdk_executor.ClaudeSDKExecutor.
|
|
# We stub all four so the test file can run in CI without those packages
|
|
# installed. The pre-validation branches we care about run BEFORE the
|
|
# executor instantiates, so the stub doesn't affect what we're testing.
|
|
|
|
|
|
@dataclass
|
|
class _StubRuntimeCapabilities:
|
|
provides_native_session: bool = False
|
|
|
|
|
|
@dataclass
|
|
class _StubAdapterConfig:
|
|
runtime_config: object = None
|
|
config_path: str = "/tmp/configs"
|
|
system_prompt: str = ""
|
|
heartbeat: object = None
|
|
|
|
|
|
class _StubBaseAdapter:
|
|
async def install_plugins_via_registry(self, *_args, **_kwargs):
|
|
pass
|
|
|
|
|
|
def _install_stubs():
|
|
"""Install the smallest set of import shims that adapter.py needs."""
|
|
if "molecule_runtime" not in sys.modules:
|
|
mr = types.ModuleType("molecule_runtime")
|
|
mr.adapters = types.ModuleType("molecule_runtime.adapters")
|
|
mr.adapters.base = types.ModuleType("molecule_runtime.adapters.base")
|
|
mr.adapters.base.BaseAdapter = _StubBaseAdapter
|
|
mr.adapters.base.AdapterConfig = _StubAdapterConfig
|
|
mr.adapters.base.RuntimeCapabilities = _StubRuntimeCapabilities
|
|
# adapter.setup() lazy-imports molecule_runtime.plugins.load_plugins.
|
|
# Stub it as a no-op returning [] so setup() pass-paths run cleanly
|
|
# without needing the real runtime installed in the test env.
|
|
mr.plugins = types.ModuleType("molecule_runtime.plugins")
|
|
mr.plugins.load_plugins = lambda **_kwargs: []
|
|
sys.modules["molecule_runtime"] = mr
|
|
sys.modules["molecule_runtime.adapters"] = mr.adapters
|
|
sys.modules["molecule_runtime.adapters.base"] = mr.adapters.base
|
|
sys.modules["molecule_runtime.plugins"] = mr.plugins
|
|
if "a2a" not in sys.modules:
|
|
a2a = types.ModuleType("a2a")
|
|
a2a.server = types.ModuleType("a2a.server")
|
|
a2a.server.agent_execution = types.ModuleType("a2a.server.agent_execution")
|
|
a2a.server.agent_execution.AgentExecutor = type("AgentExecutor", (), {})
|
|
sys.modules["a2a"] = a2a
|
|
sys.modules["a2a.server"] = a2a.server
|
|
sys.modules["a2a.server.agent_execution"] = a2a.server.agent_execution
|
|
if "claude_sdk_executor" not in sys.modules:
|
|
mod = types.ModuleType("claude_sdk_executor")
|
|
mod.ClaudeSDKExecutor = MagicMock(name="ClaudeSDKExecutor")
|
|
sys.modules["claude_sdk_executor"] = mod
|
|
|
|
|
|
# ---- Fixtures ----
|
|
|
|
|
|
# Canonical provider registry used by most setup() tests. Mirrors the
|
|
# real config.yaml's `providers:` list — kept inline here so a config.yaml
|
|
# rename/edit doesn't silently change test semantics. If the prod
|
|
# registry ever drifts from this fixture, the divergence is intentional
|
|
# and visible in the diff.
|
|
_FIXTURE_PROVIDERS_YAML = textwrap.dedent("""
|
|
providers:
|
|
- name: anthropic-oauth
|
|
auth_mode: oauth
|
|
model_prefixes: []
|
|
model_aliases: [sonnet, opus, haiku]
|
|
base_url: null
|
|
auth_env: [CLAUDE_CODE_OAUTH_TOKEN]
|
|
|
|
- name: anthropic-api
|
|
auth_mode: anthropic_api
|
|
model_prefixes: [claude-]
|
|
model_aliases: []
|
|
base_url: null
|
|
auth_env: [ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN]
|
|
|
|
- name: xiaomi-mimo
|
|
auth_mode: third_party_anthropic_compat
|
|
model_prefixes: [mimo-]
|
|
model_aliases: []
|
|
base_url: https://api.xiaomimimo.com/anthropic
|
|
auth_env: [ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY]
|
|
|
|
- name: minimax
|
|
auth_mode: third_party_anthropic_compat
|
|
model_prefixes: [minimax-]
|
|
model_aliases: []
|
|
base_url: https://api.minimax.io/anthropic
|
|
auth_env: [ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY]
|
|
|
|
- name: zai
|
|
auth_mode: third_party_anthropic_compat
|
|
model_prefixes: [glm-]
|
|
model_aliases: []
|
|
base_url: https://api.z.ai/api/anthropic
|
|
auth_env: [ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY]
|
|
|
|
- name: moonshot
|
|
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]
|
|
|
|
- name: deepseek
|
|
auth_mode: third_party_anthropic_compat
|
|
model_prefixes: [deepseek-]
|
|
model_aliases: []
|
|
base_url: https://api.deepseek.com/anthropic
|
|
auth_env: [ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY]
|
|
""")
|
|
|
|
|
|
@pytest.fixture
|
|
def adapter(monkeypatch):
|
|
"""Fresh ClaudeCodeAdapter with all imports stubbed."""
|
|
_install_stubs()
|
|
# adapter.py lives in the parent dir. tests/ has no __init__.py
|
|
# because the template directory itself is a Python package
|
|
# (production runtime imports it via the platform's adapter loader),
|
|
# and adding tests/__init__.py would re-expose the same relative-
|
|
# import collection problem we sidestepped by isolating tests here.
|
|
parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
if parent_dir not in sys.path:
|
|
sys.path.insert(0, parent_dir)
|
|
# Strip any cached module so the stubbed sys.modules entries take effect.
|
|
sys.modules.pop("adapter", None)
|
|
import adapter as adapter_module # noqa: WPS433
|
|
return adapter_module.ClaudeCodeAdapter()
|
|
|
|
|
|
@pytest.fixture
|
|
def configs_dir(tmp_path):
|
|
"""Per-test /configs dir with the canonical provider registry written to
|
|
config.yaml. Tests pass the path as ``config_path`` on _StubAdapterConfig
|
|
so adapter.setup() reads our fixture rather than the host's real
|
|
/configs/config.yaml (which doesn't exist in CI).
|
|
"""
|
|
cfg = tmp_path / "config.yaml"
|
|
cfg.write_text(_FIXTURE_PROVIDERS_YAML)
|
|
return str(tmp_path)
|
|
|
|
|
|
@pytest.fixture
|
|
def empty_configs_dir(tmp_path):
|
|
"""A /configs dir with no config.yaml — exercises the FileNotFoundError
|
|
fallback path in _load_providers (must yield _BUILTIN_PROVIDERS).
|
|
"""
|
|
return str(tmp_path)
|
|
|
|
|
|
# ---- create_executor pre-validation tests ----
|
|
#
|
|
# These exercise the 2026-04-30 hang-fix branch: ANTHROPIC_BASE_URL
|
|
# pointed at a non-Anthropic shim with no model picked silently passes
|
|
# 'sonnet' to the SDK, which hangs for 30s on the --print probe. The
|
|
# adapter raises early instead.
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_executor_raises_when_custom_base_url_and_no_model(
|
|
adapter, monkeypatch
|
|
):
|
|
"""The 2026-04-30 incident shape: custom upstream + no explicit model.
|
|
|
|
Adapter must raise ValueError with an actionable message instead of
|
|
silently passing 'sonnet' to ClaudeSDKExecutor (which would hang
|
|
for 30s on the SDK probe before timing out).
|
|
"""
|
|
monkeypatch.setenv(
|
|
"ANTHROPIC_BASE_URL", "https://api.xiaomimimo.com/anthropic"
|
|
)
|
|
cfg = _StubAdapterConfig(runtime_config={"model": ""})
|
|
|
|
with pytest.raises(ValueError) as exc_info:
|
|
await adapter.create_executor(cfg)
|
|
|
|
msg = str(exc_info.value)
|
|
assert "ANTHROPIC_BASE_URL" in msg
|
|
assert "api.xiaomimimo.com" in msg
|
|
assert "MODEL_PROVIDER" in msg or "runtime_config.model" in msg
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_executor_passes_when_anthropic_native_and_no_model(
|
|
adapter, monkeypatch
|
|
):
|
|
"""Anthropic-native users with no model picked still get the 'sonnet'
|
|
fallback — that's correct behavior, never an error. The pre-validation
|
|
only fires on non-Anthropic hosts.
|
|
"""
|
|
monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://api.anthropic.com")
|
|
cfg = _StubAdapterConfig(runtime_config={"model": ""})
|
|
|
|
# Should not raise — fallback to "sonnet" is the documented default.
|
|
executor = await adapter.create_executor(cfg)
|
|
assert executor is not None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_executor_passes_when_no_base_url_set(adapter, monkeypatch):
|
|
"""No ANTHROPIC_BASE_URL = SDK uses its built-in Anthropic default.
|
|
That's the historical happy path. Pre-validation must not regress it.
|
|
"""
|
|
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
|
|
cfg = _StubAdapterConfig(runtime_config={"model": ""})
|
|
|
|
executor = await adapter.create_executor(cfg)
|
|
assert executor is not None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_executor_passes_when_custom_base_url_with_explicit_model(
|
|
adapter, monkeypatch
|
|
):
|
|
"""The fix the user is supposed to apply: set both URL and model.
|
|
Pre-validation must let this through cleanly. End-to-end success path
|
|
for the MiniMax-shim use case after Option B PRs land.
|
|
"""
|
|
monkeypatch.setenv(
|
|
"ANTHROPIC_BASE_URL", "https://api.xiaomimimo.com/anthropic"
|
|
)
|
|
cfg = _StubAdapterConfig(
|
|
runtime_config={"model": "MiniMax-M2"}
|
|
)
|
|
|
|
executor = await adapter.create_executor(cfg)
|
|
assert executor is not None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_executor_passes_dataclass_runtime_config(adapter, monkeypatch):
|
|
"""runtime_config can arrive as a dataclass (the production shape via
|
|
main.py's load_config) instead of a dict. The defensive read at line
|
|
118-122 must work for both. Regression coverage for the read path.
|
|
"""
|
|
monkeypatch.setenv(
|
|
"ANTHROPIC_BASE_URL", "https://api.xiaomimimo.com/anthropic"
|
|
)
|
|
|
|
@dataclass
|
|
class _RC:
|
|
model: str = "MiniMax-M2"
|
|
provider: str = "minimax"
|
|
|
|
cfg = _StubAdapterConfig(runtime_config=_RC())
|
|
executor = await adapter.create_executor(cfg)
|
|
assert executor is not None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_executor_raises_when_dataclass_runtime_config_empty_model(
|
|
adapter, monkeypatch
|
|
):
|
|
"""Dataclass shape with empty model triggers the same validation as
|
|
dict shape with empty model. Symmetric behavior across both inputs.
|
|
"""
|
|
monkeypatch.setenv(
|
|
"ANTHROPIC_BASE_URL", "https://api.xiaomimimo.com/anthropic"
|
|
)
|
|
|
|
@dataclass
|
|
class _RC:
|
|
model: str = ""
|
|
provider: str = ""
|
|
|
|
cfg = _StubAdapterConfig(runtime_config=_RC())
|
|
|
|
with pytest.raises(ValueError):
|
|
await adapter.create_executor(cfg)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_executor_passes_when_unparseable_url(adapter, monkeypatch):
|
|
"""An unparseable URL value (no host extractable) shouldn't crash
|
|
with AttributeError. Should still pass through to the SDK so the
|
|
SDK gets to error on it itself — adapter doesn't take ownership
|
|
of URL validation, just the missing-model invariant.
|
|
"""
|
|
monkeypatch.setenv("ANTHROPIC_BASE_URL", "://garbage")
|
|
cfg = _StubAdapterConfig(runtime_config={"model": ""})
|
|
|
|
# Empty hostname → pre-validation skips → reaches SDK with "sonnet"
|
|
# fallback. The SDK will fail; that's not the adapter's job.
|
|
executor = await adapter.create_executor(cfg)
|
|
assert executor is not None
|
|
|
|
|
|
# ---- setup() provider-registry tests ----
|
|
#
|
|
# Symmetric to create_executor's pre-validate: setup() raises on the
|
|
# inverse misconfig (third-party MODEL picked but ANTHROPIC_BASE_URL
|
|
# unset and the resolved provider has no default base_url). Both
|
|
# produce "boots but every LLM call fails" if not caught; raising at
|
|
# boot keeps the workspace from entering "online" status with
|
|
# structurally-broken auth.
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_setup_passes_when_third_party_model_with_registered_base_url(
|
|
adapter, monkeypatch, configs_dir
|
|
):
|
|
"""Third-party model + provider has default base_url in YAML →
|
|
setup() auto-applies it (no operator URL needed) and runs cleanly
|
|
through to plugin install. The Option B v2 happy path: pick mimo-
|
|
or minimax- model in canvas, the registry handles routing.
|
|
"""
|
|
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
|
|
cfg = _StubAdapterConfig(
|
|
runtime_config={"model": "mimo-v2-flash"}, config_path=configs_dir
|
|
)
|
|
|
|
await adapter.setup(cfg)
|
|
|
|
# Registry-default base_url should now be in env for the SDK to pick up.
|
|
assert os.environ.get("ANTHROPIC_BASE_URL") == "https://api.xiaomimimo.com/anthropic"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_setup_passes_for_minimax_model(adapter, monkeypatch, configs_dir):
|
|
"""MiniMax-M2 resolves to the minimax provider, auto-sets the MiniMax
|
|
Anthropic-compat endpoint. Verifies registry adds new providers
|
|
without code changes — the original motivation for the YAML registry.
|
|
"""
|
|
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
|
|
cfg = _StubAdapterConfig(
|
|
runtime_config={"model": "MiniMax-M2"}, config_path=configs_dir
|
|
)
|
|
|
|
await adapter.setup(cfg)
|
|
|
|
assert os.environ.get("ANTHROPIC_BASE_URL") == "https://api.minimax.io/anthropic"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_setup_minimax_case_insensitive_match(
|
|
adapter, monkeypatch, configs_dir
|
|
):
|
|
"""MiniMax docs use mixed-case ids (MiniMax-M2.7); some operators may
|
|
type minimax-m2.7. Both must resolve to the same provider — registry
|
|
matches lowercased prefixes.
|
|
"""
|
|
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
|
|
cfg = _StubAdapterConfig(
|
|
runtime_config={"model": "minimax-m2.7"}, config_path=configs_dir
|
|
)
|
|
|
|
await adapter.setup(cfg)
|
|
|
|
assert os.environ.get("ANTHROPIC_BASE_URL") == "https://api.minimax.io/anthropic"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_setup_operator_base_url_overrides_registry_default(
|
|
adapter, monkeypatch, configs_dir
|
|
):
|
|
"""Operator-set ANTHROPIC_BASE_URL wins over the provider's default —
|
|
escape hatch for regional endpoints (Xiaomi token-plan-sgp.*,
|
|
MiniMax api.minimaxi.com China endpoint). Pinning this so a future
|
|
refactor can't quietly clobber the override.
|
|
"""
|
|
monkeypatch.setenv(
|
|
"ANTHROPIC_BASE_URL",
|
|
"https://token-plan-sgp.xiaomimimo.com/anthropic",
|
|
)
|
|
cfg = _StubAdapterConfig(
|
|
runtime_config={"model": "mimo-v2-flash"}, config_path=configs_dir
|
|
)
|
|
|
|
await adapter.setup(cfg)
|
|
|
|
# Operator value untouched — adapter must not overwrite.
|
|
assert (
|
|
os.environ.get("ANTHROPIC_BASE_URL")
|
|
== "https://token-plan-sgp.xiaomimimo.com/anthropic"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_setup_passes_when_oauth_model_no_base_url(
|
|
adapter, monkeypatch, configs_dir
|
|
):
|
|
"""OAuth-aliased models (sonnet/opus/haiku) are Anthropic-native; no
|
|
base URL is required. setup() must not raise on the OAuth path even
|
|
though base_url is unset — that's the historical happy path.
|
|
"""
|
|
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
|
|
cfg = _StubAdapterConfig(
|
|
runtime_config={"model": "sonnet"}, config_path=configs_dir
|
|
)
|
|
|
|
await adapter.setup(cfg)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_setup_passes_when_anthropic_api_model_no_base_url(
|
|
adapter, monkeypatch, configs_dir
|
|
):
|
|
"""claude-* versioned ids are Anthropic API-key path; base URL
|
|
optional (defaults to api.anthropic.com). setup() must not raise.
|
|
"""
|
|
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
|
|
cfg = _StubAdapterConfig(
|
|
runtime_config={"model": "claude-sonnet-4-6"},
|
|
config_path=configs_dir,
|
|
)
|
|
|
|
await adapter.setup(cfg)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_setup_falls_back_to_builtin_when_yaml_missing(
|
|
adapter, monkeypatch, empty_configs_dir
|
|
):
|
|
"""No config.yaml in the configs dir → _load_providers falls back to
|
|
_BUILTIN_PROVIDERS (oauth + anthropic-api only). OAuth-aliased models
|
|
must still resolve cleanly so a bare-bones workspace boots even if
|
|
config.yaml is missing or malformed.
|
|
"""
|
|
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
|
|
cfg = _StubAdapterConfig(
|
|
runtime_config={"model": "sonnet"}, config_path=empty_configs_dir
|
|
)
|
|
|
|
await adapter.setup(cfg)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_setup_raises_when_yaml_missing_and_third_party_model(
|
|
adapter, monkeypatch, empty_configs_dir
|
|
):
|
|
"""No config.yaml + third-party model picked → builtin registry has no
|
|
matching prefix → resolves to the OAuth fallback (provider[0]). The
|
|
user picked a model the builtin can't route, so OAuth's auth_env
|
|
won't have the right key, but it won't raise here — auth check is
|
|
a warning, not an error. setup() should complete (no third-party
|
|
misconfig fires because the fallback isn't third-party).
|
|
|
|
Documented behavior: when YAML is missing, third-party models are
|
|
silently downgraded to OAuth fallback. Operators must fix their
|
|
config.yaml to get correct routing. This test pins that the failure
|
|
mode is "warning + boots" rather than "raises" (helps debug-vs-recover
|
|
triage when CI loses the YAML somehow).
|
|
"""
|
|
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
|
|
cfg = _StubAdapterConfig(
|
|
runtime_config={"model": "mimo-v2-flash"}, config_path=empty_configs_dir
|
|
)
|
|
|
|
# No raise — falls back to OAuth provider, third-party gate doesn't fire.
|
|
await adapter.setup(cfg)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_setup_auth_token_alone_satisfies_third_party_check(
|
|
adapter, monkeypatch, configs_dir, caplog
|
|
):
|
|
"""MiniMax docs prefer ANTHROPIC_AUTH_TOKEN over ANTHROPIC_API_KEY.
|
|
The provider entry lists both as accepted; setting only AUTH_TOKEN
|
|
must NOT trigger the "no auth env set" warning.
|
|
"""
|
|
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
|
|
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
|
monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "test-minimax-token")
|
|
cfg = _StubAdapterConfig(
|
|
runtime_config={"model": "MiniMax-M2"}, config_path=configs_dir
|
|
)
|
|
|
|
import logging
|
|
with caplog.at_level(logging.WARNING):
|
|
await adapter.setup(cfg)
|
|
|
|
auth_warnings = [r for r in caplog.records if "AuthenticationError" in r.getMessage()]
|
|
assert auth_warnings == [], (
|
|
"ANTHROPIC_AUTH_TOKEN alone should satisfy minimax provider auth "
|
|
"but adapter logged a missing-auth warning anyway"
|
|
)
|
|
|
|
|
|
# ---- _load_providers / _resolve_provider unit tests ----
|
|
|
|
|
|
def test_load_providers_returns_builtin_when_yaml_missing(tmp_path):
|
|
"""FileNotFoundError path returns the in-code defaults verbatim."""
|
|
_install_stubs()
|
|
parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
if parent_dir not in sys.path:
|
|
sys.path.insert(0, parent_dir)
|
|
sys.modules.pop("adapter", None)
|
|
import adapter as adapter_module
|
|
|
|
result = adapter_module._load_providers(str(tmp_path))
|
|
assert result == adapter_module._BUILTIN_PROVIDERS
|
|
|
|
|
|
def test_load_providers_parses_yaml_and_normalizes(tmp_path):
|
|
"""YAML present + parses → normalized tuple of provider dicts."""
|
|
_install_stubs()
|
|
parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
if parent_dir not in sys.path:
|
|
sys.path.insert(0, parent_dir)
|
|
sys.modules.pop("adapter", None)
|
|
import adapter as adapter_module
|
|
|
|
(tmp_path / "config.yaml").write_text(_FIXTURE_PROVIDERS_YAML)
|
|
result = adapter_module._load_providers(str(tmp_path))
|
|
|
|
assert len(result) == 7
|
|
names = [p["name"] for p in result]
|
|
assert names == [
|
|
"anthropic-oauth", "anthropic-api", "xiaomi-mimo", "minimax",
|
|
"zai", "moonshot", "deepseek",
|
|
]
|
|
# YAML lists must be normalized to tuples for downstream lookup ergonomics.
|
|
assert isinstance(result[0]["model_aliases"], tuple)
|
|
assert isinstance(result[2]["model_prefixes"], tuple)
|
|
|
|
|
|
@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"),
|
|
("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.
|
|
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.
|
|
"""
|
|
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
|
|
cfg = _StubAdapterConfig(
|
|
runtime_config={"model": model}, config_path=configs_dir
|
|
)
|
|
|
|
await adapter.setup(cfg)
|
|
|
|
assert os.environ.get("ANTHROPIC_BASE_URL") == expected_url
|
|
|
|
|
|
def test_load_providers_falls_back_on_malformed_yaml(tmp_path, caplog):
|
|
"""Malformed YAML → log warning + fallback (don't kill boot)."""
|
|
_install_stubs()
|
|
parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
if parent_dir not in sys.path:
|
|
sys.path.insert(0, parent_dir)
|
|
sys.modules.pop("adapter", None)
|
|
import adapter as adapter_module
|
|
|
|
(tmp_path / "config.yaml").write_text("providers: [not valid yaml: {{{")
|
|
|
|
import logging
|
|
with caplog.at_level(logging.WARNING):
|
|
result = adapter_module._load_providers(str(tmp_path))
|
|
|
|
assert result == adapter_module._BUILTIN_PROVIDERS
|
|
|
|
|
|
def test_resolve_provider_minimax_prefix_matches_minimax_provider():
|
|
"""The headline routing test: MiniMax-M2 lands on the minimax entry."""
|
|
_install_stubs()
|
|
parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
if parent_dir not in sys.path:
|
|
sys.path.insert(0, parent_dir)
|
|
sys.modules.pop("adapter", None)
|
|
import adapter as adapter_module
|
|
|
|
providers = tuple(
|
|
adapter_module._normalize_provider(p) for p in [
|
|
{"name": "anthropic-oauth", "auth_mode": "oauth",
|
|
"model_aliases": ["sonnet"], "auth_env": ["CLAUDE_CODE_OAUTH_TOKEN"]},
|
|
{"name": "minimax", "auth_mode": "third_party_anthropic_compat",
|
|
"model_prefixes": ["minimax-"],
|
|
"base_url": "https://api.minimax.io/anthropic",
|
|
"auth_env": ["ANTHROPIC_AUTH_TOKEN"]},
|
|
]
|
|
)
|
|
|
|
result = adapter_module._resolve_provider("MiniMax-M2", providers)
|
|
assert result["name"] == "minimax"
|
|
|
|
# Case insensitivity also exercised.
|
|
result2 = adapter_module._resolve_provider("minimax-m2.7", providers)
|
|
assert result2["name"] == "minimax"
|
|
|
|
|
|
def test_resolve_provider_falls_back_to_first_when_unknown():
|
|
"""Unknown model id → fallback to first provider (OAuth by convention)."""
|
|
_install_stubs()
|
|
parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
if parent_dir not in sys.path:
|
|
sys.path.insert(0, parent_dir)
|
|
sys.modules.pop("adapter", None)
|
|
import adapter as adapter_module
|
|
|
|
providers = tuple(
|
|
adapter_module._normalize_provider(p) for p in [
|
|
{"name": "anthropic-oauth", "auth_mode": "oauth",
|
|
"auth_env": ["CLAUDE_CODE_OAUTH_TOKEN"]},
|
|
{"name": "minimax", "auth_mode": "third_party_anthropic_compat",
|
|
"model_prefixes": ["minimax-"],
|
|
"auth_env": ["ANTHROPIC_AUTH_TOKEN"]},
|
|
]
|
|
)
|
|
|
|
result = adapter_module._resolve_provider("some-unknown-model", providers)
|
|
assert result["name"] == "anthropic-oauth"
|