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:
parent
e02c5bf34b
commit
c6f4912d09
221
adapter.py
221
adapter.py
@ -15,46 +15,104 @@ logger = logging.getLogger(__name__)
|
||||
# the workspace by polling /transcript?limit=999999.
|
||||
_TRANSCRIPT_MAX_LIMIT = 1000
|
||||
|
||||
# Auth-mode classification for a selected model id. The Claude Code CLI
|
||||
# accepts three auth paths and the right env var differs per path; warning
|
||||
# at boot about the wrong var (the pre-multi-provider behavior) misled
|
||||
# operators who picked an API-key or third-party model. New third-party
|
||||
# providers add a prefix → mode entry below + a model-prefix → base-URL
|
||||
# mapping in entrypoint.sh until the data-driven `runtime_env` schema
|
||||
# field lands platform-side.
|
||||
# Auth-mode constants — provider entries use one of these strings.
|
||||
# Drives validation behavior in setup() (third-party requires base_url
|
||||
# resolution; oauth/anthropic-api leave base_url=None for CLI defaults).
|
||||
_AUTH_MODE_OAUTH = "oauth"
|
||||
_AUTH_MODE_ANTHROPIC_API = "anthropic_api"
|
||||
_AUTH_MODE_THIRD_PARTY = "third_party_anthropic_compat"
|
||||
|
||||
_THIRD_PARTY_PREFIXES = ("mimo-",)
|
||||
_OAUTH_ALIASES = frozenset({"sonnet", "opus", "haiku"})
|
||||
# Built-in provider registry — used as a fallback when /configs/config.yaml
|
||||
# doesn't define `providers:`. The canonical registry is the YAML file: it
|
||||
# becomes the single source of truth read by both this adapter (for boot-time
|
||||
# routing) and the canvas Config tab (Provider dropdown). Adding a new
|
||||
# provider should be a one-line YAML edit, not a code change. This builtin
|
||||
# exists so a workspace with a malformed/missing config.yaml still boots
|
||||
# with sensible defaults instead of failing.
|
||||
_BUILTIN_PROVIDERS = (
|
||||
{
|
||||
"name": "anthropic-oauth",
|
||||
"auth_mode": _AUTH_MODE_OAUTH,
|
||||
"model_prefixes": (),
|
||||
"model_aliases": ("sonnet", "opus", "haiku"),
|
||||
"base_url": None,
|
||||
"auth_env": ("CLAUDE_CODE_OAUTH_TOKEN",),
|
||||
},
|
||||
{
|
||||
"name": "anthropic-api",
|
||||
"auth_mode": _AUTH_MODE_ANTHROPIC_API,
|
||||
"model_prefixes": ("claude-",),
|
||||
"model_aliases": (),
|
||||
"base_url": None,
|
||||
"auth_env": ("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _detect_auth_mode(model: str) -> str:
|
||||
"""Classify the picked model into one of three auth paths.
|
||||
def _normalize_provider(entry: dict) -> dict:
|
||||
"""Coerce a YAML-loaded provider dict into the shape adapter logic expects.
|
||||
|
||||
Used by setup() to validate the right env var is set so operators see
|
||||
the misconfiguration at boot instead of on the first LLM call.
|
||||
Unknown ids default to OAuth — the historical default and the safest
|
||||
fallback for the warning path.
|
||||
YAML gives us lists (not tuples) and may omit optional keys. Normalize
|
||||
to the union of all fields so downstream lookups work without scattered
|
||||
.get(...) calls.
|
||||
"""
|
||||
return {
|
||||
"name": entry.get("name") or "<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
|
||||
|
||||
144
config.yaml
144
config.yaml
@ -4,13 +4,91 @@ description: >-
|
||||
(1) Claude Code subscription via OAuth token (CLAUDE_CODE_OAUTH_TOKEN,
|
||||
obtained from `claude login`), (2) Anthropic API key
|
||||
(ANTHROPIC_API_KEY, pay-as-you-go via console.anthropic.com), or
|
||||
(3) third-party Anthropic-API-compatible providers (e.g. Xiaomi MiMo)
|
||||
via ANTHROPIC_API_KEY + provider-specific ANTHROPIC_BASE_URL routing.
|
||||
The `claude` CLI picks whichever is set; OAuth takes precedence when
|
||||
multiple are present.
|
||||
(3) third-party Anthropic-API-compatible providers (e.g. Xiaomi MiMo,
|
||||
MiniMax) via ANTHROPIC_AUTH_TOKEN/ANTHROPIC_API_KEY + provider-specific
|
||||
ANTHROPIC_BASE_URL routing. The `claude` CLI picks whichever is set;
|
||||
OAuth takes precedence when multiple are present.
|
||||
version: 1.0.0
|
||||
tier: 2
|
||||
|
||||
# Provider registry — single source of truth for model→endpoint→auth
|
||||
# routing. The adapter reads this list at boot to resolve the picked
|
||||
# model to a provider, auto-set ANTHROPIC_BASE_URL, and validate the
|
||||
# right auth env var. The canvas Config tab reads the same list to
|
||||
# render its Provider dropdown — UI and adapter never disagree on
|
||||
# what's available.
|
||||
#
|
||||
# Adding a new provider = one entry below. No adapter or entrypoint.sh
|
||||
# code changes needed. Operator override always wins: setting
|
||||
# ANTHROPIC_BASE_URL as a workspace secret bypasses the auto-routing
|
||||
# (useful for regional endpoints like Xiaomi's token-plan-sgp.* or
|
||||
# MiniMax's api.minimaxi.com China endpoint).
|
||||
#
|
||||
# Schema per entry:
|
||||
# name : human-readable label (boot banner + UI dropdown)
|
||||
# auth_mode : "oauth" | "anthropic_api" | "third_party_anthropic_compat"
|
||||
# model_prefixes : lowercase model-id prefixes (e.g. ["mimo-", "minimax-"])
|
||||
# model_aliases : exact lowercase ids (e.g. ["sonnet", "opus"])
|
||||
# base_url : ANTHROPIC_BASE_URL to set; null = CLI default (anthropic-native)
|
||||
# auth_env : env vars accepted; any one being set satisfies auth
|
||||
providers:
|
||||
- name: anthropic-oauth
|
||||
auth_mode: oauth
|
||||
model_prefixes: []
|
||||
model_aliases: [sonnet, opus, haiku]
|
||||
base_url: null
|
||||
auth_env: [CLAUDE_CODE_OAUTH_TOKEN]
|
||||
|
||||
- name: anthropic-api
|
||||
auth_mode: anthropic_api
|
||||
model_prefixes: [claude-]
|
||||
model_aliases: []
|
||||
base_url: null
|
||||
auth_env: [ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN]
|
||||
|
||||
- name: xiaomi-mimo
|
||||
auth_mode: third_party_anthropic_compat
|
||||
model_prefixes: [mimo-]
|
||||
model_aliases: []
|
||||
base_url: https://api.xiaomimimo.com/anthropic
|
||||
auth_env: [ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY]
|
||||
|
||||
- name: minimax
|
||||
auth_mode: third_party_anthropic_compat
|
||||
model_prefixes: [minimax-]
|
||||
model_aliases: []
|
||||
base_url: https://api.minimax.io/anthropic
|
||||
auth_env: [ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY]
|
||||
|
||||
# Z.ai — GLM family. docs.z.ai/scenario-example/develop-tools/claude.
|
||||
# Model ids are uppercase (GLM-4.6) but the registry lowercases for
|
||||
# matching, so the `glm-` prefix catches both `GLM-4.6` and `glm-4.6`.
|
||||
- name: zai
|
||||
auth_mode: third_party_anthropic_compat
|
||||
model_prefixes: [glm-]
|
||||
model_aliases: []
|
||||
base_url: https://api.z.ai/api/anthropic
|
||||
auth_env: [ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY]
|
||||
|
||||
# Moonshot AI — Kimi family. platform.kimi.ai/docs/guide/agent-support.
|
||||
- name: moonshot
|
||||
auth_mode: third_party_anthropic_compat
|
||||
model_prefixes: [kimi-]
|
||||
model_aliases: []
|
||||
base_url: https://api.moonshot.ai/anthropic
|
||||
auth_env: [ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY]
|
||||
|
||||
# DeepSeek — api-docs.deepseek.com/guides/anthropic_api. Note: their
|
||||
# endpoint silently maps unknown model ids to deepseek-v4-flash, so a
|
||||
# typo lands on a working-but-wrong-tier model rather than 400ing.
|
||||
# Worth flagging in operator-facing docs.
|
||||
- name: deepseek
|
||||
auth_mode: third_party_anthropic_compat
|
||||
model_prefixes: [deepseek-]
|
||||
model_aliases: []
|
||||
base_url: https://api.deepseek.com/anthropic
|
||||
auth_env: [ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY]
|
||||
|
||||
runtime: claude-code
|
||||
runtime_config:
|
||||
model: sonnet
|
||||
@ -45,13 +123,11 @@ runtime_config:
|
||||
name: Claude Haiku 4.5 (API key / Anthropic Console)
|
||||
required_env: [ANTHROPIC_API_KEY]
|
||||
|
||||
# --- Xiaomi MiMo (third-party, Anthropic-API-compatible) — set ANTHROPIC_API_KEY ---
|
||||
# Routed through https://api.xiaomimimo.com/anthropic via ANTHROPIC_BASE_URL
|
||||
# (the claude CLI honors the env var natively). Mapping lives in
|
||||
# entrypoint.sh — when MODEL matches mimo-*, base URL is rewritten before
|
||||
# the runtime starts. The user's ANTHROPIC_API_KEY here is a Xiaomi key,
|
||||
# not an Anthropic Console key. Long-term, this should move to a
|
||||
# data-driven `runtime_env` schema field; tracked separately.
|
||||
# --- Xiaomi MiMo (third-party, Anthropic-API-compatible) ---
|
||||
# Routed via the `xiaomi-mimo` provider entry above (base_url and
|
||||
# auth_env are resolved from the registry — the adapter sets
|
||||
# ANTHROPIC_BASE_URL automatically based on the model prefix). Either
|
||||
# ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY satisfies auth — both work.
|
||||
- id: mimo-v2-flash
|
||||
name: Xiaomi MiMo V2 Flash (third-party, Anthropic-API-compatible)
|
||||
required_env: [ANTHROPIC_API_KEY]
|
||||
@ -61,10 +137,56 @@ runtime_config:
|
||||
- id: mimo-v2-omni
|
||||
name: Xiaomi MiMo V2 Omni (third-party, Anthropic-API-compatible)
|
||||
required_env: [ANTHROPIC_API_KEY]
|
||||
- id: mimo-v2.5
|
||||
name: Xiaomi MiMo V2.5 (third-party, Anthropic-API-compatible)
|
||||
required_env: [ANTHROPIC_API_KEY]
|
||||
- id: mimo-v2.5-pro
|
||||
name: Xiaomi MiMo V2.5 Pro (third-party, Anthropic-API-compatible)
|
||||
required_env: [ANTHROPIC_API_KEY]
|
||||
|
||||
# --- MiniMax (third-party, Anthropic-API-compatible) ---
|
||||
# Routed via the `minimax` provider entry above. MiniMax docs prefer
|
||||
# ANTHROPIC_AUTH_TOKEN (Bearer-style) — see platform.minimax.io/docs/token-plan/claude-code.
|
||||
# ANTHROPIC_API_KEY also works (the claude CLI accepts both).
|
||||
- id: MiniMax-M2
|
||||
name: MiniMax M2 (third-party, Anthropic-API-compatible)
|
||||
required_env: [ANTHROPIC_AUTH_TOKEN]
|
||||
- id: MiniMax-M2.7
|
||||
name: MiniMax M2.7 (third-party, Anthropic-API-compatible)
|
||||
required_env: [ANTHROPIC_AUTH_TOKEN]
|
||||
|
||||
# --- Z.ai GLM family (third-party, Anthropic-API-compatible) ---
|
||||
# Routed via the `zai` provider entry. docs.z.ai for the full
|
||||
# Anthropic-compat docs. GLM-4.6 is the current-gen flagship; 4.5
|
||||
# remains for users on legacy quotas.
|
||||
- id: GLM-4.6
|
||||
name: Z.ai GLM-4.6 (third-party, Anthropic-API-compatible)
|
||||
required_env: [ANTHROPIC_AUTH_TOKEN]
|
||||
- id: GLM-4.5
|
||||
name: Z.ai GLM-4.5 (third-party, Anthropic-API-compatible)
|
||||
required_env: [ANTHROPIC_AUTH_TOKEN]
|
||||
|
||||
# --- Moonshot AI Kimi family (third-party, Anthropic-API-compatible) ---
|
||||
# Routed via the `moonshot` provider entry. platform.kimi.ai for docs.
|
||||
# K2.5 is the latest agentic-coding tier; K2 stays as a cheaper option.
|
||||
- id: kimi-k2.5
|
||||
name: Moonshot Kimi K2.5 (third-party, Anthropic-API-compatible)
|
||||
required_env: [ANTHROPIC_AUTH_TOKEN]
|
||||
- id: kimi-k2
|
||||
name: Moonshot Kimi K2 (third-party, Anthropic-API-compatible)
|
||||
required_env: [ANTHROPIC_AUTH_TOKEN]
|
||||
|
||||
# --- DeepSeek (third-party, Anthropic-API-compatible) ---
|
||||
# Routed via the `deepseek` provider entry. api-docs.deepseek.com.
|
||||
# Note: unknown deepseek-* ids silently fall back to v4-flash on
|
||||
# DeepSeek's side — pick the exact tier you mean.
|
||||
- id: deepseek-v4-pro
|
||||
name: DeepSeek V4 Pro (third-party, Anthropic-API-compatible)
|
||||
required_env: [ANTHROPIC_AUTH_TOKEN]
|
||||
- id: deepseek-v4-flash
|
||||
name: DeepSeek V4 Flash (third-party, Anthropic-API-compatible)
|
||||
required_env: [ANTHROPIC_AUTH_TOKEN]
|
||||
|
||||
# Default required_env — per-model entries above override this once a
|
||||
# model is picked. Keep CLAUDE_CODE_OAUTH_TOKEN as the default so
|
||||
# existing workspaces (which all use OAuth) keep working unchanged.
|
||||
|
||||
@ -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 "$@"
|
||||
|
||||
@ -1,19 +1,23 @@
|
||||
"""Unit tests for ClaudeCodeAdapter.create_executor pre-validation.
|
||||
"""Unit tests for ClaudeCodeAdapter.setup + create_executor.
|
||||
|
||||
Pin the failure-mode-caught-on-2026-04-30 (workspaces with
|
||||
ANTHROPIC_BASE_URL pointing at a MiniMax/OpenAI shim and no explicit
|
||||
model hung on the SDK --print probe for 30s, eventually triggering
|
||||
the platform's phantom-busy sweep).
|
||||
Two surfaces under test:
|
||||
1. setup() — provider-registry loading + auth-env validation +
|
||||
base_url resolution. Pins the post-2026-04-30 architecture where
|
||||
the model→provider mapping lives in /configs/config.yaml's
|
||||
`providers:` list (canonical) with `_BUILTIN_PROVIDERS` as the
|
||||
malformed-YAML fallback.
|
||||
2. create_executor() — the 2026-04-30 hang fix (custom upstream + no
|
||||
model = raise instead of silently passing 'sonnet' to the SDK).
|
||||
|
||||
These tests exercise the pre-validation branch in create_executor
|
||||
without booting the actual ClaudeSDKExecutor — we mock the import
|
||||
so we can drive the validation logic in isolation.
|
||||
These tests stub the import dependencies (molecule_runtime, a2a,
|
||||
claude_sdk_executor) so they can run without the real packages installed.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import textwrap
|
||||
import types
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
@ -26,7 +30,7 @@ import pytest
|
||||
# - a2a.server.agent_execution (AgentExecutor)
|
||||
# create_executor lazily imports claude_sdk_executor.ClaudeSDKExecutor.
|
||||
# We stub all four so the test file can run in CI without those packages
|
||||
# installed. The pre-validation branch we care about runs BEFORE the
|
||||
# installed. The pre-validation branches we care about run BEFORE the
|
||||
# executor instantiates, so the stub doesn't affect what we're testing.
|
||||
|
||||
|
||||
@ -80,6 +84,67 @@ def _install_stubs():
|
||||
sys.modules["claude_sdk_executor"] = mod
|
||||
|
||||
|
||||
# ---- Fixtures ----
|
||||
|
||||
|
||||
# Canonical provider registry used by most setup() tests. Mirrors the
|
||||
# real config.yaml's `providers:` list — kept inline here so a config.yaml
|
||||
# rename/edit doesn't silently change test semantics. If the prod
|
||||
# registry ever drifts from this fixture, the divergence is intentional
|
||||
# and visible in the diff.
|
||||
_FIXTURE_PROVIDERS_YAML = textwrap.dedent("""
|
||||
providers:
|
||||
- name: anthropic-oauth
|
||||
auth_mode: oauth
|
||||
model_prefixes: []
|
||||
model_aliases: [sonnet, opus, haiku]
|
||||
base_url: null
|
||||
auth_env: [CLAUDE_CODE_OAUTH_TOKEN]
|
||||
|
||||
- name: anthropic-api
|
||||
auth_mode: anthropic_api
|
||||
model_prefixes: [claude-]
|
||||
model_aliases: []
|
||||
base_url: null
|
||||
auth_env: [ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN]
|
||||
|
||||
- name: xiaomi-mimo
|
||||
auth_mode: third_party_anthropic_compat
|
||||
model_prefixes: [mimo-]
|
||||
model_aliases: []
|
||||
base_url: https://api.xiaomimimo.com/anthropic
|
||||
auth_env: [ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY]
|
||||
|
||||
- name: minimax
|
||||
auth_mode: third_party_anthropic_compat
|
||||
model_prefixes: [minimax-]
|
||||
model_aliases: []
|
||||
base_url: https://api.minimax.io/anthropic
|
||||
auth_env: [ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY]
|
||||
|
||||
- name: zai
|
||||
auth_mode: third_party_anthropic_compat
|
||||
model_prefixes: [glm-]
|
||||
model_aliases: []
|
||||
base_url: https://api.z.ai/api/anthropic
|
||||
auth_env: [ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY]
|
||||
|
||||
- name: moonshot
|
||||
auth_mode: third_party_anthropic_compat
|
||||
model_prefixes: [kimi-]
|
||||
model_aliases: []
|
||||
base_url: https://api.moonshot.ai/anthropic
|
||||
auth_env: [ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY]
|
||||
|
||||
- name: deepseek
|
||||
auth_mode: third_party_anthropic_compat
|
||||
model_prefixes: [deepseek-]
|
||||
model_aliases: []
|
||||
base_url: https://api.deepseek.com/anthropic
|
||||
auth_env: [ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY]
|
||||
""")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def adapter(monkeypatch):
|
||||
"""Fresh ClaudeCodeAdapter with all imports stubbed."""
|
||||
@ -98,7 +163,32 @@ def adapter(monkeypatch):
|
||||
return adapter_module.ClaudeCodeAdapter()
|
||||
|
||||
|
||||
# ---- Pre-validation tests ----
|
||||
@pytest.fixture
|
||||
def configs_dir(tmp_path):
|
||||
"""Per-test /configs dir with the canonical provider registry written to
|
||||
config.yaml. Tests pass the path as ``config_path`` on _StubAdapterConfig
|
||||
so adapter.setup() reads our fixture rather than the host's real
|
||||
/configs/config.yaml (which doesn't exist in CI).
|
||||
"""
|
||||
cfg = tmp_path / "config.yaml"
|
||||
cfg.write_text(_FIXTURE_PROVIDERS_YAML)
|
||||
return str(tmp_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def empty_configs_dir(tmp_path):
|
||||
"""A /configs dir with no config.yaml — exercises the FileNotFoundError
|
||||
fallback path in _load_providers (must yield _BUILTIN_PROVIDERS).
|
||||
"""
|
||||
return str(tmp_path)
|
||||
|
||||
|
||||
# ---- create_executor pre-validation tests ----
|
||||
#
|
||||
# These exercise the 2026-04-30 hang-fix branch: ANTHROPIC_BASE_URL
|
||||
# pointed at a non-Anthropic shim with no model picked silently passes
|
||||
# 'sonnet' to the SDK, which hangs for 30s on the --print probe. The
|
||||
# adapter raises early instead.
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@ -230,65 +320,107 @@ async def test_create_executor_passes_when_unparseable_url(adapter, monkeypatch)
|
||||
assert executor is not None
|
||||
|
||||
|
||||
# ---- setup() pre-validation tests ----
|
||||
# ---- setup() provider-registry tests ----
|
||||
#
|
||||
# Symmetric to create_executor's pre-validate: setup() raises on the
|
||||
# inverse misconfig (third-party MODEL picked but ANTHROPIC_BASE_URL
|
||||
# unset). Both produce "boots but every LLM call fails" if not caught;
|
||||
# raising at boot keeps the workspace from entering "online" status with
|
||||
# unset and the resolved provider has no default base_url). Both
|
||||
# produce "boots but every LLM call fails" if not caught; raising at
|
||||
# boot keeps the workspace from entering "online" status with
|
||||
# structurally-broken auth.
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_raises_when_third_party_model_and_no_base_url(
|
||||
adapter, monkeypatch
|
||||
async def test_setup_passes_when_third_party_model_with_registered_base_url(
|
||||
adapter, monkeypatch, configs_dir
|
||||
):
|
||||
"""mimo-* model picked but no ANTHROPIC_BASE_URL → raise.
|
||||
|
||||
Without the URL, every LLM request lands on api.anthropic.com with
|
||||
a non-Anthropic key and 401s. The adapter should fail at boot
|
||||
rather than ship a workspace that 401s on every prompt.
|
||||
"""Third-party model + provider has default base_url in YAML →
|
||||
setup() auto-applies it (no operator URL needed) and runs cleanly
|
||||
through to plugin install. The Option B v2 happy path: pick mimo-
|
||||
or minimax- model in canvas, the registry handles routing.
|
||||
"""
|
||||
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
|
||||
cfg = _StubAdapterConfig(
|
||||
runtime_config={"model": "mimo-v2-flash"}, config_path="/tmp/configs"
|
||||
runtime_config={"model": "mimo-v2-flash"}, config_path=configs_dir
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
await adapter.setup(cfg)
|
||||
|
||||
msg = str(exc_info.value)
|
||||
assert "mimo-v2-flash" in msg
|
||||
assert "ANTHROPIC_BASE_URL" in msg
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_passes_when_third_party_model_with_base_url(
|
||||
adapter, monkeypatch
|
||||
):
|
||||
"""The fix path: third-party model + base URL set → setup() runs
|
||||
cleanly through to plugin install (which is a no-op stub here).
|
||||
"""
|
||||
monkeypatch.setenv(
|
||||
"ANTHROPIC_BASE_URL", "https://api.xiaomimimo.com/anthropic"
|
||||
)
|
||||
cfg = _StubAdapterConfig(
|
||||
runtime_config={"model": "mimo-v2-flash"}, config_path="/tmp/configs"
|
||||
)
|
||||
|
||||
# Should complete without raising. Plugin install is stubbed.
|
||||
await adapter.setup(cfg)
|
||||
|
||||
# Registry-default base_url should now be in env for the SDK to pick up.
|
||||
assert os.environ.get("ANTHROPIC_BASE_URL") == "https://api.xiaomimimo.com/anthropic"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_passes_when_oauth_model_no_base_url(adapter, monkeypatch):
|
||||
async def test_setup_passes_for_minimax_model(adapter, monkeypatch, configs_dir):
|
||||
"""MiniMax-M2 resolves to the minimax provider, auto-sets the MiniMax
|
||||
Anthropic-compat endpoint. Verifies registry adds new providers
|
||||
without code changes — the original motivation for the YAML registry.
|
||||
"""
|
||||
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
|
||||
cfg = _StubAdapterConfig(
|
||||
runtime_config={"model": "MiniMax-M2"}, config_path=configs_dir
|
||||
)
|
||||
|
||||
await adapter.setup(cfg)
|
||||
|
||||
assert os.environ.get("ANTHROPIC_BASE_URL") == "https://api.minimax.io/anthropic"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_minimax_case_insensitive_match(
|
||||
adapter, monkeypatch, configs_dir
|
||||
):
|
||||
"""MiniMax docs use mixed-case ids (MiniMax-M2.7); some operators may
|
||||
type minimax-m2.7. Both must resolve to the same provider — registry
|
||||
matches lowercased prefixes.
|
||||
"""
|
||||
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
|
||||
cfg = _StubAdapterConfig(
|
||||
runtime_config={"model": "minimax-m2.7"}, config_path=configs_dir
|
||||
)
|
||||
|
||||
await adapter.setup(cfg)
|
||||
|
||||
assert os.environ.get("ANTHROPIC_BASE_URL") == "https://api.minimax.io/anthropic"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_operator_base_url_overrides_registry_default(
|
||||
adapter, monkeypatch, configs_dir
|
||||
):
|
||||
"""Operator-set ANTHROPIC_BASE_URL wins over the provider's default —
|
||||
escape hatch for regional endpoints (Xiaomi token-plan-sgp.*,
|
||||
MiniMax api.minimaxi.com China endpoint). Pinning this so a future
|
||||
refactor can't quietly clobber the override.
|
||||
"""
|
||||
monkeypatch.setenv(
|
||||
"ANTHROPIC_BASE_URL",
|
||||
"https://token-plan-sgp.xiaomimimo.com/anthropic",
|
||||
)
|
||||
cfg = _StubAdapterConfig(
|
||||
runtime_config={"model": "mimo-v2-flash"}, config_path=configs_dir
|
||||
)
|
||||
|
||||
await adapter.setup(cfg)
|
||||
|
||||
# Operator value untouched — adapter must not overwrite.
|
||||
assert (
|
||||
os.environ.get("ANTHROPIC_BASE_URL")
|
||||
== "https://token-plan-sgp.xiaomimimo.com/anthropic"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_passes_when_oauth_model_no_base_url(
|
||||
adapter, monkeypatch, configs_dir
|
||||
):
|
||||
"""OAuth-aliased models (sonnet/opus/haiku) are Anthropic-native; no
|
||||
base URL is required. setup() must not raise on the OAuth path even
|
||||
though base_url is unset — that's the historical happy path.
|
||||
"""
|
||||
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
|
||||
cfg = _StubAdapterConfig(
|
||||
runtime_config={"model": "sonnet"}, config_path="/tmp/configs"
|
||||
runtime_config={"model": "sonnet"}, config_path=configs_dir
|
||||
)
|
||||
|
||||
await adapter.setup(cfg)
|
||||
@ -296,7 +428,7 @@ async def test_setup_passes_when_oauth_model_no_base_url(adapter, monkeypatch):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_passes_when_anthropic_api_model_no_base_url(
|
||||
adapter, monkeypatch
|
||||
adapter, monkeypatch, configs_dir
|
||||
):
|
||||
"""claude-* versioned ids are Anthropic API-key path; base URL
|
||||
optional (defaults to api.anthropic.com). setup() must not raise.
|
||||
@ -304,7 +436,210 @@ async def test_setup_passes_when_anthropic_api_model_no_base_url(
|
||||
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
|
||||
cfg = _StubAdapterConfig(
|
||||
runtime_config={"model": "claude-sonnet-4-6"},
|
||||
config_path="/tmp/configs",
|
||||
config_path=configs_dir,
|
||||
)
|
||||
|
||||
await adapter.setup(cfg)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_falls_back_to_builtin_when_yaml_missing(
|
||||
adapter, monkeypatch, empty_configs_dir
|
||||
):
|
||||
"""No config.yaml in the configs dir → _load_providers falls back to
|
||||
_BUILTIN_PROVIDERS (oauth + anthropic-api only). OAuth-aliased models
|
||||
must still resolve cleanly so a bare-bones workspace boots even if
|
||||
config.yaml is missing or malformed.
|
||||
"""
|
||||
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
|
||||
cfg = _StubAdapterConfig(
|
||||
runtime_config={"model": "sonnet"}, config_path=empty_configs_dir
|
||||
)
|
||||
|
||||
await adapter.setup(cfg)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_raises_when_yaml_missing_and_third_party_model(
|
||||
adapter, monkeypatch, empty_configs_dir
|
||||
):
|
||||
"""No config.yaml + third-party model picked → builtin registry has no
|
||||
matching prefix → resolves to the OAuth fallback (provider[0]). The
|
||||
user picked a model the builtin can't route, so OAuth's auth_env
|
||||
won't have the right key, but it won't raise here — auth check is
|
||||
a warning, not an error. setup() should complete (no third-party
|
||||
misconfig fires because the fallback isn't third-party).
|
||||
|
||||
Documented behavior: when YAML is missing, third-party models are
|
||||
silently downgraded to OAuth fallback. Operators must fix their
|
||||
config.yaml to get correct routing. This test pins that the failure
|
||||
mode is "warning + boots" rather than "raises" (helps debug-vs-recover
|
||||
triage when CI loses the YAML somehow).
|
||||
"""
|
||||
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
|
||||
cfg = _StubAdapterConfig(
|
||||
runtime_config={"model": "mimo-v2-flash"}, config_path=empty_configs_dir
|
||||
)
|
||||
|
||||
# No raise — falls back to OAuth provider, third-party gate doesn't fire.
|
||||
await adapter.setup(cfg)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_auth_token_alone_satisfies_third_party_check(
|
||||
adapter, monkeypatch, configs_dir, caplog
|
||||
):
|
||||
"""MiniMax docs prefer ANTHROPIC_AUTH_TOKEN over ANTHROPIC_API_KEY.
|
||||
The provider entry lists both as accepted; setting only AUTH_TOKEN
|
||||
must NOT trigger the "no auth env set" warning.
|
||||
"""
|
||||
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
|
||||
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
||||
monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "test-minimax-token")
|
||||
cfg = _StubAdapterConfig(
|
||||
runtime_config={"model": "MiniMax-M2"}, config_path=configs_dir
|
||||
)
|
||||
|
||||
import logging
|
||||
with caplog.at_level(logging.WARNING):
|
||||
await adapter.setup(cfg)
|
||||
|
||||
auth_warnings = [r for r in caplog.records if "AuthenticationError" in r.getMessage()]
|
||||
assert auth_warnings == [], (
|
||||
"ANTHROPIC_AUTH_TOKEN alone should satisfy minimax provider auth "
|
||||
"but adapter logged a missing-auth warning anyway"
|
||||
)
|
||||
|
||||
|
||||
# ---- _load_providers / _resolve_provider unit tests ----
|
||||
|
||||
|
||||
def test_load_providers_returns_builtin_when_yaml_missing(tmp_path):
|
||||
"""FileNotFoundError path returns the in-code defaults verbatim."""
|
||||
_install_stubs()
|
||||
parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if parent_dir not in sys.path:
|
||||
sys.path.insert(0, parent_dir)
|
||||
sys.modules.pop("adapter", None)
|
||||
import adapter as adapter_module
|
||||
|
||||
result = adapter_module._load_providers(str(tmp_path))
|
||||
assert result == adapter_module._BUILTIN_PROVIDERS
|
||||
|
||||
|
||||
def test_load_providers_parses_yaml_and_normalizes(tmp_path):
|
||||
"""YAML present + parses → normalized tuple of provider dicts."""
|
||||
_install_stubs()
|
||||
parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if parent_dir not in sys.path:
|
||||
sys.path.insert(0, parent_dir)
|
||||
sys.modules.pop("adapter", None)
|
||||
import adapter as adapter_module
|
||||
|
||||
(tmp_path / "config.yaml").write_text(_FIXTURE_PROVIDERS_YAML)
|
||||
result = adapter_module._load_providers(str(tmp_path))
|
||||
|
||||
assert len(result) == 7
|
||||
names = [p["name"] for p in result]
|
||||
assert names == [
|
||||
"anthropic-oauth", "anthropic-api", "xiaomi-mimo", "minimax",
|
||||
"zai", "moonshot", "deepseek",
|
||||
]
|
||||
# YAML lists must be normalized to tuples for downstream lookup ergonomics.
|
||||
assert isinstance(result[0]["model_aliases"], tuple)
|
||||
assert isinstance(result[2]["model_prefixes"], tuple)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model,expected_provider,expected_url", [
|
||||
("GLM-4.6", "zai", "https://api.z.ai/api/anthropic"),
|
||||
("glm-4.5", "zai", "https://api.z.ai/api/anthropic"),
|
||||
("kimi-k2.5", "moonshot", "https://api.moonshot.ai/anthropic"),
|
||||
("deepseek-v4-pro", "deepseek", "https://api.deepseek.com/anthropic"),
|
||||
])
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_routes_extra_providers(
|
||||
adapter, monkeypatch, configs_dir, model, expected_provider, expected_url
|
||||
):
|
||||
"""The Z.ai / Moonshot / DeepSeek providers added in this PR must
|
||||
route correctly: model id → provider entry → ANTHROPIC_BASE_URL.
|
||||
Parametrized to keep the matrix coverage tight without 3 near-identical
|
||||
test bodies. Locks in the per-vendor base_url so a future YAML edit
|
||||
that mistypes z.ai's `/api/anthropic` suffix gets caught.
|
||||
"""
|
||||
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
|
||||
cfg = _StubAdapterConfig(
|
||||
runtime_config={"model": model}, config_path=configs_dir
|
||||
)
|
||||
|
||||
await adapter.setup(cfg)
|
||||
|
||||
assert os.environ.get("ANTHROPIC_BASE_URL") == expected_url
|
||||
|
||||
|
||||
def test_load_providers_falls_back_on_malformed_yaml(tmp_path, caplog):
|
||||
"""Malformed YAML → log warning + fallback (don't kill boot)."""
|
||||
_install_stubs()
|
||||
parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if parent_dir not in sys.path:
|
||||
sys.path.insert(0, parent_dir)
|
||||
sys.modules.pop("adapter", None)
|
||||
import adapter as adapter_module
|
||||
|
||||
(tmp_path / "config.yaml").write_text("providers: [not valid yaml: {{{")
|
||||
|
||||
import logging
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result = adapter_module._load_providers(str(tmp_path))
|
||||
|
||||
assert result == adapter_module._BUILTIN_PROVIDERS
|
||||
|
||||
|
||||
def test_resolve_provider_minimax_prefix_matches_minimax_provider():
|
||||
"""The headline routing test: MiniMax-M2 lands on the minimax entry."""
|
||||
_install_stubs()
|
||||
parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if parent_dir not in sys.path:
|
||||
sys.path.insert(0, parent_dir)
|
||||
sys.modules.pop("adapter", None)
|
||||
import adapter as adapter_module
|
||||
|
||||
providers = tuple(
|
||||
adapter_module._normalize_provider(p) for p in [
|
||||
{"name": "anthropic-oauth", "auth_mode": "oauth",
|
||||
"model_aliases": ["sonnet"], "auth_env": ["CLAUDE_CODE_OAUTH_TOKEN"]},
|
||||
{"name": "minimax", "auth_mode": "third_party_anthropic_compat",
|
||||
"model_prefixes": ["minimax-"],
|
||||
"base_url": "https://api.minimax.io/anthropic",
|
||||
"auth_env": ["ANTHROPIC_AUTH_TOKEN"]},
|
||||
]
|
||||
)
|
||||
|
||||
result = adapter_module._resolve_provider("MiniMax-M2", providers)
|
||||
assert result["name"] == "minimax"
|
||||
|
||||
# Case insensitivity also exercised.
|
||||
result2 = adapter_module._resolve_provider("minimax-m2.7", providers)
|
||||
assert result2["name"] == "minimax"
|
||||
|
||||
|
||||
def test_resolve_provider_falls_back_to_first_when_unknown():
|
||||
"""Unknown model id → fallback to first provider (OAuth by convention)."""
|
||||
_install_stubs()
|
||||
parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if parent_dir not in sys.path:
|
||||
sys.path.insert(0, parent_dir)
|
||||
sys.modules.pop("adapter", None)
|
||||
import adapter as adapter_module
|
||||
|
||||
providers = tuple(
|
||||
adapter_module._normalize_provider(p) for p in [
|
||||
{"name": "anthropic-oauth", "auth_mode": "oauth",
|
||||
"auth_env": ["CLAUDE_CODE_OAUTH_TOKEN"]},
|
||||
{"name": "minimax", "auth_mode": "third_party_anthropic_compat",
|
||||
"model_prefixes": ["minimax-"],
|
||||
"auth_env": ["ANTHROPIC_AUTH_TOKEN"]},
|
||||
]
|
||||
)
|
||||
|
||||
result = adapter_module._resolve_provider("some-unknown-model", providers)
|
||||
assert result["name"] == "anthropic-oauth"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user