diff --git a/README.md b/README.md index c1b76afa..a6a32d96 100644 --- a/README.md +++ b/README.md @@ -87,8 +87,9 @@ Wizard defaults reflect the project's per-phase recommendations (stronger reason | `anthropic` | [console.anthropic.com](https://console.anthropic.com/settings/keys) | Reference adapter. NOT included in Claude Pro / Max subscriptions — separate billing. | | `openai` | [platform.openai.com](https://platform.openai.com/api-keys) | NOT included in ChatGPT / Codex subscriptions — separate billing. | | `google` | [aistudio.google.com](https://aistudio.google.com/apikey) | NOT included in Gemini Advanced — separate billing. | +| `openrouter` | [openrouter.ai](https://openrouter.ai/settings/keys) | Gateway to many providers with one key and one prepaid balance (also reads `OPENROUTER_API_KEY`). Model IDs are `vendor/model` slugs (`anthropic/claude-sonnet-4.6`, `openai/gpt-4o-mini`, ...) — browse them at [openrouter.ai/models](https://openrouter.ai/models). Not offered by the setup wizard yet; configure by hand — full guide: [`utilities/llm/providers/OPENROUTER.md`](libs/openant-core/utilities/llm/providers/OPENROUTER.md). | -All three support tool calling, so any of them can drive the `enhance` and `verify` phases that use the agentic tool-use loop. +All four support tool calling, so any of them can drive the `enhance` and `verify` phases that use the agentic tool-use loop. #### Quick path for Anthropic-only setups @@ -128,7 +129,7 @@ The wizard writes `~/.config/openant/config.json` for you, but you can edit it d } ``` -Providers accept a custom `base_url` for OpenAI-compatible / Anthropic-compatible proxies (OpenRouter, vLLM, Bedrock, internal gateways). The `openant-default` config (Claude across all phases) is built in and always available regardless of file contents. +Providers accept a custom `base_url` for OpenAI-compatible / Anthropic-compatible proxies (vLLM, Bedrock, internal gateways); OpenRouter has its own first-class `openrouter` provider type. The `openant-default` config (Claude across all phases) is built in and always available regardless of file contents. #### Adding a new provider adapter diff --git a/config/models.json b/config/models.json index f945be07..e82817d8 100644 --- a/config/models.json +++ b/config/models.json @@ -133,6 +133,96 @@ "price": {"input": 0.075, "output": 0.30}, "source": "openant shipped pricing table (utilities/model_config.py); not re-verified against live Google pricing", "retrieved": "2026-07-23" + }, + { + "id": "anthropic/claude-opus-4.8", "provider": "openrouter", "status": "current", + "price": {"input": 5.00, "output": 25.00}, + "source": "verified against the live OpenRouter catalogue (GET https://openrouter.ai/api/v1/models), per-token rates converted to per-1M; OpenRouter's rate, which may differ from the direct provider's", + "retrieved": "2026-08-01" + }, + { + "id": "anthropic/claude-sonnet-4.6", "provider": "openrouter", "status": "current", + "price": {"input": 3.00, "output": 15.00}, + "source": "verified against the live OpenRouter catalogue (GET https://openrouter.ai/api/v1/models), per-token rates converted to per-1M; OpenRouter's rate, which may differ from the direct provider's", + "retrieved": "2026-08-01" + }, + { + "id": "anthropic/claude-haiku-4.5", "provider": "openrouter", "status": "current", + "price": {"input": 1.00, "output": 5.00}, + "source": "verified against the live OpenRouter catalogue (GET https://openrouter.ai/api/v1/models), per-token rates converted to per-1M; OpenRouter's rate, which may differ from the direct provider's", + "retrieved": "2026-08-01" + }, + { + "id": "openai/gpt-4o", "provider": "openrouter", "status": "current", + "price": {"input": 2.50, "output": 10.00}, + "source": "verified against the live OpenRouter catalogue (GET https://openrouter.ai/api/v1/models), per-token rates converted to per-1M; OpenRouter's rate, which may differ from the direct provider's", + "retrieved": "2026-08-01" + }, + { + "id": "openai/gpt-4o-mini", "provider": "openrouter", "status": "current", + "price": {"input": 0.15, "output": 0.60}, + "source": "verified against the live OpenRouter catalogue (GET https://openrouter.ai/api/v1/models), per-token rates converted to per-1M; OpenRouter's rate, which may differ from the direct provider's", + "retrieved": "2026-08-01" + }, + { + "id": "openai/gpt-4.1", "provider": "openrouter", "status": "current", + "price": {"input": 2.00, "output": 8.00}, + "source": "verified against the live OpenRouter catalogue (GET https://openrouter.ai/api/v1/models), per-token rates converted to per-1M; OpenRouter's rate, which may differ from the direct provider's", + "retrieved": "2026-08-01" + }, + { + "id": "openai/gpt-4.1-mini", "provider": "openrouter", "status": "current", + "price": {"input": 0.40, "output": 1.60}, + "source": "verified against the live OpenRouter catalogue (GET https://openrouter.ai/api/v1/models), per-token rates converted to per-1M; OpenRouter's rate, which may differ from the direct provider's", + "retrieved": "2026-08-01" + }, + { + "id": "openai/gpt-4.1-nano", "provider": "openrouter", "status": "current", + "price": {"input": 0.10, "output": 0.40}, + "source": "verified against the live OpenRouter catalogue (GET https://openrouter.ai/api/v1/models), per-token rates converted to per-1M; OpenRouter's rate, which may differ from the direct provider's", + "retrieved": "2026-08-01" + }, + { + "id": "openai/o1", "provider": "openrouter", "status": "current", + "price": {"input": 15.00, "output": 60.00}, + "source": "verified against the live OpenRouter catalogue (GET https://openrouter.ai/api/v1/models), per-token rates converted to per-1M; OpenRouter's rate, which may differ from the direct provider's", + "retrieved": "2026-08-01" + }, + { + "id": "openai/o3", "provider": "openrouter", "status": "current", + "price": {"input": 2.00, "output": 8.00}, + "source": "verified against the live OpenRouter catalogue (GET https://openrouter.ai/api/v1/models), per-token rates converted to per-1M; OpenRouter's rate, which may differ from the direct provider's", + "retrieved": "2026-08-01" + }, + { + "id": "openai/o3-mini", "provider": "openrouter", "status": "current", + "price": {"input": 1.10, "output": 4.40}, + "source": "verified against the live OpenRouter catalogue (GET https://openrouter.ai/api/v1/models), per-token rates converted to per-1M; OpenRouter's rate, which may differ from the direct provider's", + "retrieved": "2026-08-01" + }, + { + "id": "openai/o4-mini", "provider": "openrouter", "status": "current", + "price": {"input": 1.10, "output": 4.40}, + "source": "verified against the live OpenRouter catalogue (GET https://openrouter.ai/api/v1/models), per-token rates converted to per-1M; OpenRouter's rate, which may differ from the direct provider's", + "retrieved": "2026-08-01" + }, + { + "id": "google/gemini-2.5-pro", "provider": "openrouter", "status": "current", + "price": {"input": 1.25, "output": 10.00}, + "source": "verified against the live OpenRouter catalogue (GET https://openrouter.ai/api/v1/models), per-token rates converted to per-1M; OpenRouter's rate, which may differ from the direct provider's", + "retrieved": "2026-08-01" + }, + { + "id": "google/gemini-2.5-flash", "provider": "openrouter", "status": "current", + "price": {"input": 0.30, "output": 2.50}, + "source": "verified against the live OpenRouter catalogue (GET https://openrouter.ai/api/v1/models), per-token rates converted to per-1M; OpenRouter's rate, which may differ from the direct provider's", + "retrieved": "2026-08-01" + }, + { + "id": "google/gemini-2.5-flash-lite", "provider": "openrouter", "status": "current", + "price": {"input": 0.10, "output": 0.40}, + "source": "verified against the live OpenRouter catalogue (GET https://openrouter.ai/api/v1/models), per-token rates converted to per-1M; OpenRouter's rate, which may differ from the direct provider's", + "retrieved": "2026-08-01" } ] } diff --git a/libs/openant-core/core/model_registry.py b/libs/openant-core/core/model_registry.py index e08558ad..9a29c9e4 100644 --- a/libs/openant-core/core/model_registry.py +++ b/libs/openant-core/core/model_registry.py @@ -39,7 +39,7 @@ _CONFIG_REL = Path("config") / "models.json" _SEARCH_LEVELS = 6 _VALID_STATUS = frozenset({"current", "retired", "unknown"}) -_VALID_PROVIDERS = frozenset({"anthropic", "openai", "google"}) +_VALID_PROVIDERS = frozenset({"anthropic", "openai", "google", "openrouter"}) def _search_upward(start: Path) -> Path | None: diff --git a/libs/openant-core/tests/_llm_factories/openrouter.py b/libs/openant-core/tests/_llm_factories/openrouter.py new file mode 100644 index 00000000..22b857e8 --- /dev/null +++ b/libs/openant-core/tests/_llm_factories/openrouter.py @@ -0,0 +1,43 @@ +"""Scenario factory for the OpenRouter adapter contract tests. + +OpenRouter speaks OpenAI's Chat Completions wire format through the same +``openai`` SDK, so the scripted fake-client behaviors are shared with +the OpenAI factory — this module reuses its scenario handlers verbatim +and only swaps the adapter under construction. What differs between the +two adapters (default base_url, ``OPENROUTER_API_KEY`` fallback, +attribution headers, 400-invalid-model / 402-credits / 403-moderation / +finish_reason-"error" mapping) is covered by the dedicated +``tests/test_llm_openrouter_adapter.py``. + +See ``tests/test_llm_adapter_contract.py`` for the scenario catalogue. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock + +import openai + +from utilities.llm import LLMAdapter +from utilities.llm.providers.openrouter import OpenRouterAdapter + +from .openai import _SCENARIO_HANDLERS as SCENARIO_HANDLERS + + +def make_adapter(scenario: str) -> LLMAdapter: + """Build an OpenRouterAdapter whose SDK is scripted for ``scenario``.""" + if scenario not in SCENARIO_HANDLERS: + raise KeyError(f"Unknown scenario: {scenario!r}") + + handler = SCENARIO_HANDLERS[scenario] + + def side_effect(**kwargs: Any) -> Any: + return handler(kwargs) + + fake_client = MagicMock(spec=openai.OpenAI) + fake_client.chat = MagicMock() + fake_client.chat.completions = MagicMock() + fake_client.chat.completions.create = MagicMock(side_effect=side_effect) + + return OpenRouterAdapter(_client=fake_client) diff --git a/libs/openant-core/tests/test_llm_adapter_contract.py b/libs/openant-core/tests/test_llm_adapter_contract.py index e518df1e..e63f25d8 100644 --- a/libs/openant-core/tests/test_llm_adapter_contract.py +++ b/libs/openant-core/tests/test_llm_adapter_contract.py @@ -112,12 +112,19 @@ def _google_factory(): return make_adapter +def _openrouter_factory(): + from tests._llm_factories.openrouter import make_adapter + + return make_adapter + + # Each row: (display_name, scenario_factory_callable) # Add a row when registering a new adapter. ADAPTERS: list[tuple[str, Callable[[str], LLMAdapter]]] = [ ("anthropic", _anthropic_factory()), ("openai", _openai_factory()), ("google", _google_factory()), + ("openrouter", _openrouter_factory()), ] diff --git a/libs/openant-core/tests/test_llm_openrouter_adapter.py b/libs/openant-core/tests/test_llm_openrouter_adapter.py new file mode 100644 index 00000000..34b4163c --- /dev/null +++ b/libs/openant-core/tests/test_llm_openrouter_adapter.py @@ -0,0 +1,324 @@ +"""OpenRouter-adapter-specific tests. + +The shared contract harness (``test_llm_adapter_contract.py``) covers +behaviors every adapter must satisfy, and the request/response +translation layer is the OpenAI adapter's (reused, covered by +``test_llm_openai_adapter.py``). This file covers the bits that are +specific to OpenRouter: + +* constructor plumbing — default base_url, override, attribution + headers, ``OPENROUTER_API_KEY`` env fallback, fail-loud when no key + can be resolved (never falling through to the SDK's OPENAI_API_KEY) +* vendor/model slugs passed through verbatim; ``openai/o3``-style + slugs still get ``max_completion_tokens`` +* 400 "not a valid model ID" mapped to LLMNotFoundError (live-verified + OpenRouter behavior — it is NOT a 404) +* 402 insufficient-credits mapped to LLMAuthError with a top-up hint +* 403 moderation-flag mapped to LLMRefusalError; plain 403 stays auth +* mid-generation provider failure (finish_reason == "error") raised as + LLMResponseError instead of warn-and-normalise +* 429 reports to the global rate limiter; other statuses don't + +These tests stub the SDK boundary so nothing hits the network. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import MagicMock + +import httpx +import openai +import pytest + +from utilities.llm import ( + LLMAuthError, + LLMNotFoundError, + LLMRateLimitError, + LLMRefusalError, + LLMResponseError, + Message, + TextBlock, +) +from utilities.llm.providers.openrouter import OpenRouterAdapter +from utilities.rate_limiter import get_rate_limiter, reset_rate_limiter + + +@pytest.fixture(autouse=True) +def _reset_state(): + reset_rate_limiter() + yield + reset_rate_limiter() + + +def _ok_response(*, text="hi", finish_reason="stop"): + return SimpleNamespace( + choices=[SimpleNamespace( + message=SimpleNamespace(content=text, tool_calls=None), + finish_reason=finish_reason, + )], + usage=SimpleNamespace(prompt_tokens=1, completion_tokens=1), + ) + + +def _stub_adapter(side_effect): + client = MagicMock(spec=openai.OpenAI) + client.chat = MagicMock() + client.chat.completions = MagicMock() + client.chat.completions.create = MagicMock(side_effect=side_effect) + return OpenRouterAdapter(_client=client), client + + +def _fake_http_resp(status, *, retry_after=None): + headers = {} + if retry_after is not None: + headers["retry-after"] = retry_after + return httpx.Response( + status_code=status, + headers=headers, + request=httpx.Request("POST", "https://openrouter.ai/api/v1/chat/completions"), + ) + + +def _complete(adapter, model="anthropic/claude-sonnet-4.6"): + return adapter.complete( + model=model, + system=None, + messages=[Message(role="user", content=[TextBlock("hi")])], + max_tokens=8, + ) + + +# --------------------------------------------------------------------------- +# Constructor plumbing +# --------------------------------------------------------------------------- + + +class TestConstructor: + def _patched(self, monkeypatch): + captured = {} + + class FakeOpenAI: + def __init__(self, **kwargs): + captured.update(kwargs) + self.chat = MagicMock() + + monkeypatch.setattr( + "utilities.llm.providers.openrouter.openai.OpenAI", FakeOpenAI + ) + return captured + + def test_defaults_to_openrouter_base_url(self, monkeypatch): + captured = self._patched(monkeypatch) + OpenRouterAdapter(api_key="sk-or-v1-test") + assert captured["base_url"] == "https://openrouter.ai/api/v1" + assert captured["api_key"] == "sk-or-v1-test" + assert captured["max_retries"] == 5 + + def test_base_url_override_wins(self, monkeypatch): + captured = self._patched(monkeypatch) + OpenRouterAdapter(api_key="sk-or-v1-test", base_url="https://gateway.internal/v1") + assert captured["base_url"] == "https://gateway.internal/v1" + + def test_attribution_headers_sent(self, monkeypatch): + captured = self._patched(monkeypatch) + OpenRouterAdapter(api_key="sk-or-v1-test") + headers = captured["default_headers"] + assert headers["HTTP-Referer"] == "https://github.com/knostic/OpenAnt" + assert headers["X-Title"] == "OpenAnt" + + def test_env_fallback_is_openrouter_api_key(self, monkeypatch): + captured = self._patched(monkeypatch) + monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-v1-from-env") + # OPENAI_API_KEY must be irrelevant even when present. + monkeypatch.setenv("OPENAI_API_KEY", "sk-proj-wrong-provider") + OpenRouterAdapter() + assert captured["api_key"] == "sk-or-v1-from-env" + + def test_no_key_anywhere_fails_loud(self, monkeypatch): + """Without an explicit key or OPENROUTER_API_KEY the constructor + must raise a typed error — NOT construct an SDK client that + would fall back to OPENAI_API_KEY and quietly send an OpenAI + key to a third party.""" + self._patched(monkeypatch) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + monkeypatch.setenv("OPENAI_API_KEY", "sk-proj-should-never-be-used") + with pytest.raises(LLMAuthError) as exc_info: + OpenRouterAdapter() + assert "OPENROUTER_API_KEY" in str(exc_info.value) + + +# --------------------------------------------------------------------------- +# Model slugs +# --------------------------------------------------------------------------- + + +class TestModelSlugs: + def test_slug_passed_verbatim(self): + adapter, client = _stub_adapter(lambda **kw: _ok_response()) + _complete(adapter, model="google/gemini-2.5-flash") + kwargs = client.chat.completions.create.call_args.kwargs + assert kwargs["model"] == "google/gemini-2.5-flash" + assert kwargs["max_tokens"] == 8 + + def test_reasoning_slug_gets_max_completion_tokens(self): + """``_token_param`` strips the vendor prefix, so reasoning + models routed through OpenRouter must not 400 on max_tokens.""" + adapter, client = _stub_adapter(lambda **kw: _ok_response()) + _complete(adapter, model="openai/o3-mini") + kwargs = client.chat.completions.create.call_args.kwargs + assert kwargs["max_completion_tokens"] == 8 + assert "max_tokens" not in kwargs + + def test_validate_probes_the_passed_slug(self): + adapter, client = _stub_adapter(lambda **kw: _ok_response()) + adapter.validate(model="anthropic/claude-haiku-4.5") + kwargs = client.chat.completions.create.call_args.kwargs + assert kwargs["model"] == "anthropic/claude-haiku-4.5" + assert kwargs["max_tokens"] == 1 + + +# --------------------------------------------------------------------------- +# OpenRouter-specific error mapping +# --------------------------------------------------------------------------- + + +class TestErrorMapping: + def test_invalid_model_400_maps_to_not_found(self): + """OpenRouter reports an unknown model as 400 'not a valid + model ID' (live-verified), not 404 — it must still fail like a + typo'd model so the registry's validate() catches it at init.""" + + def respond(**kw): + raise openai.BadRequestError( + message=( + "Error code: 400 - {'error': {'message': " + "'anthropic/claude-no-such-model is not a valid model ID', " + "'code': 400}}" + ), + response=_fake_http_resp(400), + body=None, + ) + + adapter, _ = _stub_adapter(respond) + with pytest.raises(LLMNotFoundError) as exc_info: + _complete(adapter, model="anthropic/claude-no-such-model") + assert "openrouter.ai/models" in str(exc_info.value) + + adapter, _ = _stub_adapter(respond) + with pytest.raises(LLMNotFoundError): + adapter.validate(model="anthropic/claude-no-such-model") + + def test_other_400_is_a_response_error(self): + def respond(**kw): + raise openai.BadRequestError( + message="max_tokens must be positive", + response=_fake_http_resp(400), + body=None, + ) + + adapter, _ = _stub_adapter(respond) + with pytest.raises(LLMResponseError): + _complete(adapter) + + def test_402_maps_to_auth_error_with_credits_hint(self): + def respond(**kw): + raise openai.APIStatusError( + message="Insufficient credits", + response=_fake_http_resp(402), + body=None, + ) + + adapter, _ = _stub_adapter(respond) + with pytest.raises(LLMAuthError) as exc_info: + _complete(adapter) + assert "credits" in str(exc_info.value) + + adapter, _ = _stub_adapter(respond) + with pytest.raises(LLMAuthError): + adapter.validate(model="anthropic/claude-haiku-4.5") + + def test_403_moderation_maps_to_refusal(self): + """OpenRouter reserves 403 for input moderation; a moderated + scan prompt must surface as a refusal, not an auth failure.""" + + def respond(**kw): + raise openai.PermissionDeniedError( + message="Your input was flagged by moderation", + response=_fake_http_resp(403), + body=None, + ) + + adapter, _ = _stub_adapter(respond) + with pytest.raises(LLMRefusalError): + _complete(adapter) + + def test_403_without_moderation_wording_stays_auth(self): + def respond(**kw): + raise openai.PermissionDeniedError( + message="Key disabled by organization policy", + response=_fake_http_resp(403), + body=None, + ) + + adapter, _ = _stub_adapter(respond) + with pytest.raises(LLMAuthError): + _complete(adapter) + + def test_finish_reason_error_raises_response_error(self): + """A provider failing mid-generation comes back as HTTP 200 + with finish_reason == 'error'; it must raise, never normalise + to a clean-looking end_turn.""" + adapter, _ = _stub_adapter( + lambda **kw: _ok_response(text="partial…", finish_reason="error") + ) + with pytest.raises(LLMResponseError) as exc_info: + _complete(adapter) + assert "mid-generation" in str(exc_info.value) + + +# --------------------------------------------------------------------------- +# Rate-limiter coordination +# --------------------------------------------------------------------------- + + +class TestRateLimiterCoordination: + def test_429_reports_to_global_limiter(self): + def respond(**kw): + raise openai.RateLimitError( + message="rate limited", + response=_fake_http_resp(429, retry_after="3"), + body=None, + ) + + adapter, _ = _stub_adapter(respond) + with pytest.raises(LLMRateLimitError) as exc_info: + _complete(adapter) + assert exc_info.value.retry_after == 3 + assert get_rate_limiter().is_in_backoff() + + def test_validate_429_does_not_back_off_the_fleet(self): + def respond(**kw): + raise openai.RateLimitError( + message="rate limited", + response=_fake_http_resp(429, retry_after="3"), + body=None, + ) + + adapter, _ = _stub_adapter(respond) + with pytest.raises(LLMRateLimitError): + adapter.validate(model="anthropic/claude-haiku-4.5") + assert not get_rate_limiter().is_in_backoff() + + def test_other_api_status_errors_do_not_trigger_backoff(self): + def respond(**kw): + raise openai.APIStatusError( + message="bad gateway (provider down)", + response=_fake_http_resp(502), + body=None, + ) + + adapter, _ = _stub_adapter(respond) + with pytest.raises(LLMResponseError): + _complete(adapter) + assert not get_rate_limiter().is_in_backoff() diff --git a/libs/openant-core/utilities/llm/providers/OPENROUTER.md b/libs/openant-core/utilities/llm/providers/OPENROUTER.md new file mode 100644 index 00000000..a53e906d --- /dev/null +++ b/libs/openant-core/utilities/llm/providers/OPENROUTER.md @@ -0,0 +1,170 @@ +# Using the OpenRouter adapter + +OpenAnt's `openrouter` provider type routes pipeline phases through +[OpenRouter](https://openrouter.ai) — a gateway that fronts many model +providers (Anthropic, OpenAI, Google, and hundreds of others) behind one +OpenAI-compatible endpoint, **one API key, and one prepaid balance**. + +Reasons to use it instead of the direct provider adapters: + +- **One key, one bill.** A mixed-provider config (e.g. Claude for + detection, GPT for enhancement, Gemini for reporting) normally needs + three API keys and three billing accounts. Through OpenRouter it needs + one of each. +- **Prepaid spending cap.** OpenRouter balances are topped up in + advance, so a scan can never charge more than what's loaded — a + useful hard stop for a tool whose token spend scales with repo size. +- **Models without a direct adapter.** Any tool-calling model in + OpenRouter's catalogue can drive the pipeline, including vendors + OpenAnt has no adapter for. + +## Prerequisites + +1. An OpenRouter account with a positive credit balance + ([openrouter.ai/settings/credits](https://openrouter.ai/settings/credits)). +2. An API key (`sk-or-v1-...`) from + [openrouter.ai/settings/keys](https://openrouter.ai/settings/keys). + +There is nothing to install beyond OpenAnt itself — the adapter uses the +same `openai` SDK the OpenAI adapter ships with. + +## Configuration + +The `openant setup llm` wizard does not offer `openrouter` yet; add it +to `~/.config/openant/config.json` by hand. A complete single-provider +example (all seven pipeline phases are required): + +```json +{ + "$schema_version": 2, + "default_llm": "via-openrouter", + "llm_providers": { + "openrouter": {"type": "openrouter", "api_key": "sk-or-v1-..."} + }, + "llm_configs": { + "via-openrouter": { + "app_context": {"provider": "openrouter", "model": "openai/gpt-4o-mini"}, + "llm_reach": {"provider": "openrouter", "model": "anthropic/claude-sonnet-4.6"}, + "enhance": {"provider": "openrouter", "model": "openai/gpt-4o-mini"}, + "analyze": {"provider": "openrouter", "model": "anthropic/claude-sonnet-4.6"}, + "verify": {"provider": "openrouter", "model": "anthropic/claude-sonnet-4.6"}, + "dynamic_test": {"provider": "openrouter", "model": "google/gemini-2.5-flash"}, + "report": {"provider": "openrouter", "model": "google/gemini-2.5-flash"} + } + } +} +``` + +Run a scan against it: + +```bash +openant scan /path/to/repo --llm-config via-openrouter +``` + +Mixing is fine too — an `llm_configs` entry can point some phases at +`openrouter` and others at a direct provider. + +### Key resolution + +The adapter resolves its key in this order: + +1. `api_key` on the provider entry in `config.json`; +2. the `OPENROUTER_API_KEY` environment variable. + +If neither is set, construction fails immediately with a typed +`LLMAuthError`. The adapter deliberately never falls through to the +`openai` SDK's own `OPENAI_API_KEY` default — that would silently send +an OpenAI platform key to a third party. + +### `base_url` override + +`base_url` defaults to `https://openrouter.ai/api/v1` and only needs +setting when routing through an OpenRouter-compatible proxy or internal +gateway: + +```json +"openrouter": {"type": "openrouter", "api_key": "sk-or-v1-...", "base_url": "https://gateway.internal/v1"} +``` + +## Model IDs + +OpenRouter model IDs are `vendor/model` slugs with **dotted** version +numbers — a different convention from the direct providers: + +| Direct provider ID | OpenRouter slug | +|---|---| +| `claude-sonnet-4-6` (anthropic) | `anthropic/claude-sonnet-4.6` | +| `claude-haiku-4-5-20251001` (anthropic) | `anthropic/claude-haiku-4.5` | +| `gpt-4o-mini` (openai) | `openai/gpt-4o-mini` | +| `gemini-2.5-flash` (google) | `google/gemini-2.5-flash` | + +Browse the full catalogue at +[openrouter.ai/models](https://openrouter.ai/models) or fetch it +unauthenticated: + +```bash +curl -s https://openrouter.ai/api/v1/models | python3 -m json.tool | less +``` + +IDs pass through to OpenRouter verbatim. Two details are handled for +you: + +- **Reasoning models.** Slugs like `openai/o3-mini` automatically use + `max_completion_tokens` instead of `max_tokens`, as those models + require. +- **Tool calling.** The `enhance` and `verify` phases need it; check the + `supported_parameters` field in the catalogue (look for `tools`) + before pointing those phases at an unfamiliar model. + +## Cost accounting + +`config/models.json` ships pricing records for the current Claude, GPT, +and Gemini models under their OpenRouter slugs, so scan cost reports +price those models correctly. The rates are OpenRouter's, which can +differ from the direct provider's. + +A model outside that set still works — it just reports `$0` in cost +accounting with a one-time warning. To price it, add a record to +`config/models.json` with `"provider": "openrouter"` and the rate shown +in the catalogue (OpenRouter lists per-token prices; the registry wants +per-million). + +## Errors and troubleshooting + +| Symptom | Meaning | Fix | +|---|---|---| +| `LLMAuthError: ... 401 ... User not found` | Bad or revoked key | Re-check the key at [openrouter.ai/settings/keys](https://openrouter.ai/settings/keys) | +| `LLMAuthError: ... OPENROUTER_API_KEY ...` at startup | No key resolvable | Set `api_key` in config.json or export `OPENROUTER_API_KEY` | +| `LLMAuthError: ... 402 ... balance is exhausted` | Out of credits | Top up at [openrouter.ai/settings/credits](https://openrouter.ai/settings/credits) | +| `LLMNotFoundError: ... is not a valid model ID` | Typo'd or delisted slug | Check the exact slug at [openrouter.ai/models](https://openrouter.ai/models); note the dotted versions | +| `LLMRefusalError` on a 403 | OpenRouter's moderation flagged the input | Pick a model/route without mandatory moderation, or expect gaps in coverage for that unit | +| `LLMResponseError: ... provider errored mid-generation` | The upstream provider failed while streaming the answer (OpenRouter reports this inside an HTTP 200) | Retry; if persistent, pick a different model or vendor route | +| `LLMRateLimitError` | 429 from OpenRouter or the upstream provider | Nothing to do — workers back off cooperatively via the global rate limiter | + +Two behaviors worth knowing because they differ from the direct +adapters: + +- An unknown model comes back from OpenRouter as **HTTP 400**, not 404. + The adapter still surfaces it as `LLMNotFoundError`, so a typo'd slug + fails fast at config-validation time instead of mid-scan. +- A 403 is OpenRouter's *moderation* signal, not an auth failure. The + adapter maps it to `LLMRefusalError` so a moderated prompt can't + masquerade as a clean, finding-free pass — refusals matter for a + security scanner. + +## Attribution + +Requests carry OpenRouter's optional attribution headers +(`HTTP-Referer: https://github.com/knostic/OpenAnt`, `X-Title: +OpenAnt`). They only affect OpenRouter's public app-usage rankings — +never routing, pricing, or auth. + +## Current limitations + +- Not offered by the `openant setup llm` wizard yet (config by hand, as + above). Wizard support needs a few Go touch-points in + `apps/openant-cli/cmd/setup.go`. +- OpenRouter-specific request extensions (provider routing preferences, + fallback model lists, ZDR enforcement) are not exposed; requests use + OpenRouter's account-level defaults, which you can set at + [openrouter.ai/settings/preferences](https://openrouter.ai/settings/preferences). diff --git a/libs/openant-core/utilities/llm/providers/__init__.py b/libs/openant-core/utilities/llm/providers/__init__.py index 0c494527..b08c0b2f 100644 --- a/libs/openant-core/utilities/llm/providers/__init__.py +++ b/libs/openant-core/utilities/llm/providers/__init__.py @@ -43,11 +43,15 @@ def get_adapter_class(provider_type: str) -> Type[LLMAdapter]: from .google import GoogleAdapter return GoogleAdapter + if provider_type == "openrouter": + from .openrouter import OpenRouterAdapter + + return OpenRouterAdapter raise ValueError( f"Unknown provider type: {provider_type!r}. " - f"Supported in this release: 'anthropic', 'openai', 'google'. " - f"To add a provider, see " + f"Supported in this release: 'anthropic', 'openai', 'google', " + f"'openrouter'. To add a provider, see " f"docs/features/llm-providers/HOW_TO_ADD_AN_ADAPTER.md." ) @@ -58,4 +62,4 @@ def known_provider_types() -> list[str]: Used by the Go CLI's ``llm-provider set`` to validate the ``type`` field before writing config.json. """ - return ["anthropic", "openai", "google"] + return ["anthropic", "openai", "google", "openrouter"] diff --git a/libs/openant-core/utilities/llm/providers/openai.py b/libs/openant-core/utilities/llm/providers/openai.py index a40631ec..34241151 100644 --- a/libs/openant-core/utilities/llm/providers/openai.py +++ b/libs/openant-core/utilities/llm/providers/openai.py @@ -125,7 +125,7 @@ def _token_param(model: str) -> str: return "max_completion_tokens" if _is_reasoning_model(model) else "max_tokens" -def _warn_bad_tool_json(tool_name: str) -> None: +def _warn_bad_tool_json(tool_name: str, *, adapter: str = "OpenAIAdapter") -> None: """One-time stderr warning when a tool call's ``arguments`` aren't valid JSON.""" should_warn = False with _warned_bad_tool_json_lock: @@ -134,7 +134,7 @@ def _warn_bad_tool_json(tool_name: str) -> None: should_warn = True if should_warn: sys.stderr.write( - f"warning: OpenAIAdapter could not parse tool-call arguments for " + f"warning: {adapter} could not parse tool-call arguments for " f"{tool_name!r} as JSON; passing empty input {{}}. The tool call " f"will likely fail downstream with a missing-field error.\n" ) @@ -361,7 +361,7 @@ def _tool_to_openai(tool: ToolDef) -> dict[str, Any]: } -def _response_to_unified(response: Any) -> CompletionResult: +def _response_to_unified(response: Any, *, adapter: str = "OpenAIAdapter") -> CompletionResult: """Translate an OpenAI ChatCompletion response into our types.""" choices = getattr(response, "choices", None) or [] if not choices: @@ -370,8 +370,9 @@ def _response_to_unified(response: Any) -> CompletionResult: # (mirrors the Gemini empty-``candidates`` guard); for a security # tool an empty end_turn would read as a clean, passing result. raise LLMResponseError( - "OpenAI returned no choices (empty completion); the request " - "may have been filtered or the response was malformed" + f"{adapter}: provider returned no choices (empty completion); " + f"the request may have been filtered or the response was " + f"malformed" ) choice = choices[0] message = choice.message @@ -397,7 +398,7 @@ def _response_to_unified(response: Any) -> CompletionResult: # back to an empty dict: the subsequent tool execution # surfaces a clear "missing required field" error, and a # multi-tool turn's other calls still proceed. - _warn_bad_tool_json(getattr(tc.function, "name", "")) + _warn_bad_tool_json(getattr(tc.function, "name", ""), adapter=adapter) input_dict = {} content_blocks.append(ToolUseBlock( id=tc.id, @@ -412,9 +413,9 @@ def _response_to_unified(response: Any) -> CompletionResult: # calls. OpenAI reports this as ``finish_reason == "content_filter"``. if raw_finish == _OPENAI_CONTENT_FILTER_REASON: raise LLMRefusalError( - "OpenAI content-filtered the response " - "(finish_reason='content_filter'); the completion was withheld " - "or truncated by the moderation layer" + f"{adapter}: the response was content-filtered " + f"(finish_reason='content_filter'); the completion was withheld " + f"or truncated by the moderation layer" ) if raw_finish not in _OPENAI_FINISH_REASONS: @@ -425,11 +426,11 @@ def _response_to_unified(response: Any) -> CompletionResult: should_warn = True if should_warn: sys.stderr.write( - f"warning: OpenAIAdapter received unknown finish_reason " + f"warning: {adapter} received unknown finish_reason " f"{raw_finish!r}; normalising to 'end_turn'. Add this value " f"to StopReason in utilities/llm/adapter.py and " - f"_OPENAI_FINISH_REASONS if OpenAI added a new termination " - f"reason.\n" + f"_OPENAI_FINISH_REASONS if the provider added a new " + f"termination reason.\n" ) usage = getattr(response, "usage", None) diff --git a/libs/openant-core/utilities/llm/providers/openrouter.py b/libs/openant-core/utilities/llm/providers/openrouter.py new file mode 100644 index 00000000..dc32a87c --- /dev/null +++ b/libs/openant-core/utilities/llm/providers/openrouter.py @@ -0,0 +1,266 @@ +"""OpenRouter adapter — implements :class:`LLMAdapter` via the OpenAI SDK. + +OpenRouter (https://openrouter.ai) is a routing gateway that fronts many +model providers behind one OpenAI-compatible Chat Completions endpoint, +one API key, and one balance. Because the wire format is OpenAI's, this +adapter is a thin delegation layer over the OpenAI adapter's translation +helpers (``_messages_to_openai`` / ``_tool_to_openai`` / +``_response_to_unified`` / ``_token_param``) — the same +reuse-not-duplicate approach the docstrings in ``openai.py`` describe. +What it adds on top is exactly the OpenRouter-specific surface: + +* **Endpoint + auth.** ``base_url`` defaults to + ``https://openrouter.ai/api/v1`` (still overridable for + OpenRouter-compatible gateways). ``api_key`` falls back to + ``OPENROUTER_API_KEY`` — never the SDK's ``OPENAI_API_KEY``, which + would silently send an OpenAI key to a third party. + +* **Attribution headers.** ``HTTP-Referer`` / ``X-Title`` identify + OpenAnt in OpenRouter's rankings, per their attribution docs. + +* **Model IDs.** OpenRouter model IDs are ``vendor/model`` slugs with + dotted versions (``anthropic/claude-sonnet-4.6``, + ``openai/gpt-4o-mini``); the full catalogue is at + https://openrouter.ai/models. IDs pass through verbatim. + ``_token_param`` already strips the ``openai/`` prefix, so reasoning + models routed through OpenRouter get ``max_completion_tokens``. + +* **Error deltas** (verified against a live OpenRouter account, + 2026-08-01, except where noted): + + - An unknown model is a **400** ``"... is not a valid model ID"`` — + not a 404 — so the generic OpenAI mapping would bury it in + :class:`LLMResponseError`. It maps to :class:`LLMNotFoundError` + here so the registry's init-time ``validate()`` reports it as the + typo it is. (Live-verified.) + - **402** means the account balance is exhausted; it maps to + :class:`LLMAuthError` with a top-up hint — an account-level, + won't-fix-itself condition, not a per-request one. (Per OpenRouter + error docs; not reproducible on a funded account.) + - **403** is OpenRouter's *moderation* signal — "your input was + flagged" — which maps to :class:`LLMRefusalError` so a moderated + scan prompt doesn't read as an auth problem (or worse, a clean + pass). A 403 without moderation wording still maps to + :class:`LLMAuthError` (key disabled, org restrictions). (Per + OpenRouter error docs.) + - A provider that fails **mid-generation** surfaces as a normal 200 + with ``finish_reason == "error"``; that raises + :class:`LLMResponseError` instead of warn-and-normalise, because a + truncated completion must not read as a finding-free pass. (Per + OpenRouter docs.) + +Rate limiting matches the OpenAI adapter: 429s report to the +process-global ``RateLimiter`` and every request waits on it first, so +one worker's backpressure slows the whole fan-out. +""" + +from __future__ import annotations + +import os +from typing import Any, Optional + +import openai + +from ..adapter import ( + CompletionResult, + LLMAuthError, + LLMConnectionError, + LLMNotFoundError, + LLMRateLimitError, + LLMRefusalError, + LLMResponseError, + Message, + ToolDef, +) +from ._ratelimit import report_rate_limit, wait_for_rate_limit +from .openai import ( + _messages_to_openai, + _response_to_unified, + _retry_after_from, + _token_param, + _tool_to_openai, +) +from .._pricing import LazyProviderPricing +from .._redact import redact_secrets, redacted_cause_from + + +_DEFAULT_BASE_URL = "https://openrouter.ai/api/v1" + +# Optional attribution headers, per https://openrouter.ai/docs/app-attribution. +# They only affect OpenRouter's public app rankings — never routing or auth. +_ATTRIBUTION_HEADERS = { + "HTTP-Referer": "https://github.com/knostic/OpenAnt", + "X-Title": "OpenAnt", +} + +# Live-verified fragment of OpenRouter's unknown-model 400 body: +# {'error': {'message': ' is not a valid model ID', 'code': 400}} +_INVALID_MODEL_MARKER = "not a valid model id" + +# OpenRouter's 403 moderation errors carry wording like "your input was +# flagged" / "requires moderation"; a 403 without it is a real +# permission problem. +_MODERATION_MARKERS = ("moderation", "flagged") + +_CREDITS_HINT = ( + " (HTTP 402 from OpenRouter means the account balance is exhausted — " + "top up at https://openrouter.ai/settings/credits)" +) + +_MODEL_ID_HINT = ( + " (OpenRouter model IDs are vendor/model slugs, e.g. " + "anthropic/claude-sonnet-4.6 — browse them at " + "https://openrouter.ai/models)" +) + + +def _classify_error(exc: Exception, *, report_429: bool) -> Exception: + """Map an ``openai`` SDK exception to the adapter taxonomy. + + Shared by ``complete()`` and ``validate()``; only ``complete()`` + reports 429s to the global limiter (``report_429``), matching the + reference adapters — a validate() probe shouldn't back off a scan. + """ + message = redact_secrets(str(exc)) + lowered = message.lower() + + if isinstance(exc, openai.AuthenticationError): + return LLMAuthError(message) + if isinstance(exc, openai.PermissionDeniedError): + # OpenRouter reserves 403 for input-moderation flags; only fall + # back to auth semantics when the body says nothing about it. + if any(marker in lowered for marker in _MODERATION_MARKERS): + return LLMRefusalError(message) + return LLMAuthError(message) + if isinstance(exc, openai.RateLimitError): + retry_after = _retry_after_from(exc) + if report_429: + report_rate_limit(retry_after) + return LLMRateLimitError(message, retry_after=retry_after) + if isinstance(exc, openai.NotFoundError): + return LLMNotFoundError(message + _MODEL_ID_HINT) + if isinstance(exc, openai.APIConnectionError): + return LLMConnectionError(message) + if isinstance(exc, openai.BadRequestError) and _INVALID_MODEL_MARKER in lowered: + return LLMNotFoundError(message + _MODEL_ID_HINT) + if getattr(exc, "status_code", None) == 402: + return LLMAuthError(message + _CREDITS_HINT) + # BadRequestError (other 400s), APIStatusError (5xx, anything else). + return LLMResponseError(message) + + +def _raise_on_error_finish(response: Any) -> None: + """Raise when OpenRouter reports a mid-generation provider failure. + + OpenRouter surfaces an upstream provider error inside an otherwise + successful response as ``finish_reason == "error"`` — a value no + direct provider uses. Warn-and-normalise (the shared helper's + unknown-reason path) would let a truncated completion pass as + ``end_turn``; for a security scan that's a silent false negative. + """ + choices = getattr(response, "choices", None) or [] + if choices and getattr(choices[0], "finish_reason", None) == "error": + raise LLMResponseError( + "OpenRouterAdapter: the upstream provider errored " + "mid-generation (finish_reason='error'); the completion is " + "incomplete. Retry, or pick a different route/model." + ) + + +class OpenRouterAdapter: + """:class:`LLMAdapter` implementation backed by OpenRouter's + OpenAI-compatible endpoint via ``openai.OpenAI``.""" + + name = "openrouter" + supports_tools = True + + # Resolved lazily from config/models.json (the shared registry) on + # first access; see utilities/llm/_pricing.py. Records mirror the + # `current` models of the three direct providers, under OpenRouter's + # vendor/model IDs. + pricing = LazyProviderPricing("openrouter") + + def __init__( + self, + *, + api_key: Optional[str] = None, + base_url: Optional[str] = None, + max_retries: int = 5, + _client: Optional[openai.OpenAI] = None, + ): + """Construct the adapter. + + Args: + api_key: OpenRouter API key (``sk-or-...``). When ``None``, + falls back to ``OPENROUTER_API_KEY`` in the environment. + Resolved eagerly so the SDK can never fall through to + its own ``OPENAI_API_KEY`` default and quietly send an + OpenAI key to OpenRouter. + base_url: Override the endpoint. ``None`` means + ``https://openrouter.ai/api/v1``. + max_retries: Forwarded to the SDK (retries transient 429s + and 5xx client-side). + _client: Injected SDK instance for testing. + """ + if _client is not None: + self._client = _client + return + + if api_key is None: + api_key = os.environ.get("OPENROUTER_API_KEY") + if not api_key: + raise LLMAuthError( + "OpenRouterAdapter: no API key. Set `api_key` on the " + "provider entry in config.json or export " + "OPENROUTER_API_KEY. Keys are created at " + "https://openrouter.ai/settings/keys." + ) + + self._client = openai.OpenAI( + api_key=api_key, + base_url=base_url if base_url is not None else _DEFAULT_BASE_URL, + max_retries=max_retries, + default_headers=_ATTRIBUTION_HEADERS, + ) + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def complete( + self, + *, + model: str, + system: Optional[str], + messages: list[Message], + max_tokens: int, + tools: Optional[list[ToolDef]] = None, + ) -> CompletionResult: + request: dict[str, Any] = { + "model": model, + _token_param(model): max_tokens, + "messages": _messages_to_openai(messages, system, model), + } + if tools: + request["tools"] = [_tool_to_openai(t) for t in tools] + + # Cooperate with cross-worker backoff before issuing the call. + wait_for_rate_limit() + + try: + response = self._client.chat.completions.create(**request) + except openai.APIError as exc: + raise _classify_error(exc, report_429=True) from redacted_cause_from(exc) + + _raise_on_error_finish(response) + return _response_to_unified(response, adapter="OpenRouterAdapter") + + def validate(self, model: str) -> None: + try: + self._client.chat.completions.create(**{ + "model": model, + _token_param(model): 1, + "messages": [{"role": "user", "content": "hi"}], + }) + except openai.APIError as exc: + raise _classify_error(exc, report_429=False) from redacted_cause_from(exc)