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. # the workspace by polling /transcript?limit=999999.
_TRANSCRIPT_MAX_LIMIT = 1000 _TRANSCRIPT_MAX_LIMIT = 1000
# Auth-mode classification for a selected model id. The Claude Code CLI # Auth-mode constants — provider entries use one of these strings.
# accepts three auth paths and the right env var differs per path; warning # Drives validation behavior in setup() (third-party requires base_url
# at boot about the wrong var (the pre-multi-provider behavior) misled # resolution; oauth/anthropic-api leave base_url=None for CLI defaults).
# 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_OAUTH = "oauth" _AUTH_MODE_OAUTH = "oauth"
_AUTH_MODE_ANTHROPIC_API = "anthropic_api" _AUTH_MODE_ANTHROPIC_API = "anthropic_api"
_AUTH_MODE_THIRD_PARTY = "third_party_anthropic_compat" _AUTH_MODE_THIRD_PARTY = "third_party_anthropic_compat"
_THIRD_PARTY_PREFIXES = ("mimo-",) # Built-in provider registry — used as a fallback when /configs/config.yaml
_OAUTH_ALIASES = frozenset({"sonnet", "opus", "haiku"}) # 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: def _normalize_provider(entry: dict) -> dict:
"""Classify the picked model into one of three auth paths. """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 YAML gives us lists (not tuples) and may omit optional keys. Normalize
the misconfiguration at boot instead of on the first LLM call. to the union of all fields so downstream lookups work without scattered
Unknown ids default to OAuth the historical default and the safest .get(...) calls.
fallback for the warning path.
""" """
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: if not model:
return _AUTH_MODE_OAUTH return fallback
m = model.lower() m = model.lower()
if any(m.startswith(p) for p in _THIRD_PARTY_PREFIXES): for provider in providers:
return _AUTH_MODE_THIRD_PARTY for prefix in provider["model_prefixes"]:
if m.startswith("claude-"): if prefix and m.startswith(prefix):
return _AUTH_MODE_ANTHROPIC_API return provider
if m in _OAUTH_ALIASES: for provider in providers:
return _AUTH_MODE_OAUTH if m in provider["model_aliases"]:
return _AUTH_MODE_OAUTH return provider
return fallback
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"
class ClaudeCodeAdapter(BaseAdapter): class ClaudeCodeAdapter(BaseAdapter):
@ -136,63 +194,86 @@ class ClaudeCodeAdapter(BaseAdapter):
``CLAUDE.md`` and ``/configs/skills/`` natively, and the default ``CLAUDE.md`` and ``/configs/skills/`` natively, and the default
:class:`AgentskillsAdaptor` writes to both. :class:`AgentskillsAdaptor` writes to both.
""" """
# KI-001 fix, generalized for the three auth paths the CLI supports: # Load provider registry from /configs/config.yaml — canvas reads
# OAuth (CLAUDE_CODE_OAUTH_TOKEN), Anthropic API (ANTHROPIC_API_KEY), # the same YAML for its Config-tab Provider dropdown so adapter +
# and third-party Anthropic-API-compat (ANTHROPIC_API_KEY + provider # UI never disagree on what's available. Adding a new provider is
# ANTHROPIC_BASE_URL). Detect the path from the picked model so the # a one-line YAML edit (no code change in this file or entrypoint.sh).
# warning targets the *right* env var — the pre-multi-provider code providers = _load_providers(config.config_path)
# always warned about CLAUDE_CODE_OAUTH_TOKEN even when the user had
# legitimately picked an API-key model and set ANTHROPIC_API_KEY. # 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 rc = config.runtime_config
if isinstance(rc, dict): if isinstance(rc, dict):
picked_model = rc.get("model") or "sonnet" picked_model = rc.get("model") or "sonnet"
else: else:
picked_model = getattr(rc, "model", None) or "sonnet" picked_model = getattr(rc, "model", None) or "sonnet"
auth_mode = _detect_auth_mode(picked_model) provider = _resolve_provider(picked_model, providers)
required_var = _required_env_for_mode(auth_mode) auth_env_options = provider["auth_env"]
# Single-line startup banner — operators reading boot logs can see # Endpoint precedence: operator-set ANTHROPIC_BASE_URL wins (escape
# which provider path was selected and whether ANTHROPIC_BASE_URL # hatch for custom regional endpoints — e.g. token-plan-sgp.* for
# (set by entrypoint.sh for third-party mimo-*) took effect. URL is # Xiaomi MiMo, api.minimaxi.com for MiniMax China). Otherwise the
# logged as host-only; defensive against credential-shaped query # provider's default base_url is auto-applied so the operator
# strings even though base_url shouldn't carry one. # picking a provider in the platform UI doesn't *also* have to
base_url = os.environ.get("ANTHROPIC_BASE_URL") # 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 = "" base_url_host = ""
if base_url: if effective_base_url:
try: try:
base_url_host = urlparse(base_url).netloc or "<unparseable>" base_url_host = urlparse(effective_base_url).netloc or "<unparseable>"
except Exception: except Exception:
base_url_host = "<unparseable>" base_url_host = "<unparseable>"
logger.info( logger.info(
"Claude Code adapter starting: model=%s auth_mode=%s required_env=%s%s", "Claude Code adapter starting: model=%s provider=%s auth_mode=%s "
picked_model, auth_mode, required_var, "base_url=%s (%s) auth_env=%s",
f" base_url_host={base_url_host}" if base_url_host else "", 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( logger.warning(
"%s is not set for model=%s (auth_mode=%s) — the adapter will fail " "None of %s set for model=%s (provider=%s) — the adapter "
"on the first LLM call with an AuthenticationError. Set the env " "will fail on the first LLM call with AuthenticationError. "
"var or configure the key in your platform workspace settings.", "Set one of these env vars in workspace secrets.",
required_var, picked_model, auth_mode, "/".join(auth_env_options), picked_model, provider["name"],
) )
# Third-party paths additionally need ANTHROPIC_BASE_URL; entrypoint.sh # Third-party providers must end up with a base_url one way or
# sets it for known mimo-* prefixes. Fail fast on the missing-base-URL # another (provider default OR operator override). If neither, the
# combo — the symptom otherwise is the CLI silently hitting # CLI silently hits api.anthropic.com with a non-Anthropic key and
# api.anthropic.com with a non-Anthropic key, every LLM call 401s, and # every call 401s — workspace looks "online" but is structurally
# the workspace looks "online" while being structurally broken. # broken. Symmetric with create_executor's pre-validate raise on
# Symmetric with create_executor's pre-validate raise on the inverse # the inverse misconfig. The provider registry guarantees a default
# combo (URL set, no model picked) — both unrecoverable misconfigs # for every third-party we ship, so this fires only if a future
# that would put the workspace into a "boots but never works" state. # provider entry forgets to set base_url.
if auth_mode == _AUTH_MODE_THIRD_PARTY and not base_url: if (provider["auth_mode"] == _AUTH_MODE_THIRD_PARTY
and not effective_base_url):
raise ValueError( raise ValueError(
f"claude-code adapter: model={picked_model} is a third-party " f"claude-code adapter: model={picked_model} resolved to "
"Anthropic-compat model but ANTHROPIC_BASE_URL is unset. " f"third-party provider={provider['name']} but no "
"Without it, requests land on api.anthropic.com with a " "ANTHROPIC_BASE_URL is configured (provider has no default "
"non-Anthropic key and 401 every call. Fix: check " "and operator didn't set one). Add base_url to the provider "
"entrypoint.sh's model→base-URL mapping for this model " "entry in adapter.py or set ANTHROPIC_BASE_URL via secrets."
"prefix, or set ANTHROPIC_BASE_URL as a workspace secret."
) )
from molecule_runtime.plugins import load_plugins 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, (1) Claude Code subscription via OAuth token (CLAUDE_CODE_OAUTH_TOKEN,
obtained from `claude login`), (2) Anthropic API key obtained from `claude login`), (2) Anthropic API key
(ANTHROPIC_API_KEY, pay-as-you-go via console.anthropic.com), or (ANTHROPIC_API_KEY, pay-as-you-go via console.anthropic.com), or
(3) third-party Anthropic-API-compatible providers (e.g. Xiaomi MiMo) (3) third-party Anthropic-API-compatible providers (e.g. Xiaomi MiMo,
via ANTHROPIC_API_KEY + provider-specific ANTHROPIC_BASE_URL routing. MiniMax) via ANTHROPIC_AUTH_TOKEN/ANTHROPIC_API_KEY + provider-specific
The `claude` CLI picks whichever is set; OAuth takes precedence when ANTHROPIC_BASE_URL routing. The `claude` CLI picks whichever is set;
multiple are present. OAuth takes precedence when multiple are present.
version: 1.0.0 version: 1.0.0
tier: 2 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: claude-code
runtime_config: runtime_config:
model: sonnet model: sonnet
@ -45,13 +123,11 @@ runtime_config:
name: Claude Haiku 4.5 (API key / Anthropic Console) name: Claude Haiku 4.5 (API key / Anthropic Console)
required_env: [ANTHROPIC_API_KEY] required_env: [ANTHROPIC_API_KEY]
# --- Xiaomi MiMo (third-party, Anthropic-API-compatible) — set ANTHROPIC_API_KEY --- # --- Xiaomi MiMo (third-party, Anthropic-API-compatible) ---
# Routed through https://api.xiaomimimo.com/anthropic via ANTHROPIC_BASE_URL # Routed via the `xiaomi-mimo` provider entry above (base_url and
# (the claude CLI honors the env var natively). Mapping lives in # auth_env are resolved from the registry — the adapter sets
# entrypoint.sh — when MODEL matches mimo-*, base URL is rewritten before # ANTHROPIC_BASE_URL automatically based on the model prefix). Either
# the runtime starts. The user's ANTHROPIC_API_KEY here is a Xiaomi key, # ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY satisfies auth — both work.
# not an Anthropic Console key. Long-term, this should move to a
# data-driven `runtime_env` schema field; tracked separately.
- id: mimo-v2-flash - id: mimo-v2-flash
name: Xiaomi MiMo V2 Flash (third-party, Anthropic-API-compatible) name: Xiaomi MiMo V2 Flash (third-party, Anthropic-API-compatible)
required_env: [ANTHROPIC_API_KEY] required_env: [ANTHROPIC_API_KEY]
@ -61,10 +137,56 @@ runtime_config:
- id: mimo-v2-omni - id: mimo-v2-omni
name: Xiaomi MiMo V2 Omni (third-party, Anthropic-API-compatible) name: Xiaomi MiMo V2 Omni (third-party, Anthropic-API-compatible)
required_env: [ANTHROPIC_API_KEY] 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 - id: mimo-v2.5-pro
name: Xiaomi MiMo V2.5 Pro (third-party, Anthropic-API-compatible) name: Xiaomi MiMo V2.5 Pro (third-party, Anthropic-API-compatible)
required_env: [ANTHROPIC_API_KEY] 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 # Default required_env — per-model entries above override this once a
# model is picked. Keep CLAUDE_CODE_OAUTH_TOKEN as the default so # model is picked. Keep CLAUDE_CODE_OAUTH_TOKEN as the default so
# existing workspaces (which all use OAuth) keep working unchanged. # 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 echo "${GH_TOKEN}" | gh auth login --hostname github.com --with-token 2>/dev/null || true
fi fi
# Third-party Anthropic-API-compatible provider routing. # Third-party provider routing is now handled by adapter.py at boot —
# The `claude` CLI honors ANTHROPIC_BASE_URL natively; we rewrite it # it reads the `providers:` registry from /configs/config.yaml and sets
# based on MODEL so a Xiaomi MiMo selection lands on Xiaomi's endpoint # ANTHROPIC_BASE_URL based on the picked MODEL. Adding a new provider
# without code changes inside the SDK. ANTHROPIC_API_KEY in this case # is a one-line YAML edit (see config.yaml's `providers:` section).
# is the third-party provider key, not an Anthropic Console key. # Operator-set ANTHROPIC_BASE_URL still wins as the escape hatch for
# # regional endpoints (e.g. Xiaomi's token-plan-sgp.*, MiniMax's
# Refuses to clobber an operator-set ANTHROPIC_BASE_URL — if the user # api.minimaxi.com China endpoint).
# 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
exec molecule-runtime "$@" 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 Two surfaces under test:
ANTHROPIC_BASE_URL pointing at a MiniMax/OpenAI shim and no explicit 1. setup() provider-registry loading + auth-env validation +
model hung on the SDK --print probe for 30s, eventually triggering base_url resolution. Pins the post-2026-04-30 architecture where
the platform's phantom-busy sweep). 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 These tests stub the import dependencies (molecule_runtime, a2a,
without booting the actual ClaudeSDKExecutor we mock the import claude_sdk_executor) so they can run without the real packages installed.
so we can drive the validation logic in isolation.
""" """
import os import os
import sys import sys
import textwrap
import types import types
from dataclasses import dataclass, field from dataclasses import dataclass
from unittest.mock import MagicMock from unittest.mock import MagicMock
import pytest import pytest
@ -26,7 +30,7 @@ import pytest
# - a2a.server.agent_execution (AgentExecutor) # - a2a.server.agent_execution (AgentExecutor)
# create_executor lazily imports claude_sdk_executor.ClaudeSDKExecutor. # create_executor lazily imports claude_sdk_executor.ClaudeSDKExecutor.
# We stub all four so the test file can run in CI without those packages # 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. # 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 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 @pytest.fixture
def adapter(monkeypatch): def adapter(monkeypatch):
"""Fresh ClaudeCodeAdapter with all imports stubbed.""" """Fresh ClaudeCodeAdapter with all imports stubbed."""
@ -98,7 +163,32 @@ def adapter(monkeypatch):
return adapter_module.ClaudeCodeAdapter() 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 @pytest.mark.asyncio
@ -230,65 +320,107 @@ async def test_create_executor_passes_when_unparseable_url(adapter, monkeypatch)
assert executor is not None assert executor is not None
# ---- setup() pre-validation tests ---- # ---- setup() provider-registry tests ----
# #
# Symmetric to create_executor's pre-validate: setup() raises on the # Symmetric to create_executor's pre-validate: setup() raises on the
# inverse misconfig (third-party MODEL picked but ANTHROPIC_BASE_URL # inverse misconfig (third-party MODEL picked but ANTHROPIC_BASE_URL
# unset). Both produce "boots but every LLM call fails" if not caught; # unset and the resolved provider has no default base_url). Both
# raising at boot keeps the workspace from entering "online" status with # produce "boots but every LLM call fails" if not caught; raising at
# boot keeps the workspace from entering "online" status with
# structurally-broken auth. # structurally-broken auth.
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_setup_raises_when_third_party_model_and_no_base_url( async def test_setup_passes_when_third_party_model_with_registered_base_url(
adapter, monkeypatch adapter, monkeypatch, configs_dir
): ):
"""mimo-* model picked but no ANTHROPIC_BASE_URL → raise. """Third-party model + provider has default base_url in YAML →
setup() auto-applies it (no operator URL needed) and runs cleanly
Without the URL, every LLM request lands on api.anthropic.com with through to plugin install. The Option B v2 happy path: pick mimo-
a non-Anthropic key and 401s. The adapter should fail at boot or minimax- model in canvas, the registry handles routing.
rather than ship a workspace that 401s on every prompt.
""" """
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False) monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
cfg = _StubAdapterConfig( 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) 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 @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 """OAuth-aliased models (sonnet/opus/haiku) are Anthropic-native; no
base URL is required. setup() must not raise on the OAuth path even base URL is required. setup() must not raise on the OAuth path even
though base_url is unset that's the historical happy path. though base_url is unset that's the historical happy path.
""" """
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False) monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
cfg = _StubAdapterConfig( cfg = _StubAdapterConfig(
runtime_config={"model": "sonnet"}, config_path="/tmp/configs" runtime_config={"model": "sonnet"}, config_path=configs_dir
) )
await adapter.setup(cfg) await adapter.setup(cfg)
@ -296,7 +428,7 @@ async def test_setup_passes_when_oauth_model_no_base_url(adapter, monkeypatch):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_setup_passes_when_anthropic_api_model_no_base_url( 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 """claude-* versioned ids are Anthropic API-key path; base URL
optional (defaults to api.anthropic.com). setup() must not raise. 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) monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
cfg = _StubAdapterConfig( cfg = _StubAdapterConfig(
runtime_config={"model": "claude-sonnet-4-6"}, runtime_config={"model": "claude-sonnet-4-6"},
config_path="/tmp/configs", config_path=configs_dir,
) )
await adapter.setup(cfg) 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"