OpenRouter plugin
The openrouter plugin gives Genkit access to OpenRouter, a gateway that
serves models from many vendors behind one OpenAI-compatible endpoint. Models are named under the
openrouter/ provider prefix.
Installation
Section titled “Installation”go get github.com/firebase/genkit/goConfiguration
Section titled “Configuration”Add &openrouter.OpenRouter{} to your plugin list. The plugin reads the API key from the
OPENROUTER_API_KEY environment variable.
package main
import ( "context" "fmt" "log"
"github.com/firebase/genkit/go/ai" "github.com/firebase/genkit/go/genkit" "github.com/firebase/genkit/go/plugins/compat_oai/openrouter")
func main() { ctx := context.Background()
g := genkit.Init(ctx, genkit.WithPlugins(&openrouter.OpenRouter{}), genkit.WithDefaultModel("openrouter/anthropic/claude-sonnet-4.5"), )
text, err := genkit.GenerateText(ctx, g, ai.WithPrompt("Explain reinforcement learning in two sentences.")) if err != nil { log.Fatalf("could not generate: %v", err) }
fmt.Println(text)}You must provide an API key from OpenRouter. You can get one from your
OpenRouter account settings. Set OPENROUTER_API_KEY, or set the
APIKey field. SiteURL and AppName are attribution only: they ride as the HTTP-Referer and
X-Title headers, which name your application on OpenRouter’s public rankings and change nothing
else about a request. Extra OpenAI client request options ride in Opts, applied after the plugin
defaults so they win on overlap.
import ( "context" "os"
"github.com/firebase/genkit/go/genkit" "github.com/firebase/genkit/go/plugins/compat_oai/openrouter" "github.com/openai/openai-go/option")
ctx := context.Background()
g := genkit.Init(ctx, genkit.WithPlugins(&openrouter.OpenRouter{ APIKey: os.Getenv("MY_OPENROUTER_KEY"), SiteURL: "https://example.com", AppName: "My Genkit app", Opts: []option.RequestOption{ option.WithBaseURL("https://openrouter.ai/api/v1"), },}))genkit.Init panics when neither the APIKey field nor OPENROUTER_API_KEY is set. The endpoint
defaults to https://openrouter.ai/api/v1; override it with the OPENROUTER_BASE_URL environment
variable or with option.WithBaseURL in Opts.
As always, avoid embedding API keys directly in your code.
Models
Section titled “Models”An OpenRouter model ID already carries the upstream vendor’s prefix, and Genkit adds its own, so a full model name has two slashes:
ai.WithModelName("openrouter/openai/gpt-5")ai.WithModelName("openrouter/anthropic/claude-sonnet-4.5")ai.WithModelName("openrouter/meta-llama/llama-4-70b-instruct:free")The first segment is this plugin’s provider prefix, and the rest is the ID OpenRouter serves. The ID
passed to openrouter.ModelRef works either way: ModelRef("anthropic/claude-sonnet-4.5", nil) and
ModelRef("openrouter/anthropic/claude-sonnet-4.5", nil) name the same model.
OpenRouter’s variant suffixes work as part of the ID. :free picks the no-cost tier of a model,
:nitro the fastest provider serving it, and :floor the cheapest.
The plugin registers no models when it initializes, and it lists no catalog, so the Dev UI shows no browsable model list for OpenRouter. Two reasons: an action descriptor carries a full copy of the request and response schemas, so a descriptor per catalog entry would put megabytes on every reflection poll; and OpenRouter fronts hundreds of models from dozens of vendors and adds more weekly, so any curated list would be stale.
What this costs you is discovery: there is no list to pick a model from, so take the ID from OpenRouter’s model list and paste it in. What you get in return is that every ID the gateway serves works by name, including a model released after your Genkit version.
Every model the plugin resolves is described with the same deliberately permissive capabilities: multiturn, tools, tool choice, system role, and media. The two ways to be wrong are not symmetric. A capability declared too narrow is refused by Genkit before the request is sent, which blocks a model that would have worked, while one declared too wide reaches OpenRouter, which answers with the real reason. Constrained generation is the exception, left unclaimed on purpose: a large share of the catalog lacks it natively, and unset, an output schema reaches the model as prompt instructions, which every model handles and which returns the same typed result.
openrouter.ModelRef pairs a model ID with a typed openrouter.ChatConfig, so the config is
checked where you write it and validated against the model’s schema before the request goes out.
resp, err := genkit.Generate(ctx, g, ai.WithModel(openrouter.ModelRef("openai/gpt-5", &openrouter.ChatConfig{ Provider: &openrouter.ProviderRouting{ Sort: openrouter.ProviderSortPrice, DataCollection: openrouter.DataCollectionDeny, }, Models: []string{"anthropic/claude-haiku-4.5"}, })), ai.WithPrompt("Share a joke about bananas."),)if err != nil { log.Fatalf("could not generate: %v", err)}
fmt.Println(resp.Text())You can also name a model as a string with ai.WithModelName or genkit.WithDefaultModel, and pass
the config separately with ai.WithConfig(&openrouter.ChatConfig{...}). The
OpenRouter sample
runs this as a streaming flow you can call from the Dev UI.
Provider routing
Section titled “Provider routing”The same model is served by several providers at different prices, speeds, and data policies.
ChatConfig.Provider chooses among them, and is the control the gateway exists for.
capPerMillion := 3.0
resp, err := genkit.Generate(ctx, g, ai.WithModel(openrouter.ModelRef("meta-llama/llama-4-70b-instruct", &openrouter.ChatConfig{ Provider: &openrouter.ProviderRouting{ Only: []string{"together", "fireworks"}, Quantizations: []string{"fp8", "fp16"}, MaxPrice: &openrouter.MaxPrice{Completion: &capPerMillion}, }, })), ai.WithPrompt("Summarize the plot of Hamlet in three sentences."),)if err != nil { log.Fatalf("could not generate: %v", err)}
fmt.Println(resp.Text())| Field | Type | Notes |
|---|---|---|
Order | []string | Provider slugs to try in order before any fallback. |
Only | []string | Restricts routing to these provider slugs. |
Ignore | []string | Skips these provider slugs. |
AllowFallbacks | *bool | Defaults to true. false fails the request rather than letting another provider serve it. |
RequireParameters | *bool | Routes only to providers that honor every parameter the request carries. |
DataCollection | openrouter.DataCollection | allow, the default, or deny to restrict routing to providers that do not store request data. |
ZDR | *bool | Restricts routing to zero data retention endpoints. |
Sort | openrouter.ProviderSort | price, throughput, or latency, instead of the default load balancing. |
Quantizations | []string | Quantization levels a provider must serve the model at, such as int4, fp8, or bf16. Not a closed set, since OpenRouter adds levels as hardware gains them. |
MaxPrice | *openrouter.MaxPrice | Caps Prompt and Completion in USD per million tokens, and Request and Image in USD each. A request no provider can serve within the cap fails rather than falling back to a dearer one. |
PreferredMinThroughput | *float64 | Deprioritizes providers below this many output tokens per second. They stay eligible as a fallback. |
PreferredMaxLatency | *float64 | Deprioritizes providers slower than this many seconds to first token. They stay eligible as a fallback. |
Sort, PreferredMinThroughput, and PreferredMaxLatency also have an object form, a partition or
per-percentile thresholds, that this struct does not declare. Reach it through the Extra
passthrough, which replaces the whole provider object because a colliding key wins over the field
it collides with.
&openrouter.ChatConfig{ RequestConfig: compat_oai.RequestConfig{ Extra: map[string]any{ "provider": map[string]any{ "sort": map[string]any{"by": "price", "partition": "model"}, }, }, },}Falling back to another model
Section titled “Falling back to another model”ChatConfig.Models lists further models to try, in order, when the requested one is unavailable,
rate-limited, or refuses. The model the request names is tried first, and the gateway does the
switch, so it costs no extra round trip.
&openrouter.ChatConfig{ Models: []string{"anthropic/claude-haiku-4.5", "google/gemini-2.5-flash"},}This is OpenRouter’s own fallback, distinct from the Fallback middleware,
which cascades across Genkit model references and works with any provider.
Reasoning
Section titled “Reasoning”ChatConfig.Reasoning controls the thinking a model does before it answers. OpenRouter normalizes
every vendor’s reasoning controls onto one shape, so the same config reaches an OpenAI, an
Anthropic, and a Gemini model. Reasoning arrives as a Genkit reasoning part; read it with
resp.Reasoning().
resp, err := genkit.Generate(ctx, g, ai.WithModel(openrouter.ModelRef("deepseek/deepseek-r1", &openrouter.ChatConfig{ Reasoning: &openrouter.ReasoningConfig{Effort: openrouter.ReasoningEffortHigh}, })), ai.WithPrompt("Work through this step by step: what is heavier, a kilo of steel or a kilo of feathers?"),)if err != nil { log.Fatalf("could not generate: %v", err)}
fmt.Println(resp.Reasoning())fmt.Println(resp.Text())Effort is one of none, minimal, low, medium, high, xhigh, or max. Which levels a
model takes is the model’s to decide: a level the upstream vendor does not offer is an error from
OpenRouter rather than from the plugin, and none is rejected by a model that always reasons.
MaxTokens sets an exact token budget instead and overrides Effort; vendors that take a budget
usually reject one under 1,024 tokens, a per-vendor limit rather than a documented API-wide one.
Exclude keeps the reasoning out of the response without stopping the model from doing it, and
Enabled turns reasoning on with the vendor’s defaults, which OpenRouter treats as medium effort.
Effort and MaxTokens imply it. A ReasoningConfig with no field set sends nothing, so a config
built conditionally never enables reasoning by accident.
Generation config
Section titled “Generation config”openrouter.ChatConfig carries the sampling fields OpenRouter normalizes across vendors plus the
gateway controls:
| Field | Type | Notes |
|---|---|---|
Temperature | *float64 | Randomness of token selection, 0 to 2. |
TopP | *float64 | Nucleus sampling threshold, 0 to 1. |
TopK | *int | Limits sampling to the K most likely tokens. 0, the default, applies no limit. Some vendors ignore it. |
MaxOutputTokens | int | Sent as the API’s max_tokens. See the caution above before setting it on a reasoning model. |
StopSequences | []string | Up to four. |
FrequencyPenalty | *float64 | -2 to 2. |
PresencePenalty | *float64 | -2 to 2. |
RepetitionPenalty | *float64 | 0 to 2, where 1 is neutral. Penalizes tokens by whether they appeared in the input. |
MinP | *float64 | 0 to 1. Minimum probability a token needs relative to the most likely one. |
TopA | *float64 | 0 to 1. Filters tokens by a threshold scaled from the most likely token’s probability. |
Seed | *int | Makes generation reproducible on a best-effort basis. |
LogProbs | *bool | Requests log probabilities for the output tokens. |
TopLogProbs | *int | 0 to 20. Requires LogProbs. |
ParallelToolCalls | *bool | false caps the model at one tool call per response. |
User | string | Identifies the end user a request is made for, which OpenRouter uses to isolate abuse to one user rather than the whole key. |
Models | []string | Further models to fall back to, in order. |
Provider | *openrouter.ProviderRouting | Which upstream providers may serve the request. |
Reasoning | *openrouter.ReasoningConfig | How much the model thinks before it answers. |
Plugins | []map[string]any | OpenRouter request plugins such as web search, each an object with an id and that plugin’s own options, such as {"id": "web", "max_results": 3}. Sent verbatim rather than typed, since the roster changes on OpenRouter’s schedule. |
Transforms | []string | Prompt transforms to apply, currently middle-out, which compresses a prompt that would overflow the model’s context by dropping from the middle. |
SessionID | string | Groups related requests so they keep reaching the same upstream provider, which is what keeps a multi-turn conversation on one provider’s prompt cache. |
ServiceTier | openrouter.ServiceTier | auto, default, fast, flex, priority, or scale. |
Metadata | map[string]string | Up to 16 pairs attached to the request, readable later on OpenRouter’s activity pages. Keys up to 64 characters, values up to 512. |
Pointer fields separate unset from a deliberate zero. Three documented request fields are
deliberately absent: n asks for several completion choices and bills for all of them while Genkit
reads only the first, and route and usage are deprecated by OpenRouter and have no effect.
ChatConfig also embeds compat_oai.RequestConfig, which every plugin in the family shares: a
per-request APIKey, a Version pin, and an Extra map whose keys ride to the wire verbatim under
OpenRouter’s own names. See the
OpenAI-compatible plugin page.
Correcting what the plugin knows about a model
Section titled “Correcting what the plugin knows about a model”Every model works without an entry in Models. Supply one to narrow a model whose real capabilities
you know are tighter than the permissive defaults, so Genkit refuses the request locally instead of
paying for the upstream rejection. Keys are the model ID, bare or provider-prefixed, and fields left
at their zero value keep what the plugin resolved.
g := genkit.Init(ctx, genkit.WithPlugins(&openrouter.OpenRouter{ Models: map[string]ai.ModelOptions{ // A text-only model, so Genkit refuses media before the request is sent. "mistralai/mistral-7b-instruct": { Supports: &ai.ModelSupports{ Multiturn: true, Tools: true, SystemRole: true, }, }, },}))The narrowing is enforced rather than advertised: generating with media against that model fails locally with an error naming the missing media support.
Response behavior
Section titled “Response behavior”Reasoning text, streamed token usage, gateway cost, and mid-generation provider failures are handled the same way for every plugin in this family. See the OpenAI-compatible plugin page.
Cost is worth reading here in particular, since pricing every request is part of what a gateway
does. OpenRouter reports it in USD as resp.Usage.Custom["cost"]. Presence of the key decides, not
its value: a :free model is priced at an explicit zero, which is an answer, so read it with the
two-value map form rather than testing for a value above zero.
if cost, ok := resp.Usage.Custom["cost"]; ok { fmt.Printf("this request cost %v USD\n", cost)}