This page has the details of the OpenRouter adapter. To get a first chat working, start at OpenRouter.
import { createOpenRouterText } from "@tanstack/ai-openrouter";
const adapter = createOpenRouterText(
"openai/gpt-6.1-sol",
process.env.OPENROUTER_API_KEY!,
{
serverURL: "https://openrouter.ai/api/v1", // Optional
httpReferer: "https://your-app.com", // Optional, for rankings
appTitle: "Your App Name", // Optional, for rankings
},
);Need a header on each request, or a log of each model call? Both text adapters support wrapFetch. If you set an httpClient, each wrapped request still goes through it.
A busy upstream provider can reply with HTTP 429. By default, the adapter retries only 5XX errors. To retry rate limits too, add "429" to retryCodes:
import { createOpenRouterText } from "@tanstack/ai-openrouter";
const adapter = createOpenRouterText(
"openai/gpt-6.1-sol",
process.env.OPENROUTER_API_KEY!,
{
retryConfig: {
strategy: "backoff",
backoff: {
initialInterval: 500,
maxInterval: 60000,
exponent: 1.5,
maxElapsedTime: 120000,
},
retryConnectionErrors: true,
},
retryCodes: ["429", "5XX"],
},
);Now a 429 waits, then retries, and the chat continues with the next response.
Most provider errors come as an HTTP status, and the SDK retries them as shown above. But OpenRouter can send status 200 first. Then a provider error comes as an error chunk in the stream, and the SDK cannot retry it.
To retry that error, pass retry to chat():
import { chat } from "@tanstack/ai";
import { createOpenRouterText } from "@tanstack/ai-openrouter";
const adapter = createOpenRouterText(
"openai/gpt-6.1-sol",
process.env.OPENROUTER_API_KEY!,
{ retryCodes: ["429", "5XX"] },
);
const stream = chat({
adapter,
messages: [{ role: "user", content: "Hello!" }],
threadId: "thread-1",
retry: { maxRetries: 3 },
});When chat() retries:
The wait is the initialInterval of retryConfig (default 500 ms). It is the same for each try and does not grow. See Let chat() retry for you.
OpenRouter has two OpenAI-compatible wire formats. The package has one adapter for each:
| Adapter | Endpoint | Status | When to use |
|---|---|---|---|
| openRouterText | /v1/chat/completions | Stable | The default for almost everything. The most models and tools. |
| openRouterResponsesText | /v1/responses | Beta | The OpenAI Responses request and response shape, with more multi-turn state on OpenAI-style models. |
The wire format is how your app talks to OpenRouter. It does not limit which provider answers. Both adapters work with any model that OpenRouter has (anthropic/..., google/..., meta-llama/..., and more).
import { chat } from "@tanstack/ai";
import { openRouterResponsesText } from "@tanstack/ai-openrouter";
const stream = chat({
adapter: openRouterResponsesText("anthropic/claude-sonnet-5.5"),
messages: [{ role: "user", content: "Hello!" }],
threadId: "thread-1",
});Limits of the Responses adapter while it is in beta:
modelOptions.provider sets which upstream providers can serve the request, and in which order. The adapter sends it to OpenRouter as it is. This sample pins two providers and turns off fallbacks:
import { chat } from "@tanstack/ai";
import { openRouterText } from "@tanstack/ai-openrouter";
const stream = chat({
adapter: openRouterText("meta-llama/llama-4-maverick"),
messages: [{ role: "user", content: "Hello!" }],
threadId: "thread-1",
modelOptions: {
provider: {
order: ["groq", "together"], // provider slugs, in priority order
allowFallbacks: false, // fail instead of routing elsewhere
},
},
});Provider slugs are the lowercase ids on the model pages of OpenRouter, for example "anthropic", "openai", or "groq".
Fields that limit which providers can serve the request:
Fields that set the order and the price:
Note
If you stream with requireParameters: true, also set modelOptions.streamOptions: { includeUsage: false }. Otherwise the adapter sends stream_options, and OpenRouter returns a 404 for providers that do not support that parameter.
variant adds the :variant suffix of OpenRouter to the model id. The adapter does not send it in the request body:
import { chat } from "@tanstack/ai";
import { openRouterText } from "@tanstack/ai-openrouter";
const stream = chat({
adapter: openRouterText("deepseek/deepseek-v4-pro"),
messages: [{ role: "user", content: "Hello!" }],
threadId: "thread-1",
modelOptions: {
variant: "free", // requests deepseek/deepseek-v4-pro:free
},
});The values are "free", "nitro", "online", "exacto", "extended", and "thinking". The OpenRouter catalog sets which variants a model has.
plugins turns on the request plugins of OpenRouter. Each entry has an id, plus the options of that plugin:
import { chat } from "@tanstack/ai";
import { openRouterText } from "@tanstack/ai-openrouter";
const stream = chat({
adapter: openRouterText("openai/gpt-6-astra"),
messages: [{ role: "user", content: "Hello!" }],
threadId: "thread-1",
modelOptions: {
plugins: [{ id: "web", maxResults: 5 }],
},
});Plugin ids include web, file-parser, response-healing, moderation, and auto-router. For the options of each plugin, see the OpenRouter documentation. Web fetch is a server tool (openrouter:web_fetch), not a plugin. For web search and fetch as tools that the model can call, see Provider tools.
The adapter sends these fields to OpenRouter with no change:
By default, chat() sets sessionId to the cache key, which is the threadId that you pass. So one conversation stays on one provider and reads its cache.
Your own modelOptions.sessionId wins. If you add a cache marker of your own, the adapter adds no automatic markers to that request. See Prompt Caching.
Some OpenRouter models reject strict JSON schema. For these models, chat({ outputSchema }) sends json_object and adds your schema to the system prompt.
To send JSON mode to any other model, set modelOptions.responseFormat:
import { chat } from "@tanstack/ai";
import { openRouterText } from "@tanstack/ai-openrouter";
import { z } from "zod";
const person = await chat({
adapter: openRouterText("openai/gpt-5.6-sol"),
messages: [{ role: "user", content: "Return Ada Lovelace, age 36." }],
threadId: "thread-1",
modelOptions: { responseFormat: { type: "json_object" } },
outputSchema: z.object({ name: z.string(), age: z.number() }),
});Read When strict JSON schema is rejected.
You can pass both tools and outputSchema on one chat() call. For some upstream models, OpenRouter returns the typed object in that same streaming request. Then the engine does not make a second call to get the final object.
The rules:
Import the set from @tanstack/ai-openrouter/model-meta to check a model before you send:
import { OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS } from "@tanstack/ai-openrouter/model-meta";
console.log(OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS.has("openai/gpt-5.5")); // trueBoth text adapters attach the schema on this path. The server with the Chat Completions adapter:
import { chat, toServerSentEventsResponse, toolDefinition } from "@tanstack/ai";
import { openRouterText } from "@tanstack/ai-openrouter";
import { z } from "zod";
const getWeather = toolDefinition({
name: "get_weather",
description: "Get the current weather",
inputSchema: z.object({ location: z.string() }),
}).server(async ({ location }) => {
return { temperature: 72, conditions: "sunny", location };
});
const AnswerSchema = z.object({
summary: z.string(),
location: z.string(),
});
export async function POST(request: Request) {
const { messages, threadId } = await request.json();
const stream = chat({
adapter: openRouterText("openai/gpt-5.5"),
messages,
threadId,
tools: [getWeather],
outputSchema: AnswerSchema,
stream: true,
});
return toServerSentEventsResponse(stream);
}The server with the Responses adapter has the same tools and outputSchema:
import { chat, toServerSentEventsResponse, toolDefinition } from "@tanstack/ai";
import { openRouterResponsesText } from "@tanstack/ai-openrouter";
import { z } from "zod";
const getWeather = toolDefinition({
name: "get_weather",
description: "Get the current weather",
inputSchema: z.object({ location: z.string() }),
}).server(async ({ location }) => {
return { temperature: 72, conditions: "sunny", location };
});
const AnswerSchema = z.object({
summary: z.string(),
location: z.string(),
});
export async function POST(request: Request) {
const { messages, threadId } = await request.json();
const stream = chat({
adapter: openRouterResponsesText("openai/gpt-5.5"),
messages,
threadId,
tools: [getWeather],
outputSchema: AnswerSchema,
stream: true,
});
return toServerSentEventsResponse(stream);
}The client does not change. useChat({ outputSchema }) still reads partial and final:
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
import { z } from "zod";
const AnswerSchema = z.object({
summary: z.string(),
location: z.string(),
});
export function useAnswer() {
const { sendMessage, partial, final } = useChat({
connection: fetchServerSentEvents("/api/chat"),
threadId: "support-chat",
outputSchema: AnswerSchema,
});
return { sendMessage, partial, final };
}See Structured Outputs with tools for the event order, and Middleware for how the structuredOutput phase works on this path.
To try this in a browser, run examples/ts-react-chat and open /generations/openrouter-combined. The page shows the tool call, the typed object, and the call counts of the adapter. structuredOutputStream must stay at 0.
OpenRouter runs web search and web fetch for any chat model. Import the tools from @tanstack/ai-openrouter/tools. For how provider tools compare across adapters, see Provider Tools.
webSearchTool adds web search to any OpenRouter chat model. It takes the WebSearchConfig of OpenRouter:
import { chat } from "@tanstack/ai";
import { openRouterText } from "@tanstack/ai-openrouter";
import { webSearchTool } from "@tanstack/ai-openrouter/tools";
const stream = chat({
adapter: openRouterText("openai/gpt-6.1-sol"),
messages: [{ role: "user", content: "What's new in AI this week?" }],
threadId: "thread-1",
tools: [
webSearchTool({
engine: "exa",
maxResults: 5,
allowedDomains: ["arxiv.org", "openai.com"],
}),
],
});Supported models: all OpenRouter chat models. See Provider Tools.
webFetchTool lets any OpenRouter chat model read the full content of a URL that the model picks. It takes the WebFetchServerToolConfig of OpenRouter:
import { chat } from "@tanstack/ai";
import { openRouterText } from "@tanstack/ai-openrouter";
import { webFetchTool } from "@tanstack/ai-openrouter/tools";
const stream = chat({
adapter: openRouterText("openai/gpt-6.1-sol"),
messages: [
{ role: "user", content: "Summarize https://example.com/article" },
],
threadId: "thread-1",
tools: [
webFetchTool({
engine: "openrouter",
maxContentTokens: 4000,
allowedDomains: ["example.com"],
}),
],
});Note
The native engine uses the fetch of the upstream provider, for example the Anthropic web_fetch on Claude models. Native fetch is not the same on each provider, so it can ignore allowedDomains and blockedDomains. For the same behavior on all models, use openrouter, exa, or firecrawl.
Supported models: all OpenRouter chat models. See Provider Tools.
OpenRouter reports the real cost of each request in the streamed response. The adapter puts it on the last RUN_FINISHED event:
TanStack AI does not calculate this cost from token counts. It comes from OpenRouter, so it includes routing, fallback providers, BYOK, and cached-token prices. For the meaning and units of the fields, see Usage Accounting in the OpenRouter docs.
import { chat, type RunFinishedEvent, type StreamChunk } from "@tanstack/ai";
import { openRouterText } from "@tanstack/ai-openrouter";
function isRunFinished(chunk: StreamChunk): chunk is RunFinishedEvent {
return "finishReason" in chunk;
}
for await (const chunk of chat({
adapter: openRouterText("openai/gpt-6.1-sol"),
messages: [{ role: "user", content: "Hello!" }],
threadId: "thread-1",
})) {
if (isRunFinished(chunk)) {
console.log("cost:", chunk.usage?.cost);
console.log("breakdown:", chunk.usage?.costDetails);
}
}How usage works when a call fails:
openRouterVideo uses the async video API of OpenRouter (POST /api/v1/videos). You get Seedance, Veo 3.1, Wan, Kling, and more with your one OpenRouter key. Video generation is experimental.
Like every TanStack AI video adapter, it creates a job, then you poll the job. OpenRouter returns the finished video as a stream, not a URL. Persistence stores the stream and sets url, so pass the same options to both calls.
The server:
import { generateVideo, getVideoJobStatus } from "@tanstack/ai";
import { openRouterVideo } from "@tanstack/ai-openrouter";
import { withGenerationPersistence } from "@tanstack/ai-persistence";
import { persistence } from "./persistence";
const adapter = openRouterVideo("bytedance/seedance-2.5");
const hosting = {
threadId: "videos",
middleware: [
withGenerationPersistence(persistence, {
artifactUrl: (ref) => `/api/artifacts/${ref.artifactId}`,
}),
],
};
const { jobId } = await generateVideo({
adapter,
...hosting,
prompt: [
{ type: "text", content: "Animate this product shot, slow push-in" },
{
type: "image",
source: { type: "url", value: "https://your-cdn.com/product.png" },
metadata: { role: "start_frame" },
},
],
size: "1280x720",
// `duration` is typed per model. Snap raw seconds with adapter.snapDuration(),
// or list the valid values with adapter.availableDurations().
duration: 8,
});
let status = await getVideoJobStatus({ adapter, jobId, ...hosting });
while (status.status !== "completed" && status.status !== "failed") {
await new Promise((r) => setTimeout(r, 5000));
status = await getVideoJobStatus({ adapter, jobId, ...hosting });
}
// status.url is your artifact route.
// status.usage?.cost is the real cost that OpenRouter billed.
console.log(status.url, status.usage?.cost);The client tracks the job with useGenerateVideo:
import { useGenerateVideo, fetchServerSentEvents } from "@tanstack/ai-react";
export function VideoResult() {
const { result } = useGenerateVideo({
connection: fetchServerSentEvents("/api/generate/video"),
threadId: "video-studio",
});
return result?.url ? <video src={result.url} controls /> : null;
}The types check sizes, durations, and the options of each model (resolution, aspectRatio, generateAudio, seed, and more) against the video model metadata of OpenRouter. See Video Generation for the full lifecycle, streaming mode, and the image-to-video role table.
OpenRouter can make an API key that the user owns, with OAuth PKCE. Import the helpers from @tanstack/ai-openrouter/pkce. The key is stored under openrouterByok.id (openrouter) and sent as x-byok-openrouter.
On the client, start the login. When the user comes back, save the key into defineByok:
import { useEffect } from "react";
import { defineByok, defaultByokStorage } from "@tanstack/ai-client/byok";
import {
completeOpenRouterPkceIntoByok,
startOpenRouterPkceLogin,
} from "@tanstack/ai-openrouter/pkce";
const byok = defineByok({
storage: defaultByokStorage(),
});
export function OpenRouterSignIn() {
useEffect(() => {
void completeOpenRouterPkceIntoByok(byok);
}, []);
return (
<button
type="button"
onClick={() => {
void startOpenRouterPkceLogin();
}}
>
Sign in with OpenRouter
</button>
);
}On the server, read the key with the same slug that the PKCE helper wrote:
import {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
} from "@tanstack/ai";
import { byokMissing, getByokKey } from "@tanstack/ai/byok/server";
import { createOpenRouterText } from "@tanstack/ai-openrouter";
import { openrouterByok } from "@tanstack/ai-openrouter/byok";
export async function POST(request: Request) {
const params = await chatParamsFromRequest(request);
const apiKey = getByokKey(request, openrouterByok);
if (!apiKey) return byokMissing(openrouterByok);
const stream = chat({
adapter: createOpenRouterText("openai/gpt-6.1-sol", apiKey),
messages: params.messages,
threadId: params.threadId,
runId: params.runId,
});
return toServerSentEventsResponse(stream);
}See Bring Your Own Key for the keyring and passkey storage.
A CLI or a desktop app has no web page for the redirect, so the user must copy a key by hand. openrouterSignIn() signs the user in with the browser and gives your app the key:
import { chat } from "@tanstack/ai";
import { createOpenRouterText } from "@tanstack/ai-openrouter";
import { openrouterSignIn } from "@tanstack/ai-openrouter/pkce";
const signIn = openrouterSignIn();
const apiKey = await signIn({
open: (url) => console.log(`Open this URL to sign in: ${url}`),
});
const stream = chat({
adapter: createOpenRouterText("openai/gpt-6.1-sol", apiKey),
messages: [{ role: "user", content: "Hello!" }],
threadId: "thread-1",
});The sign-in uses PKCE and a random state. It stops after 10 minutes. To change the time, pass openrouterSignIn({ timeoutMs }). To cancel it, pass signal to signIn.
The listener runs on the computer of your app. So the sign-in works only when your app and the browser are on the same computer. In a hosted app, use the browser flow above.
Each factory pair has the same shape. The short factory reads OPENROUTER_API_KEY from the environment. The create* factory takes an explicit API key. Both take model as the first argument.
| Factories | Creates |
|---|---|
| openRouterText(model, config?) / createOpenRouterText(model, apiKey, config?) | Chat adapter (Chat Completions) |
| openRouterResponsesText(model, config?) / createOpenRouterResponsesText(model, apiKey, config?) | Chat adapter (Responses, beta) |
| openRouterSummarize(model, config?) / createOpenRouterSummarize(model, apiKey, config?) | Summarization adapter |
| openRouterImage(model, config?) / createOpenRouterImage(model, apiKey, config?) | Image adapter |
| openRouterVideo(model, config?) / createOpenRouterVideo(model, apiKey, config?) | Video adapter |
| openRouterRerank(model, config?) / createOpenRouterRerank(model, apiKey, config?) | Rerank adapter |
| openRouterDecider(model, config?) / createOpenRouterDecider(model, apiKey, config?) | Evaluate adapter |
The rerank adapter forwards the optional httpReferer and appTitle config fields as attribution headers, the same as the chat adapter.