From 0d95b5098a4704ec1bde9095c32c321e89d944c5 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Thu, 30 Apr 2026 22:35:49 -0700 Subject: [PATCH] feat(adapter): pre-validate ANTHROPIC_BASE_URL + missing model combo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2026-04-30 staging incident traced back to workspaces booting with ANTHROPIC_BASE_URL pointing at a non-Anthropic shim (MiniMax / OpenAI gateway) but no explicit model configured. The adapter silently fell back to "sonnet" — an Anthropic-native alias the upstream didn't recognize — and the SDK --print probe hung 30s before timing out. Platform's phantom-busy sweep then nuked the workspace at 10min, producing "every workspace dead" with the root cause buried in a 30s subprocess hang. Pre-validate the combo at adapter boot: when ANTHROPIC_BASE_URL host is non-Anthropic AND no explicit model is set, raise ValueError with an actionable message pointing to MODEL_PROVIDER / runtime_config.model. Also log the resolved model + base_url_host every boot so future failures explain themselves in the workspace logs without digging into the SDK subprocess. Tests live under tests/ with their own pytest.ini that anchors rootdir there — keeps pytest from importing the package __init__.py (which does the runtime-discovery relative import that requires molecule_runtime installed). 7 tests cover: misconfig raises with the right message, Anthropic-native passes, no-base-url passes, custom-url + explicit model passes, dataclass + dict shapes, unparseable URL no-crash. CI runs them on every push/PR. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 17 +++ adapter.py | 54 ++++++- tests/pytest.ini | 11 ++ tests/test_adapter_prevalidate.py | 224 ++++++++++++++++++++++++++++++ 4 files changed, 304 insertions(+), 2 deletions(-) create mode 100644 tests/pytest.ini create mode 100644 tests/test_adapter_prevalidate.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b788107..c0f2030 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,3 +3,20 @@ on: [push, pull_request] jobs: validate: uses: Molecule-AI/molecule-ci/.github/workflows/validate-workspace-template.yml@main + + tests: + name: Adapter unit tests + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - run: pip install -q pytest pytest-asyncio + # 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 + # discovery and can't be satisfied without molecule_runtime + # installed). See tests/pytest.ini for the full rationale. + - run: python3 -m pytest tests/ -v diff --git a/adapter.py b/adapter.py index af14ff5..2a26a0c 100644 --- a/adapter.py +++ b/adapter.py @@ -117,9 +117,59 @@ class ClaudeCodeAdapter(BaseAdapter): # RuntimeConfig dataclass. Read `model` defensively from either shape. rc = config.runtime_config if isinstance(rc, dict): - model = rc.get("model") or "sonnet" + explicit_model = rc.get("model") or "" else: - model = getattr(rc, "model", None) or "sonnet" + explicit_model = getattr(rc, "model", None) or "" + + # Pre-validation: detect the misconfiguration combo that drove the + # 2026-04-30 staging incident — ANTHROPIC_BASE_URL pointed at a + # non-Anthropic upstream (MiniMax / OpenAI shim) but no explicit + # model was set, so we'd silently fall back to "sonnet" and the + # upstream would hang on the SDK --print probe for 30s before + # timing out. The platform's phantom-busy sweep then resets the + # workspace at the 10min mark — the user-visible failure is "every + # workspace dead" but the root cause is one missing env var. + # + # Fail fast here with an actionable message so the operator sees + # exactly what to fix instead of chasing ghosts in workspace logs. + # We only fire when ALL three are true: + # 1. ANTHROPIC_BASE_URL is set (custom upstream is in play) + # 2. The host is NOT api.anthropic.com (real Anthropic accepts + # "sonnet" as a known alias, so the fallback is fine there) + # 3. The user did NOT set an explicit model (the check we want) + # Anthropic-native users with no model picked still get the + # "sonnet" fallback — that's correct behavior, no error. + base_url = os.environ.get("ANTHROPIC_BASE_URL", "").strip() + if base_url and not explicit_model: + from urllib.parse import urlparse + host = urlparse(base_url).hostname or "" + if host and host != "api.anthropic.com": + raise ValueError( + "claude-code adapter: ANTHROPIC_BASE_URL points at a " + f"non-Anthropic host ({host}) but no model is configured. " + "The default fallback ('sonnet') is an Anthropic-native " + "alias; non-Anthropic shims (MiniMax, OpenAI gateways, " + "etc.) won't recognize it and the SDK --print probe will " + "hang for 30s before timing out. Fix: set MODEL_PROVIDER " + "as a workspace secret (canvas: Save+Restart with model " + "picked) or set runtime_config.model in /configs/config.yaml." + ) + + model = explicit_model or "sonnet" + # Surface what we resolved to in logs — when the workspace agent + # eventually fails, this single line in the logs explains "the + # adapter sent X to Y" without having to dig into the SDK + # subprocess. Cheap diagnostic, no runtime cost. + if base_url: + from urllib.parse import urlparse + logger.info( + "claude-code: model=%s base_url_host=%s%s", + model, + urlparse(base_url).hostname or "", + " (custom upstream)" if base_url else "", + ) + else: + logger.info("claude-code: model=%s base_url=anthropic-default", model) return ClaudeSDKExecutor( system_prompt=system_prompt, diff --git a/tests/pytest.ini b/tests/pytest.ini new file mode 100644 index 0000000..98b1486 --- /dev/null +++ b/tests/pytest.ini @@ -0,0 +1,11 @@ +[pytest] +# This pytest.ini anchors pytest's rootdir at tests/ (instead of the +# template directory itself). The template's __init__.py does +# `from .adapter import ClaudeCodeAdapter` for production runtime +# discovery; if pytest treats the template dir as the rootdir, it picks +# up __init__.py as a package node and the relative import fails because +# adapter.py's runtime deps (molecule_runtime, a2a) aren't installed in +# the test environment. Anchoring rootdir here keeps pytest from ever +# touching __init__.py. +addopts = --import-mode=importlib +asyncio_mode = auto diff --git a/tests/test_adapter_prevalidate.py b/tests/test_adapter_prevalidate.py new file mode 100644 index 0000000..08a4a5c --- /dev/null +++ b/tests/test_adapter_prevalidate.py @@ -0,0 +1,224 @@ +"""Unit tests for ClaudeCodeAdapter.create_executor pre-validation. + +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). + +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. +""" + +import os +import sys +import types +from dataclasses import dataclass, field +from unittest.mock import MagicMock + +import pytest + + +# ---- Test scaffolding ---- +# +# adapter.py imports at module load: +# - molecule_runtime.adapters.base (BaseAdapter, AdapterConfig, RuntimeCapabilities) +# - 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 +# executor instantiates, so the stub doesn't affect what we're testing. + + +@dataclass +class _StubRuntimeCapabilities: + provides_native_session: bool = False + + +@dataclass +class _StubAdapterConfig: + runtime_config: object = None + config_path: str = "/tmp/configs" + system_prompt: str = "" + heartbeat: object = None + + +class _StubBaseAdapter: + async def install_plugins_via_registry(self, *_args, **_kwargs): + pass + + +def _install_stubs(): + """Install the smallest set of import shims that adapter.py needs.""" + if "molecule_runtime" not in sys.modules: + mr = types.ModuleType("molecule_runtime") + mr.adapters = types.ModuleType("molecule_runtime.adapters") + mr.adapters.base = types.ModuleType("molecule_runtime.adapters.base") + mr.adapters.base.BaseAdapter = _StubBaseAdapter + mr.adapters.base.AdapterConfig = _StubAdapterConfig + mr.adapters.base.RuntimeCapabilities = _StubRuntimeCapabilities + sys.modules["molecule_runtime"] = mr + sys.modules["molecule_runtime.adapters"] = mr.adapters + sys.modules["molecule_runtime.adapters.base"] = mr.adapters.base + if "a2a" not in sys.modules: + a2a = types.ModuleType("a2a") + a2a.server = types.ModuleType("a2a.server") + a2a.server.agent_execution = types.ModuleType("a2a.server.agent_execution") + a2a.server.agent_execution.AgentExecutor = type("AgentExecutor", (), {}) + sys.modules["a2a"] = a2a + sys.modules["a2a.server"] = a2a.server + sys.modules["a2a.server.agent_execution"] = a2a.server.agent_execution + if "claude_sdk_executor" not in sys.modules: + mod = types.ModuleType("claude_sdk_executor") + mod.ClaudeSDKExecutor = MagicMock(name="ClaudeSDKExecutor") + sys.modules["claude_sdk_executor"] = mod + + +@pytest.fixture +def adapter(monkeypatch): + """Fresh ClaudeCodeAdapter with all imports stubbed.""" + _install_stubs() + # adapter.py lives in the parent dir. tests/ has no __init__.py + # because the template directory itself is a Python package + # (production runtime imports it via the platform's adapter loader), + # and adding tests/__init__.py would re-expose the same relative- + # import collection problem we sidestepped by isolating tests here. + 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) + # Strip any cached module so the stubbed sys.modules entries take effect. + sys.modules.pop("adapter", None) + import adapter as adapter_module # noqa: WPS433 + return adapter_module.ClaudeCodeAdapter() + + +# ---- Pre-validation tests ---- + + +@pytest.mark.asyncio +async def test_create_executor_raises_when_custom_base_url_and_no_model( + adapter, monkeypatch +): + """The 2026-04-30 incident shape: custom upstream + no explicit model. + + Adapter must raise ValueError with an actionable message instead of + silently passing 'sonnet' to ClaudeSDKExecutor (which would hang + for 30s on the SDK probe before timing out). + """ + monkeypatch.setenv( + "ANTHROPIC_BASE_URL", "https://api.xiaomimimo.com/anthropic" + ) + cfg = _StubAdapterConfig(runtime_config={"model": ""}) + + with pytest.raises(ValueError) as exc_info: + await adapter.create_executor(cfg) + + msg = str(exc_info.value) + assert "ANTHROPIC_BASE_URL" in msg + assert "api.xiaomimimo.com" in msg + assert "MODEL_PROVIDER" in msg or "runtime_config.model" in msg + + +@pytest.mark.asyncio +async def test_create_executor_passes_when_anthropic_native_and_no_model( + adapter, monkeypatch +): + """Anthropic-native users with no model picked still get the 'sonnet' + fallback — that's correct behavior, never an error. The pre-validation + only fires on non-Anthropic hosts. + """ + monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://api.anthropic.com") + cfg = _StubAdapterConfig(runtime_config={"model": ""}) + + # Should not raise — fallback to "sonnet" is the documented default. + executor = await adapter.create_executor(cfg) + assert executor is not None + + +@pytest.mark.asyncio +async def test_create_executor_passes_when_no_base_url_set(adapter, monkeypatch): + """No ANTHROPIC_BASE_URL = SDK uses its built-in Anthropic default. + That's the historical happy path. Pre-validation must not regress it. + """ + monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False) + cfg = _StubAdapterConfig(runtime_config={"model": ""}) + + executor = await adapter.create_executor(cfg) + assert executor is not None + + +@pytest.mark.asyncio +async def test_create_executor_passes_when_custom_base_url_with_explicit_model( + adapter, monkeypatch +): + """The fix the user is supposed to apply: set both URL and model. + Pre-validation must let this through cleanly. End-to-end success path + for the MiniMax-shim use case after Option B PRs land. + """ + monkeypatch.setenv( + "ANTHROPIC_BASE_URL", "https://api.xiaomimimo.com/anthropic" + ) + cfg = _StubAdapterConfig( + runtime_config={"model": "MiniMax-M2"} + ) + + executor = await adapter.create_executor(cfg) + assert executor is not None + + +@pytest.mark.asyncio +async def test_create_executor_passes_dataclass_runtime_config(adapter, monkeypatch): + """runtime_config can arrive as a dataclass (the production shape via + main.py's load_config) instead of a dict. The defensive read at line + 118-122 must work for both. Regression coverage for the read path. + """ + monkeypatch.setenv( + "ANTHROPIC_BASE_URL", "https://api.xiaomimimo.com/anthropic" + ) + + @dataclass + class _RC: + model: str = "MiniMax-M2" + provider: str = "minimax" + + cfg = _StubAdapterConfig(runtime_config=_RC()) + executor = await adapter.create_executor(cfg) + assert executor is not None + + +@pytest.mark.asyncio +async def test_create_executor_raises_when_dataclass_runtime_config_empty_model( + adapter, monkeypatch +): + """Dataclass shape with empty model triggers the same validation as + dict shape with empty model. Symmetric behavior across both inputs. + """ + monkeypatch.setenv( + "ANTHROPIC_BASE_URL", "https://api.xiaomimimo.com/anthropic" + ) + + @dataclass + class _RC: + model: str = "" + provider: str = "" + + cfg = _StubAdapterConfig(runtime_config=_RC()) + + with pytest.raises(ValueError): + await adapter.create_executor(cfg) + + +@pytest.mark.asyncio +async def test_create_executor_passes_when_unparseable_url(adapter, monkeypatch): + """An unparseable URL value (no host extractable) shouldn't crash + with AttributeError. Should still pass through to the SDK so the + SDK gets to error on it itself — adapter doesn't take ownership + of URL validation, just the missing-model invariant. + """ + monkeypatch.setenv("ANTHROPIC_BASE_URL", "://garbage") + cfg = _StubAdapterConfig(runtime_config={"model": ""}) + + # Empty hostname → pre-validation skips → reaches SDK with "sonnet" + # fallback. The SDK will fail; that's not the adapter's job. + executor = await adapter.create_executor(cfg) + assert executor is not None