Compare commits

...

3 Commits

Author SHA1 Message Date
Hongming Wang
64fd2118d0 docs(readme): add OpenRouter routing note for anthropic:/claude: prefixes
Some checks failed
CI / validate (push) Failing after 0s
2026-05-01 20:05:36 -07:00
Hongming Wang
d1e9affde6
Merge pull request #13 from Molecule-AI/fix/anthropic-prefix-route-via-openrouter
fix(adapter): route anthropic:/claude: prefixes via OpenRouter, not OpenAI
2026-05-01 17:37:25 -07:00
Hongming Wang
c1fc78090e fix(adapter): route anthropic:/claude: prefixes via OpenRouter, not OpenAI
Some checks failed
CI / validate (push) Failing after 0s
OpenClaw is OpenAI-compat only (--custom-compatibility openai is
hard-set). Prior routing fell through every unrecognised prefix
(including anthropic and claude) to OPENAI_API_KEY + api.openai.com
with the bare model id, so workspaces booted with the wheel-default
`anthropic:claude-opus-4-7` reached `running` status while every
inference call returned 401/404 from OpenAI.

Detect anthropic/claude → re-route through OpenRouter at slash-form
`anthropic/<id>`, which exposes Claude under the OpenAI-compat API.
Per-prefix API-key lookup also lands so groq/openrouter/qianfan use
their proper env vars instead of the legacy OPENAI_API_KEY fallthrough.

Routing extracted to a pure helper `_resolve_provider_routing(model,
env, runtime_config)` so the twelve branches it encodes are pinned in
tests/test_model_routing.py without invoking the npm install +
onboard side effects of setup() (which is `# pragma: no cover`).

Filed as Issue 5 in known-issues.md (OWC-272).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 17:20:26 -07:00
6 changed files with 403 additions and 17 deletions

5
.gitignore vendored
View File

@ -19,3 +19,8 @@
# Workspace auth tokens
.auth-token
.auth_token
# Python bytecode + pytest cache
__pycache__/
*.pyc
.pytest_cache/

View File

@ -20,5 +20,8 @@ github://Molecule-AI/template-openclaw
## Schema version
`template_schema_version: 1` — compatible with Molecule AI platform v1.x.
## API keys / model routing
For `anthropic:` or `claude:` model prefixes, set `OPENROUTER_API_KEY` — OpenClaw is OpenAI-compatible only and does not speak the Anthropic API natively, so Claude models are routed through OpenRouter (which exposes them under the OpenAI-compat surface). See `known-issues.md` Issue 5 for the routing table and pre-fix workaround.
## License
Business Source License 1.1 — © Molecule AI.

View File

