diff --git a/adapter.py b/adapter.py index 6b871c7..dc38ab9 100644 --- a/adapter.py +++ b/adapter.py @@ -15,46 +15,104 @@ logger = logging.getLogger(__name__) # the workspace by polling /transcript?limit=999999. _TRANSCRIPT_MAX_LIMIT = 1000 -# Auth-mode classification for a selected model id. The Claude Code CLI -# accepts three auth paths and the right env var differs per path; warning -# at boot about the wrong var (the pre-multi-provider behavior) misled -# operators who picked an API-key or third-party model. New third-party -# providers add a prefix → mode entry below + a model-prefix → base-URL -# mapping in entrypoint.sh until the data-driven `runtime_env` schema -# field lands platform-side. +# Auth-mode constants — provider entries use one of these strings. +# Drives validation behavior in setup() (third-party requires base_url +# resolution; oauth/anthropic-api leave base_url=None for CLI defaults). _AUTH_MODE_OAUTH = "oauth" _AUTH_MODE_ANTHROPIC_API = "anthropic_api" _AUTH_MODE_THIRD_PARTY = "third_party_anthropic_compat" -_THIRD_PARTY_PREFIXES = ("mimo-",) -_OAUTH_ALIASES = frozenset({"sonnet", "opus", "haiku"}) +# Built-in provider registry — used as a fallback when /configs/config.yaml +# doesn't define `providers:`. The canonical registry is the YAML file: it +# becomes the single source of truth read by both this adapter (for boot-time +# routing) and the canvas Config tab (Provider dropdown). Adding a new +# provider should be a one-line YAML edit, not a code change. This builtin +# exists so a workspace with a malformed/missing config.yaml still boots +# with sensible defaults instead of failing. +_BUILTIN_PROVIDERS = ( + { + "name": "anthropic-oauth", + "auth_mode": _AUTH_MODE_OAUTH, + "model_prefixes": (), + "model_aliases": ("sonnet", "opus", "haiku"), + "base_url": None, + "auth_env": ("CLAUDE_CODE_OAUTH_TOKEN",), + }, + { + "name": "anthropic-api", + "auth_mode": _AUTH_MODE_ANTHROPIC_API, + "model_prefixes": ("claude-",), + "model_aliases": (), + "base_url": None, + "auth_env": ("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"), + }, +) -def _detect_auth_mode(model: str) -> str: - """Classify the picked model into one of three auth paths. +def _normalize_provider(entry: dict) -> dict: + """Coerce a YAML-loaded provider dict into the shape adapter logic expects. - Used by setup() to validate the right env var is set so operators see - the misconfiguration at boot instead of on the first LLM call. - Unknown ids default to OAuth — the historical default and the safest - fallback for the warning path. + YAML gives us lists (not tuples) and may omit optional keys. Normalize + to the union of all fields so downstream lookups work without scattered + .get(...) calls. """ + return { + "name": entry.get("name") or "", + "auth_mode": entry.get("auth_mode") or _AUTH_MODE_OAUTH, + "model_prefixes": tuple(p.lower() for p in entry.get("model_prefixes") or ()), + "model_aliases": tuple(a.lower() for a in entry.get("model_aliases") or ()), + "base_url": entry.get("base_url") or None, + "auth_env": tuple(entry.get("auth_env") or ()), + } + + +def _load_providers(config_path: str) -> tuple: + """Load the provider registry from /configs/config.yaml. + + The YAML's top-level ``providers:`` list is the canonical source — + canvas Config tab reads the same list to populate its Provider + dropdown so the UI and the adapter never disagree on what's + available. Falls back to ``_BUILTIN_PROVIDERS`` (oauth + anthropic-api) + if the file is missing, malformed, or has no providers section, so a + bare-bones workspace still boots with the historical defaults. + + Mode mismatches (e.g. a provider entry without a name) are logged + but don't fail the load — better-something-than-nothing for boot. + """ + yaml_path = os.path.join(config_path, "config.yaml") + try: + import yaml # transitive dep via molecule-ai-workspace-runtime + with open(yaml_path, "r") as f: + data = yaml.safe_load(f) or {} + raw = data.get("providers") + if isinstance(raw, list) and raw: + return tuple(_normalize_provider(p) for p in raw if isinstance(p, dict)) + except FileNotFoundError: + logger.info("providers: %s not found, using builtin defaults", yaml_path) + except Exception as exc: # noqa: BLE001 — defensive: never block boot on YAML + logger.warning("providers: failed to load from %s (%s); using builtins", yaml_path, exc) + return _BUILTIN_PROVIDERS + + +def _resolve_provider(model: str, providers: tuple) -> dict: + """Return the provider entry matching this model id. + + Match is case-insensitive: prefix wins over alias when both could + apply. Unknown ids fall back to the first provider in the registry + (by convention, the OAuth/safest default). + """ + fallback = providers[0] if providers else _normalize_provider({}) if not model: - return _AUTH_MODE_OAUTH + return fallback m = model.lower() - if any(m.startswith(p) for p in _THIRD_PARTY_PREFIXES): - return _AUTH_MODE_THIRD_PARTY - if m.startswith("claude-"): - return _AUTH_MODE_ANTHROPIC_API - if m in _OAUTH_ALIASES: - return _AUTH_MODE_OAUTH - return _AUTH_MODE_OAUTH - - -def _required_env_for_mode(mode: str) -> str: - """The env var the claude CLI needs to authenticate for a given mode.""" - if mode == _AUTH_MODE_OAUTH: - return "CLAUDE_CODE_OAUTH_TOKEN" - return "ANTHROPIC_API_KEY" + for provider in providers: + for prefix in provider["model_prefixes"]: + if prefix and m.startswith(prefix): + return provider + for provider in providers: + if m in provider["model_aliases"]: + return provider + return fallback class ClaudeCodeAdapter(BaseAdapter): @@ -136,63 +194,86 @@ class ClaudeCodeAdapter(BaseAdapter): ``CLAUDE.md`` and ``/configs/skills/`` natively, and the default :class:`AgentskillsAdaptor` writes to both. """ - # KI-001 fix, generalized for the three auth paths the CLI supports: - # OAuth (CLAUDE_CODE_OAUTH_TOKEN), Anthropic API (ANTHROPIC_API_KEY), - # and third-party Anthropic-API-compat (ANTHROPIC_API_KEY + provider - # ANTHROPIC_BASE_URL). Detect the path from the picked model so the - # warning targets the *right* env var — the pre-multi-provider code - # always warned about CLAUDE_CODE_OAUTH_TOKEN even when the user had - # legitimately picked an API-key model and set ANTHROPIC_API_KEY. + # Load provider registry from /configs/config.yaml — canvas reads + # the same YAML for its Config-tab Provider dropdown so adapter + + # UI never disagree on what's available. Adding a new provider is + # a one-line YAML edit (no code change in this file or entrypoint.sh). + providers = _load_providers(config.config_path) + + # Resolve the picked model to a provider entry, then drive auth-env + # validation + ANTHROPIC_BASE_URL routing from that single decision. rc = config.runtime_config if isinstance(rc, dict): picked_model = rc.get("model") or "sonnet" else: picked_model = getattr(rc, "model", None) or "sonnet" - auth_mode = _detect_auth_mode(picked_model) - required_var = _required_env_for_mode(auth_mode) + provider = _resolve_provider(picked_model, providers) + auth_env_options = provider["auth_env"] - # Single-line startup banner — operators reading boot logs can see - # which provider path was selected and whether ANTHROPIC_BASE_URL - # (set by entrypoint.sh for third-party mimo-*) took effect. URL is - # logged as host-only; defensive against credential-shaped query - # strings even though base_url shouldn't carry one. - base_url = os.environ.get("ANTHROPIC_BASE_URL") + # 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 + # provider's default base_url is auto-applied so the operator + # picking a provider in the platform UI doesn't *also* have to + # paste a URL. Anthropic-native paths (oauth, anthropic_api) leave + # base_url=None and let the CLI's built-in default take effect. + explicit_base_url = os.environ.get("ANTHROPIC_BASE_URL") + if explicit_base_url: + effective_base_url = explicit_base_url + base_url_source = "operator-override" + elif provider["base_url"]: + os.environ["ANTHROPIC_BASE_URL"] = provider["base_url"] + effective_base_url = provider["base_url"] + base_url_source = f"provider={provider['name']}" + else: + effective_base_url = None + base_url_source = "anthropic-default" + + # Boot banner — operators reading workspace logs see which provider + # was selected, where the URL came from, and which auth env var + # the adapter expects. Cheap diagnostic; cuts root-cause-finding + # time when an LLM call fails downstream. base_url_host = "" - if base_url: + if effective_base_url: try: - base_url_host = urlparse(base_url).netloc or "" + base_url_host = urlparse(effective_base_url).netloc or "" except Exception: base_url_host = "" logger.info( - "Claude Code adapter starting: model=%s auth_mode=%s required_env=%s%s", - picked_model, auth_mode, required_var, - f" base_url_host={base_url_host}" if base_url_host else "", + "Claude Code adapter starting: model=%s provider=%s auth_mode=%s " + "base_url=%s (%s) auth_env=%s", + picked_model, provider["name"], provider["auth_mode"], + base_url_host or "anthropic-default", base_url_source, + "/".join(auth_env_options), ) - if not os.environ.get(required_var): + # Auth check — any of the provider's accepted env vars satisfies. + # Warning (not raise) so a workspace can still boot for non-LLM + # work (terminal, file editing) while the operator sets the key. + if not any(os.environ.get(v) for v in auth_env_options): logger.warning( - "%s is not set for model=%s (auth_mode=%s) — the adapter will fail " - "on the first LLM call with an AuthenticationError. Set the env " - "var or configure the key in your platform workspace settings.", - required_var, picked_model, auth_mode, + "None of %s set for model=%s (provider=%s) — the adapter " + "will fail on the first LLM call with AuthenticationError. " + "Set one of these env vars in workspace secrets.", + "/".join(auth_env_options), picked_model, provider["name"], ) - # Third-party paths additionally need ANTHROPIC_BASE_URL; entrypoint.sh - # sets it for known mimo-* prefixes. Fail fast on the missing-base-URL - # combo — the symptom otherwise is the CLI silently hitting - # api.anthropic.com with a non-Anthropic key, every LLM call 401s, and - # the workspace looks "online" while being structurally broken. - # Symmetric with create_executor's pre-validate raise on the inverse - # combo (URL set, no model picked) — both unrecoverable misconfigs - # that would put the workspace into a "boots but never works" state. - if auth_mode == _AUTH_MODE_THIRD_PARTY and not base_url: + # Third-party providers must end up with a base_url one way or + # another (provider default OR operator override). If neither, the + # CLI silently hits api.anthropic.com with a non-Anthropic key and + # every call 401s — workspace looks "online" but is structurally + # broken. Symmetric with create_executor's pre-validate raise on + # the inverse misconfig. The provider registry guarantees a default + # for every third-party we ship, so this fires only if a future + # provider entry forgets to set base_url. + if (provider["auth_mode"] == _AUTH_MODE_THIRD_PARTY + and not effective_base_url): raise ValueError( - f"claude-code adapter: model={picked_model} is a third-party " - "Anthropic-compat model but ANTHROPIC_BASE_URL is unset. " - "Without it, requests land on api.anthropic.com with a " - "non-Anthropic key and 401 every call. Fix: check " - "entrypoint.sh's model→base-URL mapping for this model " - "prefix, or set ANTHROPIC_BASE_URL as a workspace secret." + f"claude-code adapter: model={picked_model} resolved to " + f"third-party provider={provider['name']} but no " + "ANTHROPIC_BASE_URL is configured (provider has no default " + "and operator didn't set one). Add base_url to the provider " + "entry in adapter.py or set ANTHROPIC_BASE_URL via secrets." ) from molecule_runtime.plugins import load_plugins diff --git a/config.yaml b/config.yaml index f3c18a5..f7558ce 100644 --- a/config.yaml +++ b/config.yaml @@ -4,13 +4,91 @@ description: >- (1) Claude Code subscription via OAuth token (CLAUDE_CODE_OAUTH_TOKEN, obtained from `claude login`), (2) Anthropic API key (ANTHROPIC_API_KEY, pay-as-you-go via console.anthropic.com), or - (3) third-party Anthropic-API-compatible providers (e.g. Xiaomi MiMo) - via ANTHROPIC_API_KEY + provider-specific ANTHROPIC_BASE_URL routing. - The `claude` CLI picks whichever is set; OAuth takes precedence when - multiple are present. + (3) third-party Anthropic-API-compatible providers (e.g. Xiaomi MiMo, + MiniMax) via ANTHROPIC_AUTH_TOKEN/ANTHROPIC_API_KEY + provider-specific + ANTHROPIC_BASE_URL routing. The `claude` CLI picks whichever is set; + OAuth takes precedence when multiple are present. version: 1.0.0 tier: 2 +# Provider registry — single source of truth for model→endpoint→auth +# routing. The adapter reads this list at boot to resolve the picked +# model to a provider, auto-set ANTHROPIC_BASE_URL, and validate the +# right auth env var. The canvas Config tab reads the same list to +# render its Provider dropdown — UI and adapter never disagree on +# what's available. +# +# Adding a new provider = one entry below. No adapter or entrypoint.sh +# code changes needed. Operator override always wins: setting +# ANTHROPIC_BASE_URL as a workspace secret bypasses the auto-routing +# (useful for regional endpoints like Xiaomi's token-plan-sgp.* or +# MiniMax's api.minimaxi.com China endpoint). +# +# Schema per entry: +# name : human-readable label (boot banner + UI dropdown) +# auth_mode : "oauth" | "anthropic_api" | "third_party_anthropic_compat" +# model_prefixes : lowercase model-id prefixes (e.g. ["mimo-", "minimax-"]) +# model_aliases : exact lowercase ids (e.g. ["sonnet", "opus"]) +# base_url : ANTHROPIC_BASE_URL to set; null = CLI default (anthropic-native) +# auth_env : env vars accepted; any one being set satisfies auth +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] + + # Z.ai — GLM family. docs.z.ai/scenario-example/develop-tools/claude. + # Model ids are uppercase (GLM-4.6) but the registry lowercases for + # matching, so the `glm-` prefix catches both `GLM-4.6` and `glm-4.6`. + - 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] + + # Moonshot AI — Kimi family. platform.kimi.ai/docs/guide/agent-support. + - 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] + + # DeepSeek — api-docs.deepseek.com/guides/anthropic_api. Note: their + # endpoint silently maps unknown model ids to deepseek-v4-flash, so a + # typo lands on a working-but-wrong-tier model rather than 400ing. + # Worth flagging in operator-facing docs. + - 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] + runtime: claude-code runtime_config: model: sonnet @@ -45,13 +123,11 @@ runtime_config: name: Claude Haiku 4.5 (API key / Anthropic Console) required_env: [ANTHROPIC_API_KEY] - # --- Xiaomi MiMo (third-party, Anthropic-API-compatible) — set ANTHROPIC_API_KEY --- - # Routed through https://api.xiaomimimo.com/anthropic via ANTHROPIC_BASE_URL - # (the claude CLI honors the env var natively). Mapping lives in - # entrypoint.sh — when MODEL matches mimo-*, base URL is rewritten before - # the runtime starts. The user's ANTHROPIC_API_KEY here is a Xiaomi key, - # not an Anthropic Console key. Long-term, this should move to a - # data-driven `runtime_env` schema field; tracked separately. + # --- Xiaomi MiMo (third-party, Anthropic-API-compatible) --- + # Routed via the `xiaomi-mimo` provider entry above (base_url and + # auth_env are resolved from the registry — the adapter sets + # ANTHROPIC_BASE_URL automatically based on the model prefix). Either + # ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY satisfies auth — both work. - id: mimo-v2-flash name: Xiaomi MiMo V2 Flash (third-party, Anthropic-API-compatible) required_env: [ANTHROPIC_API_KEY] @@ -61,10 +137,56 @@ runtime_config: - id: mimo-v2-omni name: Xiaomi MiMo V2 Omni (third-party, Anthropic-API-compatible) required_env: [ANTHROPIC_API_KEY] + - id: mimo-v2.5 + name: Xiaomi MiMo V2.5 (third-party, Anthropic-API-compatible) + required_env: [ANTHROPIC_API_KEY] - id: mimo-v2.5-pro name: Xiaomi MiMo V2.5 Pro (third-party, Anthropic-API-compatible) 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). + - id: MiniMax-M2 + name: MiniMax M2 (third-party, Anthropic-API-compatible) + required_env: [ANTHROPIC_AUTH_TOKEN] + - id: MiniMax-M2.7 + name: MiniMax M2.7 (third-party, Anthropic-API-compatible) + required_env: [ANTHROPIC_AUTH_TOKEN] + + # --- 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. + - id: GLM-4.6 + name: Z.ai GLM-4.6 (third-party, Anthropic-API-compatible) + required_env: [ANTHROPIC_AUTH_TOKEN] + - id: GLM-4.5 + name: Z.ai GLM-4.5 (third-party, Anthropic-API-compatible) + required_env: [ANTHROPIC_AUTH_TOKEN] + + # --- 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. + - id: kimi-k2.5 + name: Moonshot Kimi K2.5 (third-party, Anthropic-API-compatible) + required_env: [ANTHROPIC_AUTH_TOKEN] + - id: kimi-k2 + name: Moonshot Kimi K2 (third-party, Anthropic-API-compatible) + required_env: [ANTHROPIC_AUTH_TOKEN] + + # --- 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. + - id: deepseek-v4-pro + name: DeepSeek V4 Pro (third-party, Anthropic-API-compatible) + required_env: [ANTHROPIC_AUTH_TOKEN] + - id: deepseek-v4-flash + name: DeepSeek V4 Flash (third-party, Anthropic-API-compatible) + required_env: [ANTHROPIC_AUTH_TOKEN] + # Default required_env — per-model entries above override this once a # model is picked. Keep CLAUDE_CODE_OAUTH_TOKEN as the default so # existing workspaces (which all use OAuth) keep working unchanged. diff --git a/entrypoint.sh b/entrypoint.sh index a605cde..396f2a0 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -81,35 +81,12 @@ elif [ -n "${GH_TOKEN:-}" ]; then echo "${GH_TOKEN}" | gh auth login --hostname github.com --with-token 2>/dev/null || true fi -# Third-party Anthropic-API-compatible provider routing. -# The `claude` CLI honors ANTHROPIC_BASE_URL natively; we rewrite it -# based on MODEL so a Xiaomi MiMo selection lands on Xiaomi's endpoint -# without code changes inside the SDK. ANTHROPIC_API_KEY in this case -# is the third-party provider key, not an Anthropic Console key. -# -# Refuses to clobber an operator-set ANTHROPIC_BASE_URL — if the user -# provided one explicitly via secrets (e.g. a Xiaomi MiMo Token Plan -# endpoint such as https://token-plan-sgp.xiaomimimo.com/anthropic), -# that wins. The mapping below is only the fallback for known model -# prefixes. -# -# Supported Xiaomi MiMo endpoints: -# - Pay-as-you-go: https://api.xiaomimimo.com/anthropic -# - Token Plan SG: https://token-plan-sgp.xiaomimimo.com/anthropic -# - Token Plan HK: https://token-plan-hk.xiaomimimo.com/anthropic -# (Set ANTHROPIC_BASE_URL explicitly to use a specific endpoint.) -# -# Long-term this should move to a data-driven `runtime_env` field in -# config.yaml read by the platform provisioner; tracked separately. -case "${MODEL:-}" in - mimo-*) - if [ -z "${ANTHROPIC_BASE_URL:-}" ]; then - export ANTHROPIC_BASE_URL="https://api.xiaomimimo.com/anthropic" - echo "[entrypoint] MODEL=${MODEL} → ANTHROPIC_BASE_URL=${ANTHROPIC_BASE_URL}" >&2 - else - echo "[entrypoint] MODEL=${MODEL} but ANTHROPIC_BASE_URL already set; not overriding" >&2 - fi - ;; -esac +# Third-party provider routing is now handled by adapter.py at boot — +# it reads the `providers:` registry from /configs/config.yaml and sets +# ANTHROPIC_BASE_URL based on the picked MODEL. Adding a new provider +# is a one-line YAML edit (see config.yaml's `providers:` section). +# Operator-set ANTHROPIC_BASE_URL still wins as the escape hatch for +# regional endpoints (e.g. Xiaomi's token-plan-sgp.*, MiniMax's +# api.minimaxi.com China endpoint). exec molecule-runtime "$@" diff --git a/tests/test_adapter_prevalidate.py b/tests/test_adapter_prevalidate.py index 243140d..3a053f4 100644 --- a/tests/test_adapter_prevalidate.py +++ b/tests/test_adapter_prevalidate.py @@ -1,19 +1,23 @@ -"""Unit tests for ClaudeCodeAdapter.create_executor pre-validation. +"""Unit tests for ClaudeCodeAdapter.setup + create_executor. -Pin the failure-mode-caught-on-2026-04-30 (workspaces with -ANTHROPIC_BASE_URL pointing at a MiniMax/OpenAI shim and no explicit -model hung on the SDK --print probe for 30s, eventually triggering -the platform's phantom-busy sweep). +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 exercise the pre-validation branch in create_executor -without booting the actual ClaudeSDKExecutor — we mock the import -so we can drive the validation logic in isolation. +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, field +from dataclasses import dataclass from unittest.mock import MagicMock import pytest @@ -26,7 +30,7 @@ import pytest # - 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 branch we care about runs BEFORE the +# installed. The pre-validation branches we care about run BEFORE the # executor instantiates, so the stub doesn't affect what we're testing. @@ -80,6 +84,67 @@ def _install_stubs(): 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.""" @@ -98,7 +163,32 @@ def adapter(monkeypatch): return adapter_module.ClaudeCodeAdapter() -# ---- Pre-validation tests ---- +@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 @@ -230,65 +320,107 @@ async def test_create_executor_passes_when_unparseable_url(adapter, monkeypatch) assert executor is not None -# ---- setup() pre-validation tests ---- +# ---- 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). Both produce "boots but every LLM call fails" if not caught; -# raising at boot keeps the workspace from entering "online" status with +# 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_raises_when_third_party_model_and_no_base_url( - adapter, monkeypatch +async def test_setup_passes_when_third_party_model_with_registered_base_url( + adapter, monkeypatch, configs_dir ): - """mimo-* model picked but no ANTHROPIC_BASE_URL → raise. - - Without the URL, every LLM request lands on api.anthropic.com with - a non-Anthropic key and 401s. The adapter should fail at boot - rather than ship a workspace that 401s on every prompt. + """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="/tmp/configs" + runtime_config={"model": "mimo-v2-flash"}, config_path=configs_dir ) - with pytest.raises(ValueError) as exc_info: - await adapter.setup(cfg) - - msg = str(exc_info.value) - assert "mimo-v2-flash" in msg - assert "ANTHROPIC_BASE_URL" in msg - - -@pytest.mark.asyncio -async def test_setup_passes_when_third_party_model_with_base_url( - adapter, monkeypatch -): - """The fix path: third-party model + base URL set → setup() runs - cleanly through to plugin install (which is a no-op stub here). - """ - monkeypatch.setenv( - "ANTHROPIC_BASE_URL", "https://api.xiaomimimo.com/anthropic" - ) - cfg = _StubAdapterConfig( - runtime_config={"model": "mimo-v2-flash"}, config_path="/tmp/configs" - ) - - # Should complete without raising. Plugin install is stubbed. 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_when_oauth_model_no_base_url(adapter, monkeypatch): +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="/tmp/configs" + runtime_config={"model": "sonnet"}, config_path=configs_dir ) await adapter.setup(cfg) @@ -296,7 +428,7 @@ async def test_setup_passes_when_oauth_model_no_base_url(adapter, monkeypatch): @pytest.mark.asyncio async def test_setup_passes_when_anthropic_api_model_no_base_url( - adapter, monkeypatch + adapter, monkeypatch, configs_dir ): """claude-* versioned ids are Anthropic API-key path; base URL optional (defaults to api.anthropic.com). setup() must not raise. @@ -304,7 +436,210 @@ async def test_setup_passes_when_anthropic_api_model_no_base_url( monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False) cfg = _StubAdapterConfig( runtime_config={"model": "claude-sonnet-4-6"}, - config_path="/tmp/configs", + 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"