Skip to content

Add OpenRouter as a model provider - #487

Open
dsaad68 wants to merge 13 commits into
vercel-labs:mainfrom
dsaad68:feat/openrouter-provider
Open

Add OpenRouter as a model provider#487
dsaad68 wants to merge 13 commits into
vercel-labs:mainfrom
dsaad68:feat/openrouter-provider

Conversation

@dsaad68

@dsaad68 dsaad68 commented Aug 27, 2026

Copy link
Copy Markdown

Summary

Adds OpenRouter as a model provider, alongside Vercel AI Gateway, Codex, and Grok.

All three existing routes need either Vercel billing or a paid consumer subscription, and every base-URL override is restricted to loopback HTTP, so there is no way to point fx at another endpoint. OpenRouter adds ~400 models behind a single API key, including a set that cost nothing to run — so a user with no Vercel billing and no ChatGPT or Grok subscription can run fx for free.

Design notes

A new wire format. OpenRouter speaks OpenAI Chat Completions, which neither existing protocol module covers (vercel_protocol.zig is Vercel v3, responses_protocol.zig is the OpenAI Responses API). chat_completions_protocol.zig carries no vendor identity so any future OpenAI-compatible route can share it. It accumulates streamed tool calls by their index field and skips SSE comment lines, which OpenRouter emits as : OPENROUTER PROCESSING keep-alives mid-stream — feeding one to a JSON parser aborts an otherwise healthy stream.

API key, no OAuth. Auth follows the existing ai_gateway_api_key shape via OPENROUTER_API_KEY. There is no stored session, so fx logout openrouter says so rather than falling through to the Vercel logout.

Tool-capable models only. fx calls tools every turn, so the catalog fetches with ?supported_parameters=tools; a model that cannot call tools breaks on the first step.

Free models are surfaced deliberately. Identified by published pricing, sorted first, and shown four ways: a Free fact in the /model menu, a marker in fx models, a free field in its JSON, and a --free filter. is_free is authoritative; the :free id suffix is a display convenience for surfaces that only carry id strings, and a fixture test pins the two together.

Exact usage. OpenRouter reports token counts and credit cost inline on the terminal chunk, so no deferred reconciliation is needed. The 402, 429, and 503 responses carry plain-language detail, since a negative balance blocks even free models and free-tier requests are capped at 20/min and 50/day.

Ordering stays provider-owned. compareModelCatalogEntries and projectPickerModelCatalog encode Vercel-specific product policy and are gateway-only, so OpenRouter is deliberately not routed through them.

Commits

  1. Add OpenRouter as a model provider — the protocol module, transport, catalog, permission reviewer, and ProviderId plumbing.
  2. Offer OpenRouter in the interactive setup hub/setup enumerated a hardcoded three providers, so it was unreachable from the TUI.
  3. Reach OpenRouter from every provider-aware surface — a sweep for that same defect class found it in ACP config options, fx status/doctor, the tools-disabled profile, and credential guidance.

Where a hand-maintained list caused a miss, it was replaced with something the compiler or a test enforces: isGatewaySource, missingCredentialMessage, and a test that walks std.meta.tags(ProviderId) to assert every provider is reachable from the setup picker.

Testing

  • Live API: verified against real OpenRouter — a free model answering, a tool call executing, usage accounting reported.
  • Unit: 8743 passing. New tests cover the reducer (including keep-alive comments, index-correlated tool-call fragments, mid-stream errors over HTTP 200, resource ceilings), catalog parsing and free-first ordering, request serialization, and the setup picker.
  • E2E: tests/e2e/openrouter-stream.test.ts — catalog filtering, free-first ordering, the --free filter, a streamed tool-call round trip, and the 402/429 paths. Classified in corpus.json as verification-only with a shard weight.
  • Interactive: /setup → Model provider and Connections, /model, and ACP config options driven in a real terminal.

Known gap

FailureKind has no payment-required variant, so a 402 maps to forbidden — correct non-retryable semantics, but it surfaces as "HTTP 403". The detail names the real status so the message cannot mislead. Happy to add a payment_required variant instead if you'd prefer it fixed at the enum.

Note on scope

This is a fork-driven contribution and I recognize OpenRouter is a model router, so it overlaps with AI Gateway in a way that Codex and Grok do not. Opening as a draft to check appetite before polishing further — happy to close if it is not a direction you want.