@ -28,6 +28,95 @@ OPENCLAW_PORT = 18789
# Known missing optional deps in OpenClaw's npm package
OPENCLAW_MISSING_DEPS = ["@buape/carbon", "@larksuiteoapi/node-sdk", "@slack/web-api", "grammy"]
# Per-prefix API-key env var lookup. Each tuple is searched in order and
# the first env var present wins, so an operator running multiple
# providers can keep both keys set without one accidentally shadowing
# the other.
_API_KEY_BY_PREFIX = {
"openai": ("OPENAI_API_KEY",),
"groq": ("GROQ_API_KEY", "OPENAI_API_KEY"),
"openrouter": ("OPENROUTER_API_KEY",),
"qianfan": ("QIANFAN_API_KEY", "AISTUDIO_API_KEY"),
}
# OpenAI-compat base URL for each routed provider.
_PROVIDER_URLS = {
"openai": "https://api.openai.com/v1",
"groq": "https://api.groq.com/openai/v1",
"openrouter": "https://openrouter.ai/api/v1",
"qianfan": "https://qianfan.baidubce.com/v2",
}
def _resolve_provider_routing(model_str, env, runtime_config=None):
"""Translate a LangChain-style ``<provider>:<id>`` model string into
the (prefix, model_id, provider_url, api_key) tuple openclaw needs.
OpenClaw is OpenAI-compatible only its ``--custom-compatibility``
flag is hard-set to ``openai`` in setup() below. Model strings
arrive in LangChain-style ``<provider>:<id>`` form (the wheel's
config.py default is ``anthropic:claude-opus-4-7`` so
langchain/crewai consumers get a uniform string out of the box).
Routing rules:
* openai/groq/openrouter/qianfan existing per-prefix routing,
each provider exposes an OpenAI-compat endpoint directly.
* anthropic/claude re-route through OpenRouter, which exposes
Claude under the OpenAI-compat API at ``anthropic/<id>``
slash-form. Caught 2026-05-01: without this re-route,
``anthropic:claude-X`` landed on the OPENAI_API_KEY +
api.openai.com path with ``claude-X`` as the model id, which
OpenAI doesn't host — every inference call failed silently and
the workspace looked online but was structurally broken.
* bare model id (no ``:``) openai (legacy default, unchanged).
* unknown prefix falls back to OPENAI_API_KEY/api.openai.com so
operator-supplied prefixes that genuinely *are* OpenAI-compat
pass through; explicit anthropic/claude is the only re-route.
Pure: takes ``env`` (a Mapping) and ``runtime_config`` (a Mapping
or None) so the test suite can exercise every branch without
monkeypatching ``os.environ``.
"""
if ":" in model_str:
prefix, model = model_str.split(":", 1)
else:
prefix, model = "openai", model_str
if prefix in ("anthropic", "claude"):
if not env.get("OPENROUTER_API_KEY"):
raise RuntimeError(
f"openclaw adapter: model={model_str!r} requires "
"Anthropic/Claude routing but openclaw is OpenAI-"
"compatible only. Either: (a) set OPENROUTER_API_KEY in "
"workspace secrets so the adapter can route via "
"OpenRouter (which exposes Claude under OpenAI-compat "
"API), or (b) pick a model from the supported provider "
"list (openai/groq/openrouter/qianfan) and set "
"MODEL_PROVIDER on the workspace, e.g. "
"openrouter:anthropic/claude-sonnet-4."
)
# OpenRouter exposes Claude under `anthropic/<id>` slash form.
model = f"anthropic/{model}"
prefix = "openrouter"
env_vars = _API_KEY_BY_PREFIX.get(prefix, ("OPENAI_API_KEY",))
api_key = next((env[v] for v in env_vars if env.get(v)), "")
if not api_key:
raise RuntimeError(
f"openclaw adapter: no API key found for prefix={prefix!r} "
f"(checked: {', '.join(env_vars)}). Set one of those env "
f"vars in workspace secrets."
)
default_url = _PROVIDER_URLS.get(prefix, _PROVIDER_URLS["openai"])
if runtime_config is not None:
provider_url = runtime_config.get("provider_url", default_url)
else:
provider_url = default_url
return prefix, model, provider_url, api_key
class OpenClawAdapter(BaseAdapter):
@ -90,23 +179,15 @@ class OpenClawAdapter(BaseAdapter):
)
logger.info("OpenClaw CLI installed")
# 2. Resolve API key and model
prefix = config.model.split(":")[0] if ":" in config.model else "openai"
if prefix == "qianfan":
api_key = os.environ.get("QIANFAN_API_KEY", os.environ.get("AISTUDIO_API_KEY", ""))
else:
api_key = os.environ.get("OPENAI_API_KEY", os.environ.get("GROQ_API_KEY", os.environ.get("OPENROUTER_API_KEY", "")))
# Determine provider URL from model prefix
provider_urls = {
"openai": "https://api.openai.com/v1",
"groq": "https://api.groq.com/openai/v1",
"openrouter": "https://openrouter.ai/api/v1",
"qianfan": "https://qianfan.baidubce.com/v2",
}
provider_url = config.runtime_config.get("provider_url", provider_urls.get(prefix, "https://api.openai.com/v1"))
model = config.model
if ":" in model:
_, model = model.split(":", 1)
# 2. Resolve API key and model via the pure routing helper.
prefix, model, provider_url, api_key = _resolve_provider_routing(
config.model, os.environ, config.runtime_config
)
if prefix == "openrouter" and config.model.split(":", 1)[0] in ("anthropic", "claude"):
logger.info(
"openclaw adapter: rerouting anthropic-prefixed model via OpenRouter (model=%s)",
model,
)
# 3. Run non-interactive onboard
if not os.path.exists(os.path.expanduser("~/.openclaw/openclaw.json")):

