Skip to content

feat: OpenRouter provider adapter - #204

Open
NahumKorda wants to merge 2 commits into
knostic:masterfrom
NahumKorda:feat/openrouter-adapter
Open

feat: OpenRouter provider adapter#204
NahumKorda wants to merge 2 commits into
knostic:masterfrom
NahumKorda:feat/openrouter-adapter

Conversation

@NahumKorda

Copy link
Copy Markdown
Contributor

Adds openrouter as a first-class provider type, per the roadmap's "More provider adapters" item; related to #9 and the pricing-accounting invariants of #65. Complements #203 (Bedrock) — the two are independent; whichever merges second has a trivial conflict in providers/__init__.py, model_registry.py, models.json, and the README table.

Why a dedicated adapter (vs. type: "openai" + base_url)

The README already suggests OpenRouter can be reached through the OpenAI adapter with a base_url override, and a live gap-analysis confirmed that mostly works — text rounds and full tool round-trips pass. A dedicated type closes what doesn't:

  • Unknown models were untyped. OpenRouter reports an unknown model as 400 "... is not a valid model ID", not 404 (live-verified), so the generic mapping buried it in LLMResponseError; it now maps to LLMNotFoundError and the registry's init-time validate() reports it as the typo it is.
  • Cost accounting read $0. OpenRouter's vendor/model slugs (anthropic/claude-sonnet-4.6, ...) had no config/models.json records. This PR adds 15, covering the current Claude/GPT/Gemini models, with rates pulled from the live OpenRouter catalogue (GET /api/v1/models, 2026-08-01).
  • Key handling. api_key falls back to OPENROUTER_API_KEY and fails loud when unresolvable — never falling through to the SDK's OPENAI_API_KEY default, which would quietly send an OpenAI key to a third party.
  • OpenRouter-only error semantics. 402 credits-exhausted → LLMAuthError with a top-up hint; 403 is OpenRouter's moderation signal → LLMRefusalError (a moderated scan prompt must not read as an auth failure — or as a clean pass); a provider failing mid-generation comes back as HTTP 200 with finish_reason == "error" → raised as LLMResponseError instead of warn-and-normalise.
  • Attribution headers (HTTP-Referer / X-Title), per OpenRouter's app-attribution docs.

The wire format is OpenAI's, so the adapter reuses the OpenAI adapter's translation helpers (openai.py's shared warnings take an adapter= name so reused code attributes messages correctly). _token_param already strips vendor prefixes, so openai/o3-style slugs get max_completion_tokens for free.

Testing

  • Contract harness row (reusing the OpenAI scenario scripts — same SDK boundary) + 16 OpenRouter-specific tests, all mocked. Full core suite passes (2 pre-existing Zig parser failures unrelated to this change, present on clean master).
  • Live-validated against a real OpenRouter account: validate(), a text round, a full echo-tool round-trip on anthropic/claude-sonnet-4.6, and the unknown-model → LLMNotFoundError mapping.

Notes for reviewers

  • Go wizard support (openant setup llm) is intentionally out of scope, matching feat: AWS Bedrock provider adapter #203; the README documents hand-authored config.
  • Gemini 2.0/1.5 records were deliberately not mirrored — OpenRouter no longer lists them in its catalogue.

🤖 Generated with Claude Code

Adds `openrouter` as a first-class provider type. OpenRouter fronts
many model providers behind one OpenAI-compatible endpoint, one API
key, and one prepaid balance, so the adapter is a thin delegation
layer over the OpenAI adapter's translation helpers. What it adds is
exactly the OpenRouter-specific surface:

- default base_url https://openrouter.ai/api/v1 (still overridable);
  api_key falls back to OPENROUTER_API_KEY and fails loud when
  unresolvable, never falling through to the SDK's OPENAI_API_KEY
- HTTP-Referer / X-Title attribution headers
- error-mapping deltas, live-verified where possible: 400 "not a
  valid model ID" -> LLMNotFoundError (OpenRouter does not 404 for
  unknown models), 402 credits-exhausted -> LLMAuthError with a
  top-up hint, 403 moderation-flag -> LLMRefusalError, and
  finish_reason == "error" (mid-generation provider failure) ->
  LLMResponseError instead of warn-and-normalise
- 15 pricing records in config/models.json for the current
  Claude/GPT/Gemini models under their OpenRouter vendor/model slugs,
  rates pulled from the live OpenRouter catalogue (2026-08-01)
- openai.py's shared warnings/errors now take an adapter= name so
  reused code attributes messages to OpenRouterAdapter
- contract-harness row (reusing the OpenAI scenario scripts) plus 16
  OpenRouter-specific tests; README shipped-adapters entry

Live-validated against OpenRouter: validate(), a text round, a full
tool round-trip, and the unknown-model mapping.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds utilities/llm/providers/OPENROUTER.md — prerequisites, full
config.json example, key resolution order, model-slug conventions,
cost accounting, an error/troubleshooting table, and current
limitations — and links it from the README's shipped-adapters table.

Placed next to the adapter code (the repo convention, cf. the parser
PARSER_PIPELINE.md files) because docs/ is gitignored — which is also
why the README's HOW_TO_ADD_AN_ADAPTER.md link resolves to nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@NahumKorda

Copy link
Copy Markdown
Contributor Author

Added a detailed usage guide in 33cc127: utilities/llm/providers/OPENROUTER.md — prerequisites, key resolution, a full config.json example, model-slug conventions, cost accounting, and a troubleshooting table. Placed next to the adapter code rather than under docs/, since docs/ is gitignored.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant