Merge pull request #24 from Molecule-AI/fix/strip-langchain-provider-prefix

fix(adapter): strip LangChain-style provider prefix before CLI invocation
This commit is contained in:
Hongming Wang 2026-05-01 16:09:20 -07:00 committed by GitHub
commit b70aa1846b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 161 additions and 0 deletions

View File

@ -160,6 +160,34 @@ def _load_providers(config_path: str) -> tuple:
return tuple(parsed)
def _strip_provider_prefix(model: str) -> str:
"""Strip LangChain-style "<provider>:<model>" 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

View File

@ -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}"
)