View File

@ -132,3 +132,39 @@ Remove the `skills:` block from `config.yaml` to avoid confusion while this issu
- Ticket: OWC-258
---
## Issue 5 — `anthropic:` / `claude:` model strings silently routed to OpenAI
**Severity:** High
**First introduced:** v0.2.0 (initial routing table)
**Components affected:** `adapter.py`
### Symptom
A workspace booted with the wheel-default model string `anthropic:claude-opus-4-7` (or any `anthropic:<id>` / `claude:<id>` configured by langchain/crewai consumers) reaches `running` status, the gateway becomes healthy, and the A2A handshake succeeds — but every subsequent inference call returns 401/404 from `api.openai.com`. The workspace looks fully online while being structurally unable to answer any question.
### Root cause
OpenClaw is OpenAI-compatible only — `--custom-compatibility` is hard-set to `openai`. The pre-fix prefix-routing table in `adapter.py` mapped every prefix it didn't recognise (including `anthropic` and `claude`) to `OPENAI_API_KEY` + `https://api.openai.com/v1`, then forwarded the bare model id (`claude-opus-4-7`) to the OpenAI endpoint. OpenAI doesn't host Claude models, so every call failed — but the failure surfaced as a per-message 401/404 instead of a boot-time error, so monitoring-by-status missed it.
### Fix (PR landing on `fix/anthropic-prefix-route-via-openrouter`)
`_resolve_provider_routing` (extracted as a pure helper in `adapter.py`) now detects `anthropic` / `claude` prefixes and re-routes through OpenRouter, which exposes Claude under the OpenAI-compat API at the slash-form id `anthropic/<id>`. The helper is exercised by `tests/test_model_routing.py` across all twelve routing branches. Per-prefix API-key lookup also lands so `groq:` / `openrouter:` / `qianfan:` use their proper env vars instead of the legacy fallthrough to `OPENAI_API_KEY`.
### Current workaround (pre-fix workspaces)
If you are pinned to a pre-fix template tag, set both env vars on the workspace:
```bash
export OPENROUTER_API_KEY=sk-or-...
export OPENCLAW_MODEL=openrouter:anthropic/claude-opus-4-7
```
The explicit `openrouter:` prefix avoids the broken anthropic-fallthrough path and matches the slash-form id OpenRouter expects.
### Tracking
- Filed: 2026-05-01
- Ticket: OWC-272
---

9
tests/pytest.ini Normal file
View File

@ -0,0 +1,9 @@
[pytest]
# Anchor pytest's rootdir at tests/ (instead of the template directory
# itself). The template's __init__.py does `from .adapter import
# OpenClawAdapter` 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

252
tests/test_model_routing.py Normal file
View File

