fix(adapter): route anthropic:/claude: prefixes via OpenRouter, not OpenAI
Some checks failed
CI / validate (push) Failing after 0s
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>
This commit is contained in:
parent
c787269aba
commit
c1fc78090e
5
.gitignore
vendored
5
.gitignore
vendored
@ -19,3 +19,8 @@
|
||||
# Workspace auth tokens
|
||||
.auth-token
|
||||
.auth_token
|
||||
|
||||
# Python bytecode + pytest cache
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
|
||||
115
adapter.py
115
adapter.py
@ -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")):
|
||||
|
||||
@ -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
9
tests/pytest.ini
Normal 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
252
tests/test_model_routing.py
Normal 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"
|
||||
Loading…
Reference in New Issue
Block a user