diff --git a/adapter.py b/adapter.py index 37693af..dca08df 100644 --- a/adapter.py +++ b/adapter.py @@ -160,6 +160,34 @@ def _load_providers(config_path: str) -> tuple: return tuple(parsed) +def _strip_provider_prefix(model: str) -> str: + """Strip LangChain-style ":" prefix from a model id. + + The molecule-runtime wheel's config.py defaults model to + "anthropic:claude-opus-4-7" so langchain/crewai consumers get a uniform + LangChain-style provider:model string out of the box. The claude CLI's + --model arg expects the bare model id and silently exits 1 (no stderr) + on prefixed strings — root cause of the 2026-05-01 claude-code adapter + "Agent error (Exception)" bug. + + The strip also feeds _resolve_provider correctly: with the prefix + intact, "anthropic:claude-opus-4-7" doesn't match the anthropic-api + provider's model_prefixes=("claude-",) and falls back to the OAuth + default — wrong for users on ANTHROPIC_API_KEY. Stripping makes both + routing and CLI invocation see the same id. + + Only known-Claude prefixes are stripped. Unknown prefixes (e.g. + "openai:gpt-4") pass through so the CLI fails loudly instead of being + silently mangled into a model name it half-recognizes. + """ + if not model: + return model + for prefix in ("anthropic:", "claude:"): + if model.startswith(prefix): + return model[len(prefix):] + return model + + def _resolve_provider(model: str, providers: tuple) -> dict: """Return the provider entry matching this model id. @@ -283,6 +311,7 @@ class ClaudeCodeAdapter(BaseAdapter): picked_model = rc.get("model") or "sonnet" else: picked_model = getattr(rc, "model", None) or "sonnet" + picked_model = _strip_provider_prefix(picked_model) provider = _resolve_provider(picked_model, providers) auth_env_options = provider["auth_env"] @@ -378,6 +407,7 @@ class ClaudeCodeAdapter(BaseAdapter): explicit_model = rc.get("model") or "" else: explicit_model = getattr(rc, "model", None) or "" + explicit_model = _strip_provider_prefix(explicit_model) # Pre-validation: detect the misconfiguration combo that drove the # 2026-04-30 staging incident — ANTHROPIC_BASE_URL pointed at a diff --git a/tests/test_adapter_prevalidate.py b/tests/test_adapter_prevalidate.py index 566d5b5..0bee15f 100644 --- a/tests/test_adapter_prevalidate.py +++ b/tests/test_adapter_prevalidate.py @@ -774,3 +774,134 @@ def test_resolve_provider_falls_back_to_first_when_unknown(): result = adapter_module._resolve_provider("some-unknown-model", providers) assert result["name"] == "anthropic-oauth" + + +# ---- _strip_provider_prefix tests (2026-05-01 exit-1 root cause) ---- +# +# Wheel's molecule_runtime/config.py defaults model to +# "anthropic:claude-opus-4-7" so langchain/crewai consumers get a uniform +# LangChain-style provider:model string. The claude CLI rejects prefixed +# strings and exits 1 silently. Adapter must strip known-Claude prefixes +# before either provider routing (setup) or CLI invocation (executor) +# touches the value. + + +def _adapter_module(): + _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 # noqa: WPS433 + return adapter_module + + +def test_strip_provider_prefix_strips_anthropic(): + """The exact wheel default must reach downstream as the bare id.""" + mod = _adapter_module() + assert mod._strip_provider_prefix("anthropic:claude-opus-4-7") == "claude-opus-4-7" + + +def test_strip_provider_prefix_strips_claude(): + """Operators sometimes write `claude:opus-4-7`; treat as the same prefix.""" + mod = _adapter_module() + assert mod._strip_provider_prefix("claude:opus-4-7") == "opus-4-7" + + +def test_strip_provider_prefix_keeps_unprefixed(): + """Bare ids and aliases pass through unchanged.""" + mod = _adapter_module() + assert mod._strip_provider_prefix("sonnet") == "sonnet" + assert mod._strip_provider_prefix("claude-opus-4-7") == "claude-opus-4-7" + assert mod._strip_provider_prefix("MiniMax-M2") == "MiniMax-M2" + + +def test_strip_provider_prefix_keeps_unknown_prefix(): + """Unknown prefixes (e.g. openai:) pass through so the CLI fails loudly + instead of being silently mangled into a half-recognized name.""" + mod = _adapter_module() + assert mod._strip_provider_prefix("openai:gpt-4") == "openai:gpt-4" + assert mod._strip_provider_prefix("bedrock:claude-3") == "bedrock:claude-3" + + +def test_strip_provider_prefix_handles_empty(): + """Empty string returns empty — used by create_executor before the + 'or sonnet' fallback so the strip path can't crash on the missing-model + code path.""" + mod = _adapter_module() + assert mod._strip_provider_prefix("") == "" + + +@pytest.mark.asyncio +async def test_create_executor_strips_anthropic_prefix(adapter, monkeypatch): + """End-to-end: the wheel default ("anthropic:claude-opus-4-7") reaches + ClaudeSDKExecutor as the bare id. Without this strip the claude CLI + silently exits 1 mid-A2A. + """ + import claude_sdk_executor + + monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False) + claude_sdk_executor.ClaudeSDKExecutor.reset_mock() + + cfg = _StubAdapterConfig( + runtime_config={"model": "anthropic:claude-opus-4-7"} + ) + await adapter.create_executor(cfg) + + kwargs = claude_sdk_executor.ClaudeSDKExecutor.call_args.kwargs + assert kwargs["model"] == "claude-opus-4-7" + + +@pytest.mark.asyncio +async def test_create_executor_strips_anthropic_prefix_dataclass( + adapter, monkeypatch +): + """Symmetric coverage of dataclass-shaped runtime_config — the same + wheel default arrives via that shape in production via main.py's + load_config path. + """ + import claude_sdk_executor + + monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False) + claude_sdk_executor.ClaudeSDKExecutor.reset_mock() + + @dataclass + class _RC: + model: str = "anthropic:claude-opus-4-7" + + cfg = _StubAdapterConfig(runtime_config=_RC()) + await adapter.create_executor(cfg) + + kwargs = claude_sdk_executor.ClaudeSDKExecutor.call_args.kwargs + assert kwargs["model"] == "claude-opus-4-7" + + +@pytest.mark.asyncio +async def test_setup_strip_routes_prefixed_anthropic_to_anthropic_api( + adapter, monkeypatch, configs_dir, caplog +): + """With the prefix intact, `anthropic:claude-opus-4-7` doesn't match + anthropic-api's model_prefixes=("claude-",) and falls back to + anthropic-oauth — wrong for users on ANTHROPIC_API_KEY. The strip in + setup() must run BEFORE _resolve_provider so routing sees the bare id. + """ + import logging + + monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False) + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test") + cfg = _StubAdapterConfig( + runtime_config={"model": "anthropic:claude-opus-4-7"}, + config_path=configs_dir, + ) + + with caplog.at_level(logging.INFO, logger="adapter"): + await adapter.setup(cfg) + + banner = next( + (r.getMessage() for r in caplog.records + if "Claude Code adapter starting" in r.getMessage()), + "", + ) + assert "provider=anthropic-api" in banner, ( + f"Expected provider=anthropic-api after stripping prefix; banner={banner!r}" + )