@ -0,0 +1,252 @@
"""Pin the openclaw adapter's provider-routing logic.
Why this exists
---------------
openclaw is OpenAI-compat only its ``--custom-compatibility`` CLI
flag is hard-set to ``openai``. Model strings arrive in
LangChain-style ``<provider>:<id>`` form because the wheel's
config.py default is ``anthropic:claude-opus-4-7`` (so langchain /
crewai consumers get a uniform model string out of the box).
The pre-fix routing in ``adapter.py:setup()`` fell through
``anthropic:claude-X`` to the OpenAI key + ``api.openai.com`` path
with the bare model id ``claude-X``, which OpenAI doesn't host.
Every inference call failed silently the workspace looked online
but was structurally broken on every turn.
These tests exercise ``_resolve_provider_routing`` (the pure
extraction of the routing decision) so the eight branches it
encodes are pinned without spinning up the npm install + onboard
side effects of the real ``setup()``.
"""
import os
import sys
import types
from unittest.mock import MagicMock
import pytest
# ---- Stubs ----
#
# adapter.py imports a chain of platform modules at module load:
#
# - molecule_runtime.adapters.base (BaseAdapter, AdapterConfig)
# - molecule_runtime.adapters.shared_runtime (helper re-exports)
# - a2a.server.agent_execution (AgentExecutor base class)
#
# Stub the minimum surface so the import succeeds in a CI environment
# where the runtime wheel isn't pip-installable. Routing logic itself
# touches none of these, so the stubs only need to satisfy `from X
# import Y` at module load.
def _ensure_module(dotted: str) -> types.ModuleType:
if dotted not in sys.modules:
sys.modules[dotted] = types.ModuleType(dotted)
return sys.modules[dotted]
def _ensure_attr(mod: types.ModuleType, name: str, value: object) -> None:
if not hasattr(mod, name):
setattr(mod, name, value)
def _install_stubs() -> None:
_ensure_module("molecule_runtime")
_ensure_module("molecule_runtime.adapters")
base = _ensure_module("molecule_runtime.adapters.base")
_ensure_attr(base, "BaseAdapter", type("BaseAdapter", (), {}))
_ensure_attr(base, "AdapterConfig", type("AdapterConfig", (), {}))
shared = _ensure_module("molecule_runtime.adapters.shared_runtime")
_ensure_attr(shared, "brief_task", lambda *a, **kw: "")
_ensure_attr(shared, "extract_message_text", lambda *a, **kw: "")
_ensure_attr(shared, "set_current_task", lambda *a, **kw: None)
_ensure_module("a2a")
_ensure_module("a2a.server")
a2a_exec = _ensure_module("a2a.server.agent_execution")
_ensure_attr(a2a_exec, "AgentExecutor", type("AgentExecutor", (), {}))
def _load_adapter():
_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 # noqa: WPS433
return adapter
@pytest.fixture
def resolve():
"""Bind the routing helper after stubbed import."""
return _load_adapter()._resolve_provider_routing
# ---- Branch coverage on `_resolve_provider_routing` -------------------------
def test_bare_model_id_routes_to_openai(resolve):
"""No ``:`` prefix → falls to the legacy OpenAI default."""
prefix, model, url, key = resolve(
"gpt-4o-mini",
env={"OPENAI_API_KEY": "sk-test"},
runtime_config={},
)
assert prefix == "openai"
assert model == "gpt-4o-mini"
assert url == "https://api.openai.com/v1"
assert key == "sk-test"
def test_openai_prefix_strips_and_routes(resolve):
prefix, model, url, key = resolve(
"openai:gpt-4o",
env={"OPENAI_API_KEY": "sk-test"},
runtime_config={},
)
assert prefix == "openai"
assert model == "gpt-4o"
assert url == "https://api.openai.com/v1"
def test_groq_prefix_uses_groq_key(resolve):
prefix, model, url, key = resolve(
"groq:llama-3.1-70b",
env={"GROQ_API_KEY": "gsk-test", "OPENAI_API_KEY": "sk-also"},
runtime_config={},
)
assert prefix == "groq"
assert model == "llama-3.1-70b"
assert url == "https://api.groq.com/openai/v1"
# GROQ_API_KEY wins because it's listed first in the per-prefix tuple
# — operator with both keys set doesn't accidentally shadow groq with
# the openai fallback.
assert key == "gsk-test"
def test_groq_prefix_falls_back_to_openai_key(resolve):
"""Some operators only set OPENAI_API_KEY for a groq endpoint —
the per-prefix tuple lists ``OPENAI_API_KEY`` as the second-choice
fallback for groq specifically. Pin the fallback so removing it
breaks loudly."""
prefix, model, url, key = resolve(
"groq:llama-3.1-70b",
env={"OPENAI_API_KEY": "sk-only"},
runtime_config={},
)
assert prefix == "groq"
assert key == "sk-only"
def test_qianfan_prefix_with_aistudio_fallback(resolve):
prefix, model, url, key = resolve(
"qianfan:ernie-4.0",
env={"AISTUDIO_API_KEY": "aist-test"},
runtime_config={},
)
assert prefix == "qianfan"
assert model == "ernie-4.0"
assert url == "https://qianfan.baidubce.com/v2"
assert key == "aist-test"
def test_anthropic_prefix_reroutes_via_openrouter(resolve):
"""``anthropic:<id>`` → ``openrouter`` with model rewritten to
``anthropic/<id>`` slash-form. This is the load-bearing fix:
OpenRouter exposes Claude under the OpenAI-compat API at the
slash-form id, and openclaw is OpenAI-compat only, so this is
the one path that actually returns Claude tokens."""
prefix, model, url, key = resolve(
"anthropic:claude-opus-4-7",
env={"OPENROUTER_API_KEY": "or-test"},
runtime_config={},
)
assert prefix == "openrouter"
assert model == "anthropic/claude-opus-4-7"
assert url == "https://openrouter.ai/api/v1"
assert key == "or-test"
def test_claude_prefix_reroutes_via_openrouter(resolve):
"""``claude:<id>`` is treated as an alias for ``anthropic:<id>``
so wheel/CrewAI/langchain users typing either spelling land on
the same OpenRouter path."""
prefix, model, url, _ = resolve(
"claude:claude-sonnet-4",
env={"OPENROUTER_API_KEY": "or-test"},
runtime_config={},
)
assert prefix == "openrouter"
assert model == "anthropic/claude-sonnet-4"
def test_anthropic_prefix_without_openrouter_key_raises(resolve):
"""Fail fast with actionable guidance — silently routing to
OPENAI_API_KEY + api.openai.com was the original bug shape."""
with pytest.raises(RuntimeError) as exc:
resolve(
"anthropic:claude-opus-4-7",
env={"OPENAI_API_KEY": "sk-only"},
runtime_config={},
)
msg = str(exc.value)
assert "OPENROUTER_API_KEY" in msg
assert "openclaw is OpenAI-" in msg
def test_no_api_key_for_resolved_prefix_raises(resolve):
"""No key set at all → RuntimeError that names which env vars
were searched, so an operator can paste it straight into Railway
or 1Password."""
with pytest.raises(RuntimeError) as exc:
resolve(
"openai:gpt-4o",
env={},
runtime_config={},
)
msg = str(exc.value)
assert "no API key found" in msg
assert "OPENAI_API_KEY" in msg
def test_runtime_config_provider_url_overrides_default(resolve):
"""An operator can pin a self-hosted OpenAI-compat gateway via
``runtime_config.provider_url`` (e.g. an Azure-OpenAI deployment)
pin that the runtime_config override wins over the per-prefix
default."""
_, _, url, _ = resolve(
"openai:gpt-4o",
env={"OPENAI_API_KEY": "sk-test"},
runtime_config={"provider_url": "https://my-gateway.internal/v1"},
)
assert url == "https://my-gateway.internal/v1"
def test_runtime_config_none_uses_default(resolve):
"""Defensive: setup() always passes ``config.runtime_config`` (a
dict) but the helper accepts ``None`` so unit tests / ad-hoc
callers don't have to fabricate one."""
_, _, url, _ = resolve(
"openai:gpt-4o",
env={"OPENAI_API_KEY": "sk-test"},
runtime_config=None,
)
assert url == "https://api.openai.com/v1"
def test_unknown_prefix_falls_back_to_openai_key(resolve):
"""Unknown prefix → falls back to the OPENAI key/url path so
operator-supplied prefixes that genuinely *are* OpenAI-compat
pass through. anthropic/claude is the only explicit re-route."""
prefix, model, url, key = resolve(
"togetherai:meta-llama-3.1-70b",
env={"OPENAI_API_KEY": "sk-test"},
runtime_config={},
)
assert prefix == "togetherai"
assert model == "meta-llama-3.1-70b"
assert url == "https://api.openai.com/v1"
assert key == "sk-test"