From c1fc78090eba8b7828c45afe7a1896d0a358bad7 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Fri, 1 May 2026 17:20:26 -0700 Subject: [PATCH] fix(adapter): route anthropic:/claude: prefixes via OpenRouter, not OpenAI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/`, 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) --- .gitignore | 5 + adapter.py | 115 +++++++++++++--- known-issues.md | 36 ++++++ tests/pytest.ini | 9 ++ tests/test_model_routing.py | 252 ++++++++++++++++++++++++++++++++++++ 5 files changed, 400 insertions(+), 17 deletions(-) create mode 100644 tests/pytest.ini create mode 100644 tests/test_model_routing.py diff --git a/.gitignore b/.gitignore index 2af45b5..f1bf373 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,8 @@ # Workspace auth tokens .auth-token .auth_token + +# Python bytecode + pytest cache +__pycache__/ +*.pyc +.pytest_cache/ diff --git a/adapter.py b/adapter.py index a6d144c..2b28151 100644 --- a/adapter.py +++ b/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 ``:`` 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 ``:`` 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/`` + 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/` 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")): diff --git a/known-issues.md b/known-issues.md index 7e634db..d1339d9 100644 --- a/known-issues.md +++ b/known-issues.md @@ -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:` / `claude:` 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/`. 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 + +--- diff --git a/tests/pytest.ini b/tests/pytest.ini new file mode 100644 index 0000000..8531d01 --- /dev/null +++ b/tests/pytest.ini @@ -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 diff --git a/tests/test_model_routing.py b/tests/test_model_routing.py new file mode 100644 index 0000000..2c82178 --- /dev/null +++ b/tests/test_model_routing.py @@ -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 ``:`` 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:`` → ``openrouter`` with model rewritten to + ``anthropic/`` 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:`` is treated as an alias for ``anthropic:`` + 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"