Merge pull request #22 from Molecule-AI/feat/minimax-third-party-support

feat(adapter): data-driven provider registry in config.yaml
This commit is contained in:
Hongming Wang 2026-05-01 01:16:05 -07:00 committed by GitHub
commit e82a3ec287
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 894 additions and 161 deletions

View File

@ -13,7 +13,15 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- run: pip install -q pytest pytest-asyncio
# pyyaml is the runtime dep that adapter.py's _load_providers reads
# /configs/config.yaml through. In production it arrives transitively
# via molecule-ai-workspace-runtime; in this minimal test env we
# install it explicitly so the YAML-loading code path is actually
# exercised (without it, _load_providers' broad except-Exception
# swallows the ImportError and silently falls back to _BUILTIN_PROVIDERS,
# which is exactly the behavior that bit us 2026-04-30 when CI
# claimed green on a build that couldn't route any third-party model).
- run: pip install -q pytest pytest-asyncio pyyaml
# Tests live under tests/ with their own pytest.ini that anchors
# rootdir there — keeps pytest from importing the package
# __init__.py (which does `from .adapter import ...` for runtime

View File

@ -15,46 +15,180 @@ 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 _coerce_string_list(value, lowercase: bool = False) -> tuple:
"""Defensively coerce a YAML field expected to be a list-of-strings.
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.
Operator typos in config.yaml come in two shapes that both used to
silently produce wrong routing:
1. forgot brackets: ``model_prefixes: mimo-`` (string, not list)
2. mixed types: ``model_prefixes: [mimo-, 123]`` (int slips in)
Case 1 used to iterate over characters ``('m','i','m','o','-')``,
making the entry match every model whose id starts with any of those
letters. Case 2 raised AttributeError mid-comprehension, killing the
whole registry build and silently falling back to builtins-only
exactly the silent-fallback failure mode this PR was meant to fix.
Returns an empty tuple for any non-list (treated as "no entries");
drops non-string items in the list with a warning.
``lowercase`` controls case-folding: True for case-insensitive
comparisons (model_prefixes, model_aliases operators write
``MiniMax-M2`` in YAML, model id arrives lowercased downstream),
False to preserve case (auth_env env var names are
case-sensitive: ``CLAUDE_CODE_OAUTH_TOKEN``
``claude_code_oauth_token``).
"""
if not isinstance(value, list):
return ()
out = []
for item in value:
if not isinstance(item, str):
logger.warning(
"providers: skipping non-string list item %r (type %s)",
item, type(item).__name__,
)
continue
out.append(item.lower() if lowercase else item)
return tuple(out)
def _normalize_provider(entry: dict):
"""Coerce a YAML-loaded provider dict into the shape adapter logic expects.
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. Returns ``None`` for entries that can't be salvaged
(e.g. missing name) so the caller can drop them without poisoning the
rest of the registry.
"""
if not isinstance(entry, dict):
return None
name = entry.get("name")
if not name or not isinstance(name, str):
logger.warning("providers: skipping entry without a string name: %r", entry)
return None
return {
"name": name,
"auth_mode": entry.get("auth_mode") or _AUTH_MODE_OAUTH,
"model_prefixes": _coerce_string_list(entry.get("model_prefixes"), lowercase=True),
"model_aliases": _coerce_string_list(entry.get("model_aliases"), lowercase=True),
"base_url": entry.get("base_url") or None,
"auth_env": _coerce_string_list(entry.get("auth_env"), lowercase=False),
}
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.
Per-entry isolation: a single bad provider entry is dropped with a
warning; the rest of the registry survives. Used to be a generator
inside tuple(...) that propagated any AttributeError out and reverted
the whole registry to builtins exactly the silent-fallback failure
mode this file's existence was meant to fix.
"""
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 {}
except FileNotFoundError:
logger.info("providers: %s not found, using builtin defaults", yaml_path)
return _BUILTIN_PROVIDERS
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
raw = data.get("providers") if isinstance(data, dict) else None
if not isinstance(raw, list) or not raw:
return _BUILTIN_PROVIDERS
parsed = []
for entry in raw:
try:
normalized = _normalize_provider(entry)
except Exception as exc: # noqa: BLE001 — per-entry isolation
logger.warning("providers: dropping unparseable entry %r (%s)", entry, exc)
continue
if normalized is not None:
parsed.append(normalized)
if not parsed:
logger.warning("providers: no valid entries in %s; using builtins", yaml_path)
return _BUILTIN_PROVIDERS
return tuple(parsed)
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 anthropic-oauth in both
_BUILTIN_PROVIDERS and the shipped config.yaml).
Pre-condition: ``providers`` is non-empty. _load_providers always
returns at least one entry (built-ins when YAML is missing or every
parsed entry was rejected).
"""
if not providers:
raise ValueError(
"_resolve_provider called with empty providers tuple; "
"_load_providers must always return at least one entry "
"(falling back to _BUILTIN_PROVIDERS when needed)"
)
if not model:
return _AUTH_MODE_OAUTH
return providers[0]
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 providers[0]
class ClaudeCodeAdapter(BaseAdapter):
@ -136,63 +270,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,59 @@ 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]
- id: MiniMax-M2.7-highspeed
name: MiniMax M2.7 High-Speed (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,341 @@ 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_load_providers_drops_bad_entry_keeps_rest(tmp_path, caplog):
"""Per-entry isolation: one malformed entry shouldn't nuke the registry.
Pre-fix: ``_load_providers`` built the registry via a generator inside
``tuple(...)``. A single AttributeError mid-comprehension propagated
out and the broad except caught it, silently reverting to
``_BUILTIN_PROVIDERS`` (oauth + anthropic-api only). Every third-party
model would then route to anthropic-oauth exactly the silent-fallback
failure mode this PR was meant to eliminate.
Post-fix: per-entry try/except drops the bad entry with a warning,
rest of the registry survives.
"""
_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
yaml_with_typo = textwrap.dedent("""
providers:
- name: good-zai
auth_mode: third_party_anthropic_compat
model_prefixes: [glm-]
base_url: https://api.z.ai/api/anthropic
auth_env: [ANTHROPIC_AUTH_TOKEN]
# Operator typo: forgot list brackets, ints slipped in.
# Pre-fix: AttributeError on the int's .lower() killed the
# whole tuple build → registry fell back to builtins.
- name: bad-one
auth_mode: third_party_anthropic_compat
model_prefixes: [bad-, 123]
base_url: https://example.com
auth_env: [SOME_TOKEN]
- name: good-anthropic
auth_mode: anthropic_api
model_prefixes: [claude-]
auth_env: [ANTHROPIC_API_KEY]
""")
(tmp_path / "config.yaml").write_text(yaml_with_typo)
import logging
with caplog.at_level(logging.WARNING):
result = adapter_module._load_providers(str(tmp_path))
# All three entries survive — the integer is dropped, the rest of
# the bad-one entry's prefix list is kept (just `bad-`).
names = [p["name"] for p in result]
assert names == ["good-zai", "bad-one", "good-anthropic"], (
f"Expected all three entries to survive (with the int dropped from "
f"bad-one's prefixes), got {names}"
)
# Confirm the int got skipped, not silently coerced or crash-bubbled.
bad = next(p for p in result if p["name"] == "bad-one")
assert bad["model_prefixes"] == ("bad-",), (
f"Non-string list element should be dropped; got {bad['model_prefixes']}"
)
# Operator should see a warning so they can fix the YAML.
assert any("non-string" in r.getMessage() for r in caplog.records), (
"Expected a warning about the non-string list item"
)
def test_load_providers_string_as_prefix_does_not_split_into_chars(tmp_path, caplog):
"""A YAML field declared as list-of-strings but written as a bare
string (operator forgot brackets) used to silently iterate over
characters ``('m','i','m','o','-')``. Post-fix: non-list value
coerces to empty tuple with no exception. The entry survives but
matches nothing operator notices in the boot banner instead of
via mysteriously-misrouted requests.
"""
_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
yaml_str_prefix = textwrap.dedent("""
providers:
- name: typo-prefix
auth_mode: third_party_anthropic_compat
model_prefixes: mimo-
base_url: https://api.xiaomimimo.com/anthropic
auth_env: [ANTHROPIC_AUTH_TOKEN]
""")
(tmp_path / "config.yaml").write_text(yaml_str_prefix)
result = adapter_module._load_providers(str(tmp_path))
typo = next(p for p in result if p["name"] == "typo-prefix")
assert typo["model_prefixes"] == (), (
f"String value (forgot brackets) must coerce to empty tuple, not "
f"split into characters; got {typo['model_prefixes']}"
)
def test_load_providers_drops_entry_without_name(tmp_path, caplog):
"""An entry without ``name`` is operator error — no silent fallback
to ``<unnamed>``. Drop the entry with a warning so the boot log
surfaces the typo.
"""
_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
yaml_no_name = textwrap.dedent("""
providers:
- name: good
auth_mode: oauth
auth_env: [CLAUDE_CODE_OAUTH_TOKEN]
- auth_mode: third_party_anthropic_compat
model_prefixes: [foo-]
""")
(tmp_path / "config.yaml").write_text(yaml_no_name)
import logging
with caplog.at_level(logging.WARNING):
result = adapter_module._load_providers(str(tmp_path))
assert [p["name"] for p in result] == ["good"]
assert any("without a string name" in r.getMessage() for r in caplog.records)
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"