From c6f4912d0973dfcc437fac639b17bbd02e5f8010 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Thu, 30 Apr 2026 23:29:40 -0700 Subject: [PATCH 1/5] feat(adapter): data-driven provider registry in config.yaml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- adapter.py | 221 ++++++++++----- config.yaml | 144 +++++++++- entrypoint.sh | 37 +-- tests/test_adapter_prevalidate.py | 433 ++++++++++++++++++++++++++---- 4 files changed, 675 insertions(+), 160 deletions(-) diff --git a/adapter.py b/adapter.py index 6b871c7..dc38ab9 100644 --- a/adapter.py +++ b/adapter.py @@ -15,46 +15,104 @@ logger = logging.getLogger(__name__) # the workspace by polling /transcript?limit=999999. _TRANSCRIPT_MAX_LIMIT = 1000 -# Auth-mode classification for a selected model id. The Claude Code CLI -# accepts three auth paths and the right env var differs per path; warning -# at boot about the wrong var (the pre-multi-provider behavior) misled -# operators who picked an API-key or third-party model. New third-party -# providers add a prefix → mode entry below + a model-prefix → base-URL -# mapping in entrypoint.sh until the data-driven `runtime_env` schema -# field lands platform-side. +# Auth-mode constants — provider entries use one of these strings. +# Drives validation behavior in setup() (third-party requires base_url +# resolution; oauth/anthropic-api leave base_url=None for CLI defaults). _AUTH_MODE_OAUTH = "oauth" _AUTH_MODE_ANTHROPIC_API = "anthropic_api" _AUTH_MODE_THIRD_PARTY = "third_party_anthropic_compat" -_THIRD_PARTY_PREFIXES = ("mimo-",) -_OAUTH_ALIASES = frozenset({"sonnet", "opus", "haiku"}) +# Built-in provider registry — used as a fallback when /configs/config.yaml +# doesn't define `providers:`. The canonical registry is the YAML file: it +# becomes the single source of truth read by both this adapter (for boot-time +# routing) and the canvas Config tab (Provider dropdown). Adding a new +# provider should be a one-line YAML edit, not a code change. This builtin +# exists so a workspace with a malformed/missing config.yaml still boots +# with sensible defaults instead of failing. +_BUILTIN_PROVIDERS = ( + { + "name": "anthropic-oauth", + "auth_mode": _AUTH_MODE_OAUTH, + "model_prefixes": (), + "model_aliases": ("sonnet", "opus", "haiku"), + "base_url": None, + "auth_env": ("CLAUDE_CODE_OAUTH_TOKEN",), + }, + { + "name": "anthropic-api", + "auth_mode": _AUTH_MODE_ANTHROPIC_API, + "model_prefixes": ("claude-",), + "model_aliases": (), + "base_url": None, + "auth_env": ("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"), + }, +) -def _detect_auth_mode(model: str) -> str: - """Classify the picked model into one of three auth paths. +def _normalize_provider(entry: dict) -> dict: + """Coerce a YAML-loaded provider dict into the shape adapter logic expects. - Used by setup() to validate the right env var is set so operators see - the misconfiguration at boot instead of on the first LLM call. - Unknown ids default to OAuth — the historical default and the safest - fallback for the warning path. + YAML gives us lists (not tuples) and may omit optional keys. Normalize + to the union of all fields so downstream lookups work without scattered + .get(...) calls. """ + return { + "name": entry.get("name") or "", + "auth_mode": entry.get("auth_mode") or _AUTH_MODE_OAUTH, + "model_prefixes": tuple(p.lower() for p in entry.get("model_prefixes") or ()), + "model_aliases": tuple(a.lower() for a in entry.get("model_aliases") or ()), + "base_url": entry.get("base_url") or None, + "auth_env": tuple(entry.get("auth_env") or ()), + } + + +def _load_providers(config_path: str) -> tuple: + """Load the provider registry from /configs/config.yaml. + + The YAML's top-level ``providers:`` list is the canonical source — + canvas Config tab reads the same list to populate its Provider + dropdown so the UI and the adapter never disagree on what's + available. Falls back to ``_BUILTIN_PROVIDERS`` (oauth + anthropic-api) + if the file is missing, malformed, or has no providers section, so a + bare-bones workspace still boots with the historical defaults. + + Mode mismatches (e.g. a provider entry without a name) are logged + but don't fail the load — better-something-than-nothing for boot. + """ + yaml_path = os.path.join(config_path, "config.yaml") + try: + import yaml # transitive dep via molecule-ai-workspace-runtime + with open(yaml_path, "r") as f: + data = yaml.safe_load(f) or {} + raw = data.get("providers") + if isinstance(raw, list) and raw: + return tuple(_normalize_provider(p) for p in raw if isinstance(p, dict)) + except FileNotFoundError: + logger.info("providers: %s not found, using builtin defaults", yaml_path) + except Exception as exc: # noqa: BLE001 — defensive: never block boot on YAML + logger.warning("providers: failed to load from %s (%s); using builtins", yaml_path, exc) + return _BUILTIN_PROVIDERS + + +def _resolve_provider(model: str, providers: tuple) -> dict: + """Return the provider entry matching this model id. + + Match is case-insensitive: prefix wins over alias when both could + apply. Unknown ids fall back to the first provider in the registry + (by convention, the OAuth/safest default). + """ + fallback = providers[0] if providers else _normalize_provider({}) if not model: - return _AUTH_MODE_OAUTH + return fallback m = model.lower() - if any(m.startswith(p) for p in _THIRD_PARTY_PREFIXES): - return _AUTH_MODE_THIRD_PARTY - if m.startswith("claude-"): - return _AUTH_MODE_ANTHROPIC_API - if m in _OAUTH_ALIASES: - return _AUTH_MODE_OAUTH - return _AUTH_MODE_OAUTH - - -def _required_env_for_mode(mode: str) -> str: - """The env var the claude CLI needs to authenticate for a given mode.""" - if mode == _AUTH_MODE_OAUTH: - return "CLAUDE_CODE_OAUTH_TOKEN" - return "ANTHROPIC_API_KEY" + for provider in providers: + for prefix in provider["model_prefixes"]: + if prefix and m.startswith(prefix): + return provider + for provider in providers: + if m in provider["model_aliases"]: + return provider + return fallback class ClaudeCodeAdapter(BaseAdapter): @@ -136,63 +194,86 @@ class ClaudeCodeAdapter(BaseAdapter): ``CLAUDE.md`` and ``/configs/skills/`` natively, and the default :class:`AgentskillsAdaptor` writes to both. """ - # KI-001 fix, generalized for the three auth paths the CLI supports: - # OAuth (CLAUDE_CODE_OAUTH_TOKEN), Anthropic API (ANTHROPIC_API_KEY), - # and third-party Anthropic-API-compat (ANTHROPIC_API_KEY + provider - # ANTHROPIC_BASE_URL). Detect the path from the picked model so the - # warning targets the *right* env var — the pre-multi-provider code - # always warned about CLAUDE_CODE_OAUTH_TOKEN even when the user had - # legitimately picked an API-key model and set ANTHROPIC_API_KEY. + # Load provider registry from /configs/config.yaml — canvas reads + # the same YAML for its Config-tab Provider dropdown so adapter + + # UI never disagree on what's available. Adding a new provider is + # a one-line YAML edit (no code change in this file or entrypoint.sh). + providers = _load_providers(config.config_path) + + # Resolve the picked model to a provider entry, then drive auth-env + # validation + ANTHROPIC_BASE_URL routing from that single decision. rc = config.runtime_config if isinstance(rc, dict): picked_model = rc.get("model") or "sonnet" else: picked_model = getattr(rc, "model", None) or "sonnet" - auth_mode = _detect_auth_mode(picked_model) - required_var = _required_env_for_mode(auth_mode) + provider = _resolve_provider(picked_model, providers) + auth_env_options = provider["auth_env"] - # Single-line startup banner — operators reading boot logs can see - # which provider path was selected and whether ANTHROPIC_BASE_URL - # (set by entrypoint.sh for third-party mimo-*) took effect. URL is - # logged as host-only; defensive against credential-shaped query - # strings even though base_url shouldn't carry one. - base_url = os.environ.get("ANTHROPIC_BASE_URL") + # Endpoint precedence: operator-set ANTHROPIC_BASE_URL wins (escape + # hatch for custom regional endpoints — e.g. token-plan-sgp.* for + # Xiaomi MiMo, api.minimaxi.com for MiniMax China). Otherwise the + # provider's default base_url is auto-applied so the operator + # picking a provider in the platform UI doesn't *also* have to + # paste a URL. Anthropic-native paths (oauth, anthropic_api) leave + # base_url=None and let the CLI's built-in default take effect. + explicit_base_url = os.environ.get("ANTHROPIC_BASE_URL") + if explicit_base_url: + effective_base_url = explicit_base_url + base_url_source = "operator-override" + elif provider["base_url"]: + os.environ["ANTHROPIC_BASE_URL"] = provider["base_url"] + effective_base_url = provider["base_url"] + base_url_source = f"provider={provider['name']}" + else: + effective_base_url = None + base_url_source = "anthropic-default" + + # Boot banner — operators reading workspace logs see which provider + # was selected, where the URL came from, and which auth env var + # the adapter expects. Cheap diagnostic; cuts root-cause-finding + # time when an LLM call fails downstream. base_url_host = "" - if base_url: + if effective_base_url: try: - base_url_host = urlparse(base_url).netloc or "" + base_url_host = urlparse(effective_base_url).netloc or "" except Exception: base_url_host = "" logger.info( - "Claude Code adapter starting: model=%s auth_mode=%s required_env=%s%s", - picked_model, auth_mode, required_var, - f" base_url_host={base_url_host}" if base_url_host else "", + "Claude Code adapter starting: model=%s provider=%s auth_mode=%s " + "base_url=%s (%s) auth_env=%s", + picked_model, provider["name"], provider["auth_mode"], + base_url_host or "anthropic-default", base_url_source, + "/".join(auth_env_options), ) - if not os.environ.get(required_var): + # Auth check — any of the provider's accepted env vars satisfies. + # Warning (not raise) so a workspace can still boot for non-LLM + # work (terminal, file editing) while the operator sets the key. + if not any(os.environ.get(v) for v in auth_env_options): logger.warning( - "%s is not set for model=%s (auth_mode=%s) — the adapter will fail " - "on the first LLM call with an AuthenticationError. Set the env " - "var or configure the key in your platform workspace settings.", - required_var, picked_model, auth_mode, + "None of %s set for model=%s (provider=%s) — the adapter " + "will fail on the first LLM call with AuthenticationError. " + "Set one of these env vars in workspace secrets.", + "/".join(auth_env_options), picked_model, provider["name"], ) - # Third-party paths additionally need ANTHROPIC_BASE_URL; entrypoint.sh - # sets it for known mimo-* prefixes. Fail fast on the missing-base-URL - # combo — the symptom otherwise is the CLI silently hitting - # api.anthropic.com with a non-Anthropic key, every LLM call 401s, and - # the workspace looks "online" while being structurally broken. - # Symmetric with create_executor's pre-validate raise on the inverse - # combo (URL set, no model picked) — both unrecoverable misconfigs - # that would put the workspace into a "boots but never works" state. - if auth_mode == _AUTH_MODE_THIRD_PARTY and not base_url: + # Third-party providers must end up with a base_url one way or + # another (provider default OR operator override). If neither, the + # CLI silently hits api.anthropic.com with a non-Anthropic key and + # every call 401s — workspace looks "online" but is structurally + # broken. Symmetric with create_executor's pre-validate raise on + # the inverse misconfig. The provider registry guarantees a default + # for every third-party we ship, so this fires only if a future + # provider entry forgets to set base_url. + if (provider["auth_mode"] == _AUTH_MODE_THIRD_PARTY + and not effective_base_url): raise ValueError( - f"claude-code adapter: model={picked_model} is a third-party " - "Anthropic-compat model but ANTHROPIC_BASE_URL is unset. " - "Without it, requests land on api.anthropic.com with a " - "non-Anthropic key and 401 every call. Fix: check " - "entrypoint.sh's model→base-URL mapping for this model " - "prefix, or set ANTHROPIC_BASE_URL as a workspace secret." + f"claude-code adapter: model={picked_model} resolved to " + f"third-party provider={provider['name']} but no " + "ANTHROPIC_BASE_URL is configured (provider has no default " + "and operator didn't set one). Add base_url to the provider " + "entry in adapter.py or set ANTHROPIC_BASE_URL via secrets." ) from molecule_runtime.plugins import load_plugins diff --git a/config.yaml b/config.yaml index f3c18a5..f7558ce 100644 --- a/config.yaml +++ b/config.yaml @@ -4,13 +4,91 @@ description: >- (1) Claude Code subscription via OAuth token (CLAUDE_CODE_OAUTH_TOKEN, obtained from `claude login`), (2) Anthropic API key (ANTHROPIC_API_KEY, pay-as-you-go via console.anthropic.com), or - (3) third-party Anthropic-API-compatible providers (e.g. Xiaomi MiMo) - via ANTHROPIC_API_KEY + provider-specific ANTHROPIC_BASE_URL routing. - The `claude` CLI picks whichever is set; OAuth takes precedence when - multiple are present. + (3) third-party Anthropic-API-compatible providers (e.g. Xiaomi MiMo, + MiniMax) via ANTHROPIC_AUTH_TOKEN/ANTHROPIC_API_KEY + provider-specific + ANTHROPIC_BASE_URL routing. The `claude` CLI picks whichever is set; + OAuth takes precedence when multiple are present. version: 1.0.0 tier: 2 +# Provider registry — single source of truth for model→endpoint→auth +# routing. The adapter reads this list at boot to resolve the picked +# model to a provider, auto-set ANTHROPIC_BASE_URL, and validate the +# right auth env var. The canvas Config tab reads the same list to +# render its Provider dropdown — UI and adapter never disagree on +# what's available. +# +# Adding a new provider = one entry below. No adapter or entrypoint.sh +# code changes needed. Operator override always wins: setting +# ANTHROPIC_BASE_URL as a workspace secret bypasses the auto-routing +# (useful for regional endpoints like Xiaomi's token-plan-sgp.* or +# MiniMax's api.minimaxi.com China endpoint). +# +# Schema per entry: +# name : human-readable label (boot banner + UI dropdown) +# auth_mode : "oauth" | "anthropic_api" | "third_party_anthropic_compat" +# model_prefixes : lowercase model-id prefixes (e.g. ["mimo-", "minimax-"]) +# model_aliases : exact lowercase ids (e.g. ["sonnet", "opus"]) +# base_url : ANTHROPIC_BASE_URL to set; null = CLI default (anthropic-native) +# auth_env : env vars accepted; any one being set satisfies auth +providers: + - name: anthropic-oauth + auth_mode: oauth + model_prefixes: [] + model_aliases: [sonnet, opus, haiku] + base_url: null + auth_env: [CLAUDE_CODE_OAUTH_TOKEN] + + - name: anthropic-api + auth_mode: anthropic_api + model_prefixes: [claude-] + model_aliases: [] + base_url: null + auth_env: [ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN] + + - name: xiaomi-mimo + auth_mode: third_party_anthropic_compat + model_prefixes: [mimo-] + model_aliases: [] + base_url: https://api.xiaomimimo.com/anthropic + auth_env: [ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY] + + - name: minimax + auth_mode: third_party_anthropic_compat + model_prefixes: [minimax-] + model_aliases: [] + base_url: https://api.minimax.io/anthropic + auth_env: [ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY] + + # Z.ai — GLM family. docs.z.ai/scenario-example/develop-tools/claude. + # Model ids are uppercase (GLM-4.6) but the registry lowercases for + # matching, so the `glm-` prefix catches both `GLM-4.6` and `glm-4.6`. + - name: zai + auth_mode: third_party_anthropic_compat + model_prefixes: [glm-] + model_aliases: [] + base_url: https://api.z.ai/api/anthropic + auth_env: [ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY] + + # Moonshot AI — Kimi family. platform.kimi.ai/docs/guide/agent-support. + - name: moonshot + auth_mode: third_party_anthropic_compat + model_prefixes: [kimi-] + model_aliases: [] + base_url: https://api.moonshot.ai/anthropic + auth_env: [ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY] + + # DeepSeek — api-docs.deepseek.com/guides/anthropic_api. Note: their + # endpoint silently maps unknown model ids to deepseek-v4-flash, so a + # typo lands on a working-but-wrong-tier model rather than 400ing. + # Worth flagging in operator-facing docs. + - name: deepseek + auth_mode: third_party_anthropic_compat + model_prefixes: [deepseek-] + model_aliases: [] + base_url: https://api.deepseek.com/anthropic + auth_env: [ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY] + runtime: claude-code runtime_config: model: sonnet @@ -45,13 +123,11 @@ runtime_config: name: Claude Haiku 4.5 (API key / Anthropic Console) required_env: [ANTHROPIC_API_KEY] - # --- Xiaomi MiMo (third-party, Anthropic-API-compatible) — set ANTHROPIC_API_KEY --- - # Routed through https://api.xiaomimimo.com/anthropic via ANTHROPIC_BASE_URL - # (the claude CLI honors the env var natively). Mapping lives in - # entrypoint.sh — when MODEL matches mimo-*, base URL is rewritten before - # the runtime starts. The user's ANTHROPIC_API_KEY here is a Xiaomi key, - # not an Anthropic Console key. Long-term, this should move to a - # data-driven `runtime_env` schema field; tracked separately. + # --- Xiaomi MiMo (third-party, Anthropic-API-compatible) --- + # Routed via the `xiaomi-mimo` provider entry above (base_url and + # auth_env are resolved from the registry — the adapter sets + # ANTHROPIC_BASE_URL automatically based on the model prefix). Either + # ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY satisfies auth — both work. - id: mimo-v2-flash name: Xiaomi MiMo V2 Flash (third-party, Anthropic-API-compatible) required_env: [ANTHROPIC_API_KEY] @@ -61,10 +137,56 @@ runtime_config: - id: mimo-v2-omni name: Xiaomi MiMo V2 Omni (third-party, Anthropic-API-compatible) required_env: [ANTHROPIC_API_KEY] + - id: mimo-v2.5 + name: Xiaomi MiMo V2.5 (third-party, Anthropic-API-compatible) + required_env: [ANTHROPIC_API_KEY] - id: mimo-v2.5-pro name: Xiaomi MiMo V2.5 Pro (third-party, Anthropic-API-compatible) required_env: [ANTHROPIC_API_KEY] + # --- MiniMax (third-party, Anthropic-API-compatible) --- + # Routed via the `minimax` provider entry above. MiniMax docs prefer + # ANTHROPIC_AUTH_TOKEN (Bearer-style) — see platform.minimax.io/docs/token-plan/claude-code. + # ANTHROPIC_API_KEY also works (the claude CLI accepts both). + - id: MiniMax-M2 + name: MiniMax M2 (third-party, Anthropic-API-compatible) + required_env: [ANTHROPIC_AUTH_TOKEN] + - id: MiniMax-M2.7 + name: MiniMax M2.7 (third-party, Anthropic-API-compatible) + required_env: [ANTHROPIC_AUTH_TOKEN] + + # --- Z.ai GLM family (third-party, Anthropic-API-compatible) --- + # Routed via the `zai` provider entry. docs.z.ai for the full + # Anthropic-compat docs. GLM-4.6 is the current-gen flagship; 4.5 + # remains for users on legacy quotas. + - id: GLM-4.6 + name: Z.ai GLM-4.6 (third-party, Anthropic-API-compatible) + required_env: [ANTHROPIC_AUTH_TOKEN] + - id: GLM-4.5 + name: Z.ai GLM-4.5 (third-party, Anthropic-API-compatible) + required_env: [ANTHROPIC_AUTH_TOKEN] + + # --- Moonshot AI Kimi family (third-party, Anthropic-API-compatible) --- + # Routed via the `moonshot` provider entry. platform.kimi.ai for docs. + # K2.5 is the latest agentic-coding tier; K2 stays as a cheaper option. + - id: kimi-k2.5 + name: Moonshot Kimi K2.5 (third-party, Anthropic-API-compatible) + required_env: [ANTHROPIC_AUTH_TOKEN] + - id: kimi-k2 + name: Moonshot Kimi K2 (third-party, Anthropic-API-compatible) + required_env: [ANTHROPIC_AUTH_TOKEN] + + # --- DeepSeek (third-party, Anthropic-API-compatible) --- + # Routed via the `deepseek` provider entry. api-docs.deepseek.com. + # Note: unknown deepseek-* ids silently fall back to v4-flash on + # DeepSeek's side — pick the exact tier you mean. + - id: deepseek-v4-pro + name: DeepSeek V4 Pro (third-party, Anthropic-API-compatible) + required_env: [ANTHROPIC_AUTH_TOKEN] + - id: deepseek-v4-flash + name: DeepSeek V4 Flash (third-party, Anthropic-API-compatible) + required_env: [ANTHROPIC_AUTH_TOKEN] + # Default required_env — per-model entries above override this once a # model is picked. Keep CLAUDE_CODE_OAUTH_TOKEN as the default so # existing workspaces (which all use OAuth) keep working unchanged. diff --git a/entrypoint.sh b/entrypoint.sh index a605cde..396f2a0 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -81,35 +81,12 @@ elif [ -n "${GH_TOKEN:-}" ]; then echo "${GH_TOKEN}" | gh auth login --hostname github.com --with-token 2>/dev/null || true fi -# Third-party Anthropic-API-compatible provider routing. -# The `claude` CLI honors ANTHROPIC_BASE_URL natively; we rewrite it -# based on MODEL so a Xiaomi MiMo selection lands on Xiaomi's endpoint -# without code changes inside the SDK. ANTHROPIC_API_KEY in this case -# is the third-party provider key, not an Anthropic Console key. -# -# Refuses to clobber an operator-set ANTHROPIC_BASE_URL — if the user -# provided one explicitly via secrets (e.g. a Xiaomi MiMo Token Plan -# endpoint such as https://token-plan-sgp.xiaomimimo.com/anthropic), -# that wins. The mapping below is only the fallback for known model -# prefixes. -# -# Supported Xiaomi MiMo endpoints: -# - Pay-as-you-go: https://api.xiaomimimo.com/anthropic -# - Token Plan SG: https://token-plan-sgp.xiaomimimo.com/anthropic -# - Token Plan HK: https://token-plan-hk.xiaomimimo.com/anthropic -# (Set ANTHROPIC_BASE_URL explicitly to use a specific endpoint.) -# -# Long-term this should move to a data-driven `runtime_env` field in -# config.yaml read by the platform provisioner; tracked separately. -case "${MODEL:-}" in - mimo-*) - if [ -z "${ANTHROPIC_BASE_URL:-}" ]; then - export ANTHROPIC_BASE_URL="https://api.xiaomimimo.com/anthropic" - echo "[entrypoint] MODEL=${MODEL} → ANTHROPIC_BASE_URL=${ANTHROPIC_BASE_URL}" >&2 - else - echo "[entrypoint] MODEL=${MODEL} but ANTHROPIC_BASE_URL already set; not overriding" >&2 - fi - ;; -esac +# Third-party provider routing is now handled by adapter.py at boot — +# it reads the `providers:` registry from /configs/config.yaml and sets +# ANTHROPIC_BASE_URL based on the picked MODEL. Adding a new provider +# is a one-line YAML edit (see config.yaml's `providers:` section). +# Operator-set ANTHROPIC_BASE_URL still wins as the escape hatch for +# regional endpoints (e.g. Xiaomi's token-plan-sgp.*, MiniMax's +# api.minimaxi.com China endpoint). exec molecule-runtime "$@" diff --git a/tests/test_adapter_prevalidate.py b/tests/test_adapter_prevalidate.py index 243140d..3a053f4 100644 --- a/tests/test_adapter_prevalidate.py +++ b/tests/test_adapter_prevalidate.py @@ -1,19 +1,23 @@ -"""Unit tests for ClaudeCodeAdapter.create_executor pre-validation. +"""Unit tests for ClaudeCodeAdapter.setup + create_executor. -Pin the failure-mode-caught-on-2026-04-30 (workspaces with -ANTHROPIC_BASE_URL pointing at a MiniMax/OpenAI shim and no explicit -model hung on the SDK --print probe for 30s, eventually triggering -the platform's phantom-busy sweep). +Two surfaces under test: + 1. setup() — provider-registry loading + auth-env validation + + base_url resolution. Pins the post-2026-04-30 architecture where + the model→provider mapping lives in /configs/config.yaml's + `providers:` list (canonical) with `_BUILTIN_PROVIDERS` as the + malformed-YAML fallback. + 2. create_executor() — the 2026-04-30 hang fix (custom upstream + no + model = raise instead of silently passing 'sonnet' to the SDK). -These tests exercise the pre-validation branch in create_executor -without booting the actual ClaudeSDKExecutor — we mock the import -so we can drive the validation logic in isolation. +These tests stub the import dependencies (molecule_runtime, a2a, +claude_sdk_executor) so they can run without the real packages installed. """ import os import sys +import textwrap import types -from dataclasses import dataclass, field +from dataclasses import dataclass from unittest.mock import MagicMock import pytest @@ -26,7 +30,7 @@ import pytest # - a2a.server.agent_execution (AgentExecutor) # create_executor lazily imports claude_sdk_executor.ClaudeSDKExecutor. # We stub all four so the test file can run in CI without those packages -# installed. The pre-validation branch we care about runs BEFORE the +# installed. The pre-validation branches we care about run BEFORE the # executor instantiates, so the stub doesn't affect what we're testing. @@ -80,6 +84,67 @@ def _install_stubs(): sys.modules["claude_sdk_executor"] = mod +# ---- Fixtures ---- + + +# Canonical provider registry used by most setup() tests. Mirrors the +# real config.yaml's `providers:` list — kept inline here so a config.yaml +# rename/edit doesn't silently change test semantics. If the prod +# registry ever drifts from this fixture, the divergence is intentional +# and visible in the diff. +_FIXTURE_PROVIDERS_YAML = textwrap.dedent(""" + providers: + - name: anthropic-oauth + auth_mode: oauth + model_prefixes: [] + model_aliases: [sonnet, opus, haiku] + base_url: null + auth_env: [CLAUDE_CODE_OAUTH_TOKEN] + + - name: anthropic-api + auth_mode: anthropic_api + model_prefixes: [claude-] + model_aliases: [] + base_url: null + auth_env: [ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN] + + - name: xiaomi-mimo + auth_mode: third_party_anthropic_compat + model_prefixes: [mimo-] + model_aliases: [] + base_url: https://api.xiaomimimo.com/anthropic + auth_env: [ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY] + + - name: minimax + auth_mode: third_party_anthropic_compat + model_prefixes: [minimax-] + model_aliases: [] + base_url: https://api.minimax.io/anthropic + auth_env: [ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY] + + - name: zai + auth_mode: third_party_anthropic_compat + model_prefixes: [glm-] + model_aliases: [] + base_url: https://api.z.ai/api/anthropic + auth_env: [ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY] + + - name: moonshot + auth_mode: third_party_anthropic_compat + model_prefixes: [kimi-] + model_aliases: [] + base_url: https://api.moonshot.ai/anthropic + auth_env: [ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY] + + - name: deepseek + auth_mode: third_party_anthropic_compat + model_prefixes: [deepseek-] + model_aliases: [] + base_url: https://api.deepseek.com/anthropic + auth_env: [ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY] +""") + + @pytest.fixture def adapter(monkeypatch): """Fresh ClaudeCodeAdapter with all imports stubbed.""" @@ -98,7 +163,32 @@ def adapter(monkeypatch): return adapter_module.ClaudeCodeAdapter() -# ---- Pre-validation tests ---- +@pytest.fixture +def configs_dir(tmp_path): + """Per-test /configs dir with the canonical provider registry written to + config.yaml. Tests pass the path as ``config_path`` on _StubAdapterConfig + so adapter.setup() reads our fixture rather than the host's real + /configs/config.yaml (which doesn't exist in CI). + """ + cfg = tmp_path / "config.yaml" + cfg.write_text(_FIXTURE_PROVIDERS_YAML) + return str(tmp_path) + + +@pytest.fixture +def empty_configs_dir(tmp_path): + """A /configs dir with no config.yaml — exercises the FileNotFoundError + fallback path in _load_providers (must yield _BUILTIN_PROVIDERS). + """ + return str(tmp_path) + + +# ---- create_executor pre-validation tests ---- +# +# These exercise the 2026-04-30 hang-fix branch: ANTHROPIC_BASE_URL +# pointed at a non-Anthropic shim with no model picked silently passes +# 'sonnet' to the SDK, which hangs for 30s on the --print probe. The +# adapter raises early instead. @pytest.mark.asyncio @@ -230,65 +320,107 @@ async def test_create_executor_passes_when_unparseable_url(adapter, monkeypatch) assert executor is not None -# ---- setup() pre-validation tests ---- +# ---- setup() provider-registry tests ---- # # Symmetric to create_executor's pre-validate: setup() raises on the # inverse misconfig (third-party MODEL picked but ANTHROPIC_BASE_URL -# unset). Both produce "boots but every LLM call fails" if not caught; -# raising at boot keeps the workspace from entering "online" status with +# unset and the resolved provider has no default base_url). Both +# produce "boots but every LLM call fails" if not caught; raising at +# boot keeps the workspace from entering "online" status with # structurally-broken auth. @pytest.mark.asyncio -async def test_setup_raises_when_third_party_model_and_no_base_url( - adapter, monkeypatch +async def test_setup_passes_when_third_party_model_with_registered_base_url( + adapter, monkeypatch, configs_dir ): - """mimo-* model picked but no ANTHROPIC_BASE_URL → raise. - - Without the URL, every LLM request lands on api.anthropic.com with - a non-Anthropic key and 401s. The adapter should fail at boot - rather than ship a workspace that 401s on every prompt. + """Third-party model + provider has default base_url in YAML → + setup() auto-applies it (no operator URL needed) and runs cleanly + through to plugin install. The Option B v2 happy path: pick mimo- + or minimax- model in canvas, the registry handles routing. """ monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False) cfg = _StubAdapterConfig( - runtime_config={"model": "mimo-v2-flash"}, config_path="/tmp/configs" + runtime_config={"model": "mimo-v2-flash"}, config_path=configs_dir ) - with pytest.raises(ValueError) as exc_info: - await adapter.setup(cfg) - - msg = str(exc_info.value) - assert "mimo-v2-flash" in msg - assert "ANTHROPIC_BASE_URL" in msg - - -@pytest.mark.asyncio -async def test_setup_passes_when_third_party_model_with_base_url( - adapter, monkeypatch -): - """The fix path: third-party model + base URL set → setup() runs - cleanly through to plugin install (which is a no-op stub here). - """ - monkeypatch.setenv( - "ANTHROPIC_BASE_URL", "https://api.xiaomimimo.com/anthropic" - ) - cfg = _StubAdapterConfig( - runtime_config={"model": "mimo-v2-flash"}, config_path="/tmp/configs" - ) - - # Should complete without raising. Plugin install is stubbed. await adapter.setup(cfg) + # Registry-default base_url should now be in env for the SDK to pick up. + assert os.environ.get("ANTHROPIC_BASE_URL") == "https://api.xiaomimimo.com/anthropic" + @pytest.mark.asyncio -async def test_setup_passes_when_oauth_model_no_base_url(adapter, monkeypatch): +async def test_setup_passes_for_minimax_model(adapter, monkeypatch, configs_dir): + """MiniMax-M2 resolves to the minimax provider, auto-sets the MiniMax + Anthropic-compat endpoint. Verifies registry adds new providers + without code changes — the original motivation for the YAML registry. + """ + monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False) + cfg = _StubAdapterConfig( + runtime_config={"model": "MiniMax-M2"}, config_path=configs_dir + ) + + await adapter.setup(cfg) + + assert os.environ.get("ANTHROPIC_BASE_URL") == "https://api.minimax.io/anthropic" + + +@pytest.mark.asyncio +async def test_setup_minimax_case_insensitive_match( + adapter, monkeypatch, configs_dir +): + """MiniMax docs use mixed-case ids (MiniMax-M2.7); some operators may + type minimax-m2.7. Both must resolve to the same provider — registry + matches lowercased prefixes. + """ + monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False) + cfg = _StubAdapterConfig( + runtime_config={"model": "minimax-m2.7"}, config_path=configs_dir + ) + + await adapter.setup(cfg) + + assert os.environ.get("ANTHROPIC_BASE_URL") == "https://api.minimax.io/anthropic" + + +@pytest.mark.asyncio +async def test_setup_operator_base_url_overrides_registry_default( + adapter, monkeypatch, configs_dir +): + """Operator-set ANTHROPIC_BASE_URL wins over the provider's default — + escape hatch for regional endpoints (Xiaomi token-plan-sgp.*, + MiniMax api.minimaxi.com China endpoint). Pinning this so a future + refactor can't quietly clobber the override. + """ + monkeypatch.setenv( + "ANTHROPIC_BASE_URL", + "https://token-plan-sgp.xiaomimimo.com/anthropic", + ) + cfg = _StubAdapterConfig( + runtime_config={"model": "mimo-v2-flash"}, config_path=configs_dir + ) + + await adapter.setup(cfg) + + # Operator value untouched — adapter must not overwrite. + assert ( + os.environ.get("ANTHROPIC_BASE_URL") + == "https://token-plan-sgp.xiaomimimo.com/anthropic" + ) + + +@pytest.mark.asyncio +async def test_setup_passes_when_oauth_model_no_base_url( + adapter, monkeypatch, configs_dir +): """OAuth-aliased models (sonnet/opus/haiku) are Anthropic-native; no base URL is required. setup() must not raise on the OAuth path even though base_url is unset — that's the historical happy path. """ monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False) cfg = _StubAdapterConfig( - runtime_config={"model": "sonnet"}, config_path="/tmp/configs" + runtime_config={"model": "sonnet"}, config_path=configs_dir ) await adapter.setup(cfg) @@ -296,7 +428,7 @@ async def test_setup_passes_when_oauth_model_no_base_url(adapter, monkeypatch): @pytest.mark.asyncio async def test_setup_passes_when_anthropic_api_model_no_base_url( - adapter, monkeypatch + adapter, monkeypatch, configs_dir ): """claude-* versioned ids are Anthropic API-key path; base URL optional (defaults to api.anthropic.com). setup() must not raise. @@ -304,7 +436,210 @@ async def test_setup_passes_when_anthropic_api_model_no_base_url( monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False) cfg = _StubAdapterConfig( runtime_config={"model": "claude-sonnet-4-6"}, - config_path="/tmp/configs", + config_path=configs_dir, ) await adapter.setup(cfg) + + +@pytest.mark.asyncio +async def test_setup_falls_back_to_builtin_when_yaml_missing( + adapter, monkeypatch, empty_configs_dir +): + """No config.yaml in the configs dir → _load_providers falls back to + _BUILTIN_PROVIDERS (oauth + anthropic-api only). OAuth-aliased models + must still resolve cleanly so a bare-bones workspace boots even if + config.yaml is missing or malformed. + """ + monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False) + cfg = _StubAdapterConfig( + runtime_config={"model": "sonnet"}, config_path=empty_configs_dir + ) + + await adapter.setup(cfg) + + +@pytest.mark.asyncio +async def test_setup_raises_when_yaml_missing_and_third_party_model( + adapter, monkeypatch, empty_configs_dir +): + """No config.yaml + third-party model picked → builtin registry has no + matching prefix → resolves to the OAuth fallback (provider[0]). The + user picked a model the builtin can't route, so OAuth's auth_env + won't have the right key, but it won't raise here — auth check is + a warning, not an error. setup() should complete (no third-party + misconfig fires because the fallback isn't third-party). + + Documented behavior: when YAML is missing, third-party models are + silently downgraded to OAuth fallback. Operators must fix their + config.yaml to get correct routing. This test pins that the failure + mode is "warning + boots" rather than "raises" (helps debug-vs-recover + triage when CI loses the YAML somehow). + """ + monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False) + cfg = _StubAdapterConfig( + runtime_config={"model": "mimo-v2-flash"}, config_path=empty_configs_dir + ) + + # No raise — falls back to OAuth provider, third-party gate doesn't fire. + await adapter.setup(cfg) + + +@pytest.mark.asyncio +async def test_setup_auth_token_alone_satisfies_third_party_check( + adapter, monkeypatch, configs_dir, caplog +): + """MiniMax docs prefer ANTHROPIC_AUTH_TOKEN over ANTHROPIC_API_KEY. + The provider entry lists both as accepted; setting only AUTH_TOKEN + must NOT trigger the "no auth env set" warning. + """ + monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False) + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "test-minimax-token") + cfg = _StubAdapterConfig( + runtime_config={"model": "MiniMax-M2"}, config_path=configs_dir + ) + + import logging + with caplog.at_level(logging.WARNING): + await adapter.setup(cfg) + + auth_warnings = [r for r in caplog.records if "AuthenticationError" in r.getMessage()] + assert auth_warnings == [], ( + "ANTHROPIC_AUTH_TOKEN alone should satisfy minimax provider auth " + "but adapter logged a missing-auth warning anyway" + ) + + +# ---- _load_providers / _resolve_provider unit tests ---- + + +def test_load_providers_returns_builtin_when_yaml_missing(tmp_path): + """FileNotFoundError path returns the in-code defaults verbatim.""" + _install_stubs() + parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + if parent_dir not in sys.path: + sys.path.insert(0, parent_dir) + sys.modules.pop("adapter", None) + import adapter as adapter_module + + result = adapter_module._load_providers(str(tmp_path)) + assert result == adapter_module._BUILTIN_PROVIDERS + + +def test_load_providers_parses_yaml_and_normalizes(tmp_path): + """YAML present + parses → normalized tuple of provider dicts.""" + _install_stubs() + parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + if parent_dir not in sys.path: + sys.path.insert(0, parent_dir) + sys.modules.pop("adapter", None) + import adapter as adapter_module + + (tmp_path / "config.yaml").write_text(_FIXTURE_PROVIDERS_YAML) + result = adapter_module._load_providers(str(tmp_path)) + + assert len(result) == 7 + names = [p["name"] for p in result] + assert names == [ + "anthropic-oauth", "anthropic-api", "xiaomi-mimo", "minimax", + "zai", "moonshot", "deepseek", + ] + # YAML lists must be normalized to tuples for downstream lookup ergonomics. + assert isinstance(result[0]["model_aliases"], tuple) + assert isinstance(result[2]["model_prefixes"], tuple) + + +@pytest.mark.parametrize("model,expected_provider,expected_url", [ + ("GLM-4.6", "zai", "https://api.z.ai/api/anthropic"), + ("glm-4.5", "zai", "https://api.z.ai/api/anthropic"), + ("kimi-k2.5", "moonshot", "https://api.moonshot.ai/anthropic"), + ("deepseek-v4-pro", "deepseek", "https://api.deepseek.com/anthropic"), +]) +@pytest.mark.asyncio +async def test_setup_routes_extra_providers( + adapter, monkeypatch, configs_dir, model, expected_provider, expected_url +): + """The Z.ai / Moonshot / DeepSeek providers added in this PR must + route correctly: model id → provider entry → ANTHROPIC_BASE_URL. + Parametrized to keep the matrix coverage tight without 3 near-identical + test bodies. Locks in the per-vendor base_url so a future YAML edit + that mistypes z.ai's `/api/anthropic` suffix gets caught. + """ + monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False) + cfg = _StubAdapterConfig( + runtime_config={"model": model}, config_path=configs_dir + ) + + await adapter.setup(cfg) + + assert os.environ.get("ANTHROPIC_BASE_URL") == expected_url + + +def test_load_providers_falls_back_on_malformed_yaml(tmp_path, caplog): + """Malformed YAML → log warning + fallback (don't kill boot).""" + _install_stubs() + parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + if parent_dir not in sys.path: + sys.path.insert(0, parent_dir) + sys.modules.pop("adapter", None) + import adapter as adapter_module + + (tmp_path / "config.yaml").write_text("providers: [not valid yaml: {{{") + + import logging + with caplog.at_level(logging.WARNING): + result = adapter_module._load_providers(str(tmp_path)) + + assert result == adapter_module._BUILTIN_PROVIDERS + + +def test_resolve_provider_minimax_prefix_matches_minimax_provider(): + """The headline routing test: MiniMax-M2 lands on the minimax entry.""" + _install_stubs() + parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + if parent_dir not in sys.path: + sys.path.insert(0, parent_dir) + sys.modules.pop("adapter", None) + import adapter as adapter_module + + providers = tuple( + adapter_module._normalize_provider(p) for p in [ + {"name": "anthropic-oauth", "auth_mode": "oauth", + "model_aliases": ["sonnet"], "auth_env": ["CLAUDE_CODE_OAUTH_TOKEN"]}, + {"name": "minimax", "auth_mode": "third_party_anthropic_compat", + "model_prefixes": ["minimax-"], + "base_url": "https://api.minimax.io/anthropic", + "auth_env": ["ANTHROPIC_AUTH_TOKEN"]}, + ] + ) + + result = adapter_module._resolve_provider("MiniMax-M2", providers) + assert result["name"] == "minimax" + + # Case insensitivity also exercised. + result2 = adapter_module._resolve_provider("minimax-m2.7", providers) + assert result2["name"] == "minimax" + + +def test_resolve_provider_falls_back_to_first_when_unknown(): + """Unknown model id → fallback to first provider (OAuth by convention).""" + _install_stubs() + parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + if parent_dir not in sys.path: + sys.path.insert(0, parent_dir) + sys.modules.pop("adapter", None) + import adapter as adapter_module + + providers = tuple( + adapter_module._normalize_provider(p) for p in [ + {"name": "anthropic-oauth", "auth_mode": "oauth", + "auth_env": ["CLAUDE_CODE_OAUTH_TOKEN"]}, + {"name": "minimax", "auth_mode": "third_party_anthropic_compat", + "model_prefixes": ["minimax-"], + "auth_env": ["ANTHROPIC_AUTH_TOKEN"]}, + ] + ) + + result = adapter_module._resolve_provider("some-unknown-model", providers) + assert result["name"] == "anthropic-oauth" From 9de33057aad6d5cb8401a54dac774758f5e3e0d3 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Thu, 30 Apr 2026 23:30:24 -0700 Subject: [PATCH 2/5] feat(config): add MiniMax-M2.7-highspeed model entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Routes via the existing `minimax` provider entry (model prefix matches `minimax-` case-insensitively) — no registry change needed. Co-Authored-By: Claude Opus 4.7 (1M context) --- config.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/config.yaml b/config.yaml index f7558ce..11b74e9 100644 --- a/config.yaml +++ b/config.yaml @@ -154,6 +154,9 @@ runtime_config: - 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 From 7c3aeb5a14af789a98f99df006a0248bd6351dbf Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Thu, 30 Apr 2026 23:40:47 -0700 Subject: [PATCH 3/5] ci: install pyyaml so the YAML-loading test path is exercised MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without pyyaml in CI, adapter._load_providers' broad except-Exception swallows the ImportError and silently falls back to _BUILTIN_PROVIDERS. Tests then assert 7 providers but get 2; setup() can't route any third-party model. Locally pyyaml is system-installed so the issue went unnoticed. Same failure mode as the 2026-04-30 incident (CI green, prod broken) — pinning the dep here closes that gap. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c0f2030..2b29594 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 From 2b9b4306eb32fe76f3d71fdedeccfd486d7a9205 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Thu, 30 Apr 2026 23:58:24 -0700 Subject: [PATCH 4/5] fix(adapter): per-entry isolation in _load_providers + tighten _normalize_provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two correctness issues spotted in self-review of c6f4912: 1. String-as-prefix typo split into character tuple. ``model_prefixes: mimo-`` (operator forgot brackets) used to iterate over characters → ``('m','i','m','o','-')``, silently routing every model id starting with 'm', 'i', or '-' through the entry. Now: non-list values coerce to empty tuple (entry survives but matches nothing — operator notices in boot banner, not via misrouted requests). 2. Single bad provider entry nuked the whole registry. _load_providers built the registry via a generator inside tuple(...). One AttributeError mid-comprehension (e.g. ``[mimo-, 123]`` — int's missing .lower()) propagated out, broad except caught it, registry silently fell back 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. Now: per-entry try/except drops the bad entry with a warning, rest survives. Also: entries without a string ``name`` field are now dropped with a warning instead of silently using the placeholder ```` — operator typos surface in boot logs. Tests: 28 passing (3 new regression tests covering both issues plus the no-name path). Co-Authored-By: Claude Opus 4.7 (1M context) --- adapter.py | 90 +++++++++++++++++--- tests/test_adapter_prevalidate.py | 131 ++++++++++++++++++++++++++++++ 2 files changed, 209 insertions(+), 12 deletions(-) diff --git a/adapter.py b/adapter.py index dc38ab9..2c9fcec 100644 --- a/adapter.py +++ b/adapter.py @@ -49,20 +49,66 @@ _BUILTIN_PROVIDERS = ( ) -def _normalize_provider(entry: dict) -> dict: +def _coerce_string_list(value, lowercase: bool = False) -> tuple: + """Defensively coerce a YAML field expected to be a list-of-strings. + + 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. + .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": entry.get("name") or "", + "name": name, "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 ()), + "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": tuple(entry.get("auth_env") or ()), + "auth_env": _coerce_string_list(entry.get("auth_env"), lowercase=False), } @@ -76,22 +122,42 @@ def _load_providers(config_path: str) -> tuple: 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. + 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 {} - 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) + 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 + 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: diff --git a/tests/test_adapter_prevalidate.py b/tests/test_adapter_prevalidate.py index 3a053f4..566d5b5 100644 --- a/tests/test_adapter_prevalidate.py +++ b/tests/test_adapter_prevalidate.py @@ -622,6 +622,137 @@ def test_resolve_provider_minimax_prefix_matches_minimax_provider(): 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 ````. 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() From 25e86963f3b02ed90e1b65493494d882fd054882 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Fri, 1 May 2026 00:05:16 -0700 Subject: [PATCH 5/5] fix(adapter): drop dead _normalize_provider({}) fallback in _resolve_provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The empty-providers fallback in `_resolve_provider` was load-bearing when `_load_providers` could return an empty tuple, but after PR #22's per-entry hardening every return path yields a non-empty registry (builtins on parse failure, the parsed list otherwise). The leftover `_normalize_provider({})` branch became dead and outright broken: with the stricter `_normalize_provider` rejecting nameless entries, the fallback now returns None and would crash setup() on `provider["auth_mode"]` the moment anything called `_resolve_provider` with an empty tuple. Replace the dead branch with an explicit ValueError + pre-condition docstring. Defensive — no production caller can hit this — but turns a future silent NoneType crash into an actionable error. Co-Authored-By: Claude Opus 4.7 (1M context) --- adapter.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/adapter.py b/adapter.py index 2c9fcec..37693af 100644 --- a/adapter.py +++ b/adapter.py @@ -165,11 +165,21 @@ def _resolve_provider(model: str, providers: tuple) -> dict: 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). + (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). """ - fallback = providers[0] if providers else _normalize_provider({}) + 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 fallback + return providers[0] m = model.lower() for provider in providers: for prefix in provider["model_prefixes"]: @@ -178,7 +188,7 @@ def _resolve_provider(model: str, providers: tuple) -> dict: for provider in providers: if m in provider["model_aliases"]: return provider - return fallback + return providers[0] class ClaudeCodeAdapter(BaseAdapter):