🤖 Generated with Claude Code

@dsaad68
dsaad68 force-pushed the feat/openrouter-provider branch from cd4d0a6 to e5855e3 Compare August 28, 2026 00:13
@dsaad68 dsaad68 closed this Aug 28, 2026
@dsaad68 dsaad68 reopened this Aug 28, 2026
@dsaad68
dsaad68 marked this pull request as ready for review August 28, 2026 06:37
dsaad68 and others added 13 commits September 2, 2026 21:52
fx reaches models through three providers today: Vercel AI Gateway, Codex,
and Grok. All three need either Vercel billing or a paid consumer
subscription, and every base-URL override is restricted to loopback HTTP, so
there is no way to point fx at another endpoint.

OpenRouter adds ~400 models behind a single API key, including a set that
cost nothing to run. That is the motivating outcome: a user with no Vercel
billing and no ChatGPT or Grok subscription can now run fx for free.

OpenRouter speaks the OpenAI Chat Completions format, which neither existing
protocol module covers (vercel_protocol.zig is Vercel v3,
responses_protocol.zig is the OpenAI Responses API). The new
chat_completions_protocol.zig carries no vendor identity so any future
OpenAI-compatible route can share it. It accumulates streamed tool calls by
their `index` field and skips SSE comment lines, which OpenRouter emits as
`: OPENROUTER PROCESSING` keep-alives mid-stream.

Auth is a plain API key from OPENROUTER_API_KEY, following the existing
ai_gateway_api_key shape rather than adding an OAuth flow. There is no stored
session, so `fx logout openrouter` says so instead of falling through to the
Vercel logout.

Because fx calls tools on every turn, the catalog fetches only tool-capable
models (?supported_parameters=tools); a model that cannot call tools breaks
on the first step. Free models are identified by published pricing, sorted
first, and surfaced four ways: a `Free` fact in the /model menu, a marker in
`fx models`, a `free` field in its JSON, and a `--free` filter. The catalog's
`is_free` flag is authoritative; the `:free` id suffix is a display
convenience for surfaces that only carry id strings, and a fixture test pins
the two together.

Usage is exact: OpenRouter reports token counts and credit cost inline on the
terminal chunk, so no deferred reconciliation is needed. The 402, 429, and
503 responses carry plain-language detail, since a negative balance blocks
even free models and free-tier requests are capped at 20/min and 50/day.

Ordering is this provider's own. compareModelCatalogEntries and
projectPickerModelCatalog encode Vercel-specific product policy and are
gateway-only, so OpenRouter is deliberately not routed through them.

Verified against the live OpenRouter API and, offline, against a fake in
tests/e2e/openrouter-stream.test.ts covering catalog filtering, free-first
ordering, a streamed tool-call round trip, and the error paths. Unit tests
cover the reducer, catalog parsing, and request serialization.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first commit wired OpenRouter through the CLI and the /model menu but
missed /setup entirely, so the provider was unreachable from the TUI: the
Model provider screen enumerated a hardcoded three entries and stopped at
Grok. Selecting OpenRouter required `fx provider openrouter` from a shell.

The provider stage now offers every ProviderId, and a test walks
std.meta.tags to assert that, so a future provider cannot be added without
appearing here.

Connections gains an OpenRouter row reporting whether the key is present in
the environment. It starts no sign-in flow, because there is none: selecting
it explains that OPENROUTER_API_KEY is read from the environment. The row
also appears during onboarding, where a user with no Vercel billing and no
subscription most needs to learn a free path exists.

credential_source_order was missing the key, so probing never recorded it and
Connections could not have reported its status. Adding it exposed that the
Credential source screen filtered subscriptions by listing them individually;
that is now an isGatewaySource predicate, which keeps provider-scoped keys out
of a screen that picks a Gateway credential and fails closed for any source
added later.

Verified in a real terminal: Model provider lists OpenRouter as current,
Connections reports "environment" when the key is set, and selecting that row
renders the guidance notice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A sweep for the same defect class as the setup-hub miss found four more
places that enumerate providers by hand, where the compiler cannot help:

- The ACP provider config option was a hardcoded JSON list, so an ACP client
  could report OpenRouter as current but never offer it as a choice.
- fx status and doctor built connected_providers from three explicit checks,
  omitting OpenRouter even while reporting OPENROUTER_API_KEY as the auth
  source.
