Skip to content

Add OpenRouter as a model provider - #487

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

Add OpenRouter as a model provider#487
dsaad68 wants to merge 5 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 and others added 3 commits August 28, 2026 00:57
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>
@dsaad68
dsaad68 force-pushed the feat/openrouter-provider branch from cd4d0a6 to e5855e3 Compare August 28, 2026 00:13
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>
@dsaad68 dsaad68 closed this Aug 28, 2026
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dsaad68 dsaad68 reopened this Aug 28, 2026
@dsaad68
dsaad68 marked this pull request as ready for review August 28, 2026 06:37
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