feat(adapter): data-driven provider registry in config.yaml

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>
This commit is contained in:
Hongming Wang 2026-04-30 23:29:40 -07:00
parent e02c5bf34b
commit c6f4912d09
4 changed files with 675 additions and 160 deletions

View File

@ -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 "<unnamed>",
"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 "<unparseable>"
base_url_host = urlparse(effective_base_url).netloc or "<unparseable>"
except Exception:
base_url_host = "<unparseable>"
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

View File

@ -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.

View File

@ -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 "$@"

View File

@ -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 modelprovider 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"