- A tools-disabled host profile cleared the reviewer for three providers by
  name, leaving OpenRouter's active in a profile with no tools.
- Missing-credential guidance fell through an if/else chain to the Gateway
  message, telling an OpenRouter user to run `fx login`. That chain is now
  missingCredentialMessage/missingInteractiveCredentialMessage, a switch, so
  a new provider must state its own guidance rather than inherit Vercel's.

Token facts printed the raw window for values that are not round decimals.
Gateway models are advertised in round numbers so this never showed, but
OpenRouter reports powers of two, rendering "1048576 context · 230400
output". Non-round values now round to the nearest unit; exact multiples are
untouched.

Verified interactively: the /model menu shows "1M context · 230K output ·
Free" with the OpenRouter catalog status line, ACP lists all four providers
with openrouter current, and fx status reports OpenRouter under
connected_providers in both text and JSON.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The upstream draft was closed, so the rationale it carried — why a new
chat-completions protocol module was needed, why the catalog filters to
tool-capable models, and how free models are surfaced — would otherwise only
survive in a closed pull request.

Not part of the feature: it documents the change rather than shipping with it,
and should be removed before any future upstream submission so it does not
appear in the diff.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
OpenRouter was environment-only: /setup listed an "OpenRouter API key" row
that printed a notice telling the user to export OPENROUTER_API_KEY and
restart, and `fx setup` always prompted for a Gateway key. Every other
credential in fx can be pasted and saved, so OpenRouter now can be too.

SecretStore gains a sibling slot rather than a second store threaded through
its ~126 call sites: `openrouter` points at a store backed by its own
keychain service (FX_OPENROUTER_API_KEY) and its own profile file
(~/.fx/openrouter-api-key). Hosts that keep one slot leave it null and stay
on the environment variable, so no existing construction changes.

The environment still wins over a saved key, so a shell override keeps
working, and a store that cannot be read falls through to "absent" rather
than failing the whole resolution.

Three defects this exposed:

- The onboarding welcome screen rendered only four of the five connection
  choices, so the OpenRouter row was invisible. Its row block is now sized
  from choiceCount() instead of four hardcoded indices.
- Because the root stage already counted five choices, the invisible row was
  still selectable, and confirming it hit `unreachable` in takePickerChoice.
  In ReleaseSafe that traps. Onboarding shows the connection rows on the
  root stage, so both stages now open key entry.
- The Connections list was capped at the shared six-row picker budget,
  hiding the fifth entry behind a scroll. The hub screens hold a fixed,
  small set of rows, so they are now sized by what they contain; only the
  team and credential lists, which grow with the account, keep the cap.

`fx provider` and `/logout` still advertised <gateway|codex|grok>, the same
hand-maintained-list defect the earlier sweep found elsewhere.

Verified end to end: seeded the keychain slot directly, confirmed
`fx login openrouter` resolved it with no environment variable set, then
removed it. Full suite shows no new failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The /model provider tabs are vendor buckets derived from the model id
prefix, so an OpenRouter catalog showed no tab naming the catalog itself.

A first attempt added `openrouter` as another vendor bucket matching an
"openrouter/" id prefix. That never rendered: the live catalog carries 315
models across 37 vendor prefixes and none of them is "openrouter", so the
tab could not match anything. The tab is not a vendor at all.

It is now derived from the catalog source instead. It selects every model
and is offered only while credentials.Source.openrouter_api_key is the
authenticated source, which is the same field that already prints the
"OpenRouter catalog: authenticated with an API key." status line. Sizing the
tab by where the models came from rather than what is in them also means an
empty or single-vendor OpenRouter catalog still shows it.

It sits last among the named tabs, ahead of the Others catch-all.

A test asserted the literal index 1 for the Anthropic tab; the enum order
now decides that, so it reads the index from the enum. Also adds a test that
typing filters an OpenRouter catalog, which had no coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An OpenRouter id already carries a vendor, so the list read
"google/gemma-4-26b-a4b-it:free" with nothing saying which route serves it.
Rows now read "openrouter/google/gemma-4-26b-a4b-it:free".

This is display only. The id sent to OpenRouter, saved as the model
preference, and matched against the catalog stays unprefixed, because
OpenRouter does not know a model by that name.

Display-only creates a trap, though: the prefix is on screen but was not in
what the query matched, so typing what the row shows would find nothing.
Matching now also considers the qualified name, so "openrouter/google" finds
the row a user is looking straight at while the bare id keeps working. The
prefix is not searchable on a Gateway catalog, where it is not shown.

The qualified name is ellipsized as one string, so a narrow row trims the
model id rather than the catalog that explains it.

Verified against the live 314-model catalog: rows render qualified and
"openrouter/goo" filters to the Google models.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reverts the model-list tab and the "openrouter/provider/model-name" display.
The list is back to the model id as the provider publishes it, and the tab
strip to its vendor buckets.

Neither earned its place. The tab selected the whole catalog, so it did the
same work as All whenever it was visible at all. The qualified name repeated
on every row what the catalog status line already says once, and it had to
be display only, which forced a matching rule so a query could still find
the name it was looking at.

Keeps two test changes the tab commit carried, both of which stand on their
own: the provider-tab test reads its index from the enum rather than a
literal, and an OpenRouter catalog now has coverage that typing filters it.

Verified against the live catalog: rows read "inclusionai/ling-3.0-flash-fin:free",
the strip reads [All] Anthropic OpenAI Others, and typing "gemma" filters
343 models to 7.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Saving a key from onboarding left the welcome copy on screen. Nothing said
the key had taken, so the obvious response was to enter it again, which is
what happened: two "Saved the API key" notices behind an unchanged screen.

Every OAuth path calls closePicker on success. Key entry never did; it only
restored the screen behind it, which for onboarding is the welcome screen
itself. A saved key now ends the flow the same way a completed sign-in does.
Outside onboarding the Gateway key still returns to Connections, so adding
several credentials in one visit keeps working.

An OpenRouter key goes further, matching Codex and Grok: it activates the
provider through auth_transition rather than only being stored, since
choosing that row is how a user asks for OpenRouter.

Two related defects the same path carried:

- The save recorded `.stored_key` as the credential preference whatever was
  saved, so storing an OpenRouter key told the Gateway route to prefer a
  stored Gateway key. The result now carries the source it reloaded as, and
  a provider-scoped key is not recorded as a Gateway preference at all, the
  rule the subscription sources already follow.
- The notice said "Saved the API key" for either provider. It names which.

The Connections row reported "environment" for the OpenRouter key. That was
true when the key could only come from the environment; a stored key and an
environment key now resolve to the same source, so the row cannot tell them
apart and no longer claims one.

Verified by driving the real flow: the row reads "configured", entry prompts
for the OpenRouter key, and saving reports the key saved, switches the
provider, and closes the picker.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Onboarding offered "Add an API key" and "OpenRouter API key". The first did
not say which key it took, and the pair did not read as two of the same kind
of choice. They are now "Add an AI Gateway API key" and "Add an OpenRouter
API key".

Onboarding is the one screen with no heading naming the provider, so the row
has to carry it. The Connections list keeps its shorter labels, where the
heading already supplies that context.

Verified by rendering the welcome screen against an isolated profile with no
credentials, which is the only state that shows it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The profile file is the whole key store on every non-macOS target, so the
slot added beside the Gateway key needs the same 0600 guarantee the Gateway
file already asserts. Both slots are now checked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rebasing onto upstream left the branch merging cleanly but not compiling.
Upstream added exhaustive switches over CredentialSource and ProviderId in
places this branch never touched, so the values it introduces were unhandled:
requestedSource, sourcePresence, and the two credential-repair hints. That is
the same hand-maintained-list defect this branch already swept for once; the
compiler names every site, so each is answered rather than defaulted.

Upstream also:

- Guards Runtime.initInto with a field count, which the key-entry target
  field trips. The guard is doing its job, so initInto now sets it.
- Widened activateProviderSelection with an exact credential source. Like
  Codex and Grok, OpenRouter pins none: its key is provider-scoped and must
  not become the Gateway's preferred source.
- Replaced key probing with a presence check that reads store metadata and
  never the secret. The OpenRouter slot had no presence function, so it would
  have reported "unavailable" for a key that is present; presence is now
  slot-aware across both backends, and the OpenRouter probe no longer reads
  the key, which on macOS spawned a Keychain read per inventory refresh.

The top-level help test asserted the old setup summary. `fx setup` takes a
provider on this branch, so the assertion follows the command.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dsaad68
dsaad68 force-pushed the feat/openrouter-provider branch from 6e2a1bf to 63434f2 Compare September 2, 2026 20:45
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