feat(adapter): raise on third-party model without ANTHROPIC_BASE_URL

Aligns setup()'s third-party-model-without-URL handling with
create_executor()'s pre-validate (#19) — both unrecoverable
misconfigurations now raise ValueError at boot instead of one warning
and one raising.

Why: a third-party (mimo-*) model selected without ANTHROPIC_BASE_URL
sends every LLM request to api.anthropic.com with a non-Anthropic key,
401-ing every prompt. Workspace boots, looks "online" via heartbeat,
but is structurally broken on the user-facing path. The previous
warning-only path produced the same end-user symptom as the
2026-04-30 incident (workspace looks alive, every interaction fails)
just via a different misconfig shape.

Symmetry: create_executor raises when ANTHROPIC_BASE_URL is set to a
non-Anthropic host but no model is picked. setup() now raises when a
third-party model is picked but no URL is set. Together they catch
both halves of the misconfig surface at boot, before the workspace
enters "online" status.

Adds 4 setup() tests:
- raises on third-party + no URL
- passes on third-party + URL
- passes on OAuth alias (sonnet) + no URL
- passes on Anthropic API id (claude-*) + no URL

Stubs molecule_runtime.plugins.load_plugins as a no-op so the pass-path
tests run cleanly without the runtime installed. Test count: 11 (7
create_executor + 4 setup).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-04-30 22:50:25 -07:00
parent 3d83e0513c
commit c646b8cebe
2 changed files with 100 additions and 9 deletions

View File

@ -178,16 +178,21 @@ class ClaudeCodeAdapter(BaseAdapter):
)
# Third-party paths additionally need ANTHROPIC_BASE_URL; entrypoint.sh
# sets it for known mimo-* prefixes. Surface the missing-base-URL
# case explicitly — the symptom otherwise is the CLI silently hitting
# api.anthropic.com with a third-party key, which 401s.
# 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:
logger.warning(
"model=%s is a third-party Anthropic-compat model but "
"ANTHROPIC_BASE_URL is unset — requests will land on the real "
"api.anthropic.com and fail with 401. Check entrypoint.sh's "
"model→base-URL mapping or set ANTHROPIC_BASE_URL via secrets.",
picked_model,
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."
)
from molecule_runtime.plugins import load_plugins

View File

@ -57,9 +57,15 @@ def _install_stubs():
mr.adapters.base.BaseAdapter = _StubBaseAdapter
mr.adapters.base.AdapterConfig = _StubAdapterConfig
mr.adapters.base.RuntimeCapabilities = _StubRuntimeCapabilities
# adapter.setup() lazy-imports molecule_runtime.plugins.load_plugins.
# Stub it as a no-op returning [] so setup() pass-paths run cleanly
# without needing the real runtime installed in the test env.
mr.plugins = types.ModuleType("molecule_runtime.plugins")
mr.plugins.load_plugins = lambda **_kwargs: []
sys.modules["molecule_runtime"] = mr
sys.modules["molecule_runtime.adapters"] = mr.adapters
sys.modules["molecule_runtime.adapters.base"] = mr.adapters.base
sys.modules["molecule_runtime.plugins"] = mr.plugins
if "a2a" not in sys.modules:
a2a = types.ModuleType("a2a")
a2a.server = types.ModuleType("a2a.server")
@ -222,3 +228,83 @@ async def test_create_executor_passes_when_unparseable_url(adapter, monkeypatch)
# fallback. The SDK will fail; that's not the adapter's job.
executor = await adapter.create_executor(cfg)
assert executor is not None
# ---- setup() pre-validation 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
# structurally-broken auth.
@pytest.mark.asyncio
async def test_setup_raises_when_third_party_model_and_no_base_url(
adapter, monkeypatch
):
"""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.
"""
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
cfg = _StubAdapterConfig(
runtime_config={"model": "mimo-v2-flash"}, config_path="/tmp/configs"
)
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)
@pytest.mark.asyncio
async def test_setup_passes_when_oauth_model_no_base_url(adapter, monkeypatch):
"""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"
)
await adapter.setup(cfg)
@pytest.mark.asyncio
async def test_setup_passes_when_anthropic_api_model_no_base_url(
adapter, monkeypatch
):
"""claude-* versioned ids are Anthropic API-key path; base URL
optional (defaults to api.anthropic.com). setup() must not raise.
"""
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
cfg = _StubAdapterConfig(
runtime_config={"model": "claude-sonnet-4-6"},
config_path="/tmp/configs",
)
await adapter.setup(cfg)