- OpenAIUpdated
- Anthropic
- Google Gemini
- Google Vertex AI
- Ollama
- Grok (xAI)
- GroqUpdated
- Mistral
- Cohere
- TypeSafe
- Ollaya
- ElevenLabs
- fal.ai
- Reactor
- World Labs
- OpenRouter AdapterUpdated
- Perplexity Search
- Vercel AI Gateway
- Lovable AI Gateway
- OpenAI-CompatibleUpdated
- LLM Gateway
- CloudflareUpdated
- Claude Code
- Codex
- OpenCode
- Grok Build
- ACP-Compatible
- Amazon BedrockUpdated
- BytePlus
OpenRouter Adapter
OpenRouter is TanStack AI's first official AI partner and the recommended starting point for most projects. It provides access to 300+ models from OpenAI, Anthropic, Google, Meta, Mistral, and many more — all through a single API key and unified interface.
Installation#
npm i @tanstack/ai-openrouterpnpm add @tanstack/ai-openrouteryarn add @tanstack/ai-openrouterbun add @tanstack/ai-openrouterBasic Usage#
import { chat } from "@tanstack/ai";
import { openRouterText } from "@tanstack/ai-openrouter";
const stream = chat({
adapter: openRouterText("openai/gpt-5"),
messages: [{ role: "user", content: "Hello!" }],
});Configuration#
import { createOpenRouterText } from "@tanstack/ai-openrouter";
const adapter = createOpenRouterText(
"openai/gpt-5",
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.
Retry rate limits#
A busy upstream provider can reply with HTTP 429. The adapter retries only 5XX errors by default. Add "429" to retryCodes to retry rate limits too:
import { createOpenRouterText } from "@tanstack/ai-openrouter";
const adapter = createOpenRouterText(
"openai/gpt-5",
process.env.OPENROUTER_API_KEY!,
{
retryConfig: {
strategy: "backoff",
backoff: {
initialInterval: 500,
maxInterval: 60000,
exponent: 1.5,
maxElapsedTime: 120000,
},
retryConnectionErrors: true,
},
retryCodes: ["429", "5XX"],
},
);- retryCodes: the HTTP status codes to retry. "5XX" matches every 5xx code.
- retryConfig: how long to wait between tries. When the response has a Retry-After header, the SDK waits that long.
Now a 429 waits, then retries, and the chat continues with the next response.
Retry an error in the stream#
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-5.5",
process.env.OPENROUTER_API_KEY!,
{ retryCodes: ["429", "5XX"] },
);
const stream = chat({
adapter,
messages: [{ role: "user", content: "Hello!" }],
retry: { maxRetries: 3 },
});When chat() retries:
- Only for an error code in retryCodes. The default is 5XX only, so add "429" to retry a rate limit.
- Only when the call streamed no output yet. An error after part of the answer reaches you as a RUN_ERROR.
- Not when retryConfig has strategy: "none".
- Only with the Chat Completions adapter (openRouterText). The Responses adapter does not do this.
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.
Available Models#
OpenRouter provides access to 300+ models from various providers. Models use the format provider/model-name:
model: "openai/gpt-5.1"
model: "anthropic/claude-sonnet-4.5"
model: "google/gemini-3.1-pro-preview"
model: "meta-llama/llama-4-maverick"
model: "deepseek/deepseek-v3.2"See the full list at openrouter.ai/models.
adapter.inputModalities lists the input kinds of the selected model. It is undefined for a model that this package does not know. See Check what a model accepts.
Example: Chat Completion#
import { chat, toServerSentEventsResponse } from "@tanstack/ai";
import { openRouterText } from "@tanstack/ai-openrouter";
export async function POST(request: Request) {
const { messages } = await request.json();
const stream = chat({
adapter: openRouterText("openai/gpt-5"),
messages,
});
return toServerSentEventsResponse(stream);
}Example: With Tools#
import { chat, toServerSentEventsResponse, toolDefinition } from "@tanstack/ai";
import { openRouterText } from "@tanstack/ai-openrouter";
import { z } from "zod";
const getWeatherDef = toolDefinition({
name: "get_weather",
description: "Get the current weather",
inputSchema: z.object({
location: z.string(),
}),
});
const getWeather = getWeatherDef.server(async ({ location }) => {
return { temperature: 72, conditions: "sunny" };
});
export async function POST(request: Request) {
const { messages } = await request.json();
const stream = chat({
adapter: openRouterText("openai/gpt-5"),
messages,
tools: [getWeather],
});
return toServerSentEventsResponse(stream);
}JSON mode#
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." }],
modelOptions: { responseFormat: { type: "json_object" } },
outputSchema: z.object({ name: z.string(), age: z.number() }),
});Read When strict JSON schema is rejected.
Tools and structured output together#
You can pass both tools and outputSchema on one chat() call. For some upstream models OpenRouter can return the typed object in that same streaming request, so the engine does not make a second finalization call.
That happens only when every model that can receive the request is in OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS. The set is generated from OpenRouter's catalog on every model sync: every chat model whose supported_parameters include structured_outputs, tools and tool_choice (Claude 4.5+, Gemini 2.5+, GPT-4o+, Grok 4, DeepSeek V3+, Llama 3.1+, and so on). Models OpenRouter does not flag, such as anthropic/claude-opus-4.1, stay on the legacy two-call path.
If any fallback in modelOptions.models is outside that set, OpenRouter keeps the two-call path. Routing suffixes such as :nitro do not change the gate.
Import the set from @tanstack/ai-openrouter/model-meta if you need to check a model before you send:
import { OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS } from "@tanstack/ai-openrouter/model-meta";
OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS.has("openai/gpt-5.5");Chat Completions (openRouterText) and Responses (openRouterResponsesText) both attach the schema on this path. The client does not change: useChat({ outputSchema }) still reads partial and final.
Server (Chat Completions):
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 } = await request.json();
const stream = chat({
adapter: openRouterText("openai/gpt-5.5"),
messages,
tools: [getWeather],
outputSchema: AnswerSchema,
stream: true,
});
return toServerSentEventsResponse(stream);
}Server (Responses). Same tools and outputSchema as the Chat Completions example, with openRouterResponsesText:
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 } = await request.json();
const stream = chat({
adapter: openRouterResponsesText("openai/gpt-5.5"),
messages,
tools: [getWeather],
outputSchema: AnswerSchema,
stream: true,
});
return toServerSentEventsResponse(stream);
}Client:
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
import { z } from "zod";
const AnswerSchema = z.object({
summary: z.string(),
location: z.string(),
});
const { sendMessage, partial, final } = useChat({
connection: fetchServerSentEvents("/api/chat"),
outputSchema: AnswerSchema,
});See Structured Outputs with tools for the event order, and Middleware for how structuredOutput phase behaves 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 adapter call counts. structuredOutputStream must stay at 0.
Environment Variables#
Set your API key in environment variables:
OPENROUTER_API_KEY=sk-or-...Model Routing#
OpenRouter can automatically route requests to the best available provider:
import { chat, toServerSentEventsResponse } from "@tanstack/ai";
import { openRouterText } from "@tanstack/ai-openrouter";
export async function POST(request: Request) {
const { messages } = await request.json();
const stream = chat({
adapter: openRouterText("openrouter/auto"),
messages,
modelOptions: {
models: [
"openai/gpt-5.5",
"anthropic/claude-sonnet-4.5",
"google/gemini-3.1-pro-preview",
],
},
});
return toServerSentEventsResponse(stream);
}models is the fallback list: OpenRouter tries them in order when the primary model is unavailable. The rest of OpenRouter's routing surface is also exposed through modelOptions and forwarded to the request as-is. The option names are the camelCase ones from the OpenRouter SDK.
Provider preferences#
provider controls which upstream providers may serve the request and in what order. Pin one provider and disable 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!" }],
modelOptions: {
provider: {
order: ["groq", "together"], // provider slugs, in priority order
allowFallbacks: false, // fail instead of routing elsewhere
},
},
});Provider slugs are the lowercase ids shown on OpenRouter's model pages ("anthropic", "openai", "groq", …). Other fields on provider:
- only / ignore: allow-list or deny-list of provider slugs, merged with your account-wide provider settings for this request.
- requireParameters: only route to providers that support every parameter in the request. Without it, OpenRouter sends each provider only the parameters it supports and silently drops the rest, so a responseFormat or a sampling option can be ignored without an error. When you stream with requireParameters: true, also set modelOptions.streamOptions: { includeUsage: false }. Without it, the adapter sends stream_options, and OpenRouter returns a 404 for providers that do not support that parameter.
- sort: "price", "throughput", or "latency", or an object with by and partition, applied when order is not set. Setting it disables load balancing.
- maxPrice: the highest pricing you accept. It takes prompt and completion in USD per million tokens, plus request (per-request pricing) and image (per image) where a provider offers them. Providers above the limit are skipped.
- quantizations: restrict to providers serving the model at given quantization levels (e.g. "fp8", "int4").
- dataCollection: "deny" to use only providers that do not collect user data.
- zdr: true to restrict routing to zero-data-retention endpoints.
Model variants#
variant appends OpenRouter's :variant suffix to the model id. It is adapter metadata only and is never sent 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!" }],
modelOptions: {
variant: "free", // requests deepseek/deepseek-v4-pro:free
},
});Accepted values are "free", "nitro", "online", "exacto", "extended", and "thinking". Which of them a model actually offers is up to OpenRouter's catalog.
Plugins#
plugins enables OpenRouter's request-level plugins. Each entry is identified by id, with plugin-specific options alongside:
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!" }],
modelOptions: {
plugins: [{ id: "web", maxResults: 5 }],
},
});Plugin ids include web, file-parser, response-healing, moderation, and auto-router; see the OpenRouter documentation for each plugin's options. Web fetching is not a plugin but a server tool (openrouter:web_fetch); for web search and fetch as tools the model can call, see Provider Tools below.
Reasoning#
Set reasoning on chat(). OpenRouter gets it as reasoning.effort:
import { chat } from "@tanstack/ai";
import { openRouterText } from "@tanstack/ai-openrouter";
const stream = chat({
adapter: openRouterText("anthropic/claude-opus-5.5"),
messages: [{ role: "user", content: "Hello!" }],
reasoning: "high",
});- off sends effort: "none".
- openRouterText takes no budgetTokens. openRouterResponsesText sends budgetTokens as reasoning.maxTokens, and adds summary: "auto" so the thinking text streams.
- The types take the levels of each model. Your editor shows them when you type reasoning.
Session and metadata#
user (a unique user identifier), sessionId, and metadata (up to 16 key-value pairs) are forwarded unchanged. sessionId doubles as OpenRouter's sticky routing key: requests that share it are routed to the same provider to maximize prompt-cache hits, and grouped together for observability.
Prompt caching#
chat() sets sessionId to the cache key by default, which is the threadId that you pass. So one conversation stays on one provider and reads its cache. anthropic/* models also get cache markers on the system message, the last tool, and the last message. promptCache: 'long' asks for the 1-hour cache, and promptCache: 'none' sends nothing. See Prompt Caching.
Your own modelOptions.sessionId wins. A cache marker of your own turns the automatic markers off for that request.
Model Options#
OpenRouter supports various provider-specific options. Sampling parameters live here too — temperature, topP, and maxCompletionTokens (OpenRouter's token-limit key for the chat adapter) — rather than as root-level props on chat():
import { chat, toServerSentEventsResponse } from "@tanstack/ai";
import { openRouterText } from "@tanstack/ai-openrouter";
export async function POST(request: Request) {
const { messages } = await request.json();
const stream = chat({
adapter: openRouterText("anthropic/claude-sonnet-4.5"),
messages,
modelOptions: {
temperature: 0.7,
topP: 0.9,
maxCompletionTokens: 1024,
},
});
return toServerSentEventsResponse(stream);
}If you previously passed temperature / topP / maxTokens at the root of chat(), see Moving Sampling Options into modelOptions.
Chat Completions vs Responses (beta)#
OpenRouter exposes two OpenAI-compatible wire formats, and the adapter package ships one of each:
| Adapter | Endpoint | Status | When to use |
|---|---|---|---|
| openRouterText | /v1/chat/completions | Stable | Default for almost everything. Broadest model + tool support. |
| openRouterResponsesText | /v1/responses | Beta | OpenAI Responses-shaped request/response; richer multi-turn state on OpenAI-style models. |
Both adapters route to any underlying model OpenRouter supports (anthropic/..., google/..., meta-llama/..., etc.) — the wire format describes how your client talks to OpenRouter, not which provider answers. /v1/responses is OpenAI's newer API surface; OpenRouter implements it so clients that prefer that wire format can use it across the same 300+ model catalogue.
import { chat } from "@tanstack/ai";
import { openRouterResponsesText } from "@tanstack/ai-openrouter";
const stream = chat({
adapter: openRouterResponsesText("anthropic/claude-sonnet-4.5"),
messages: [{ role: "user", content: "Hello!" }],
});Caveats while the Responses adapter is in beta:
- Function tools are supported; OpenRouter's branded server-tools (web search, file search, …) are not yet wired through this path — use openRouterText if you need those.
- If in doubt, prefer openRouterText. The Chat Completions endpoint has broader provider coverage and feature parity today.
Cost Tracking#
OpenRouter reports the actual cost of each request inline on the streamed response. When present, the adapter forwards it on the terminal RUN_FINISHED event under usage.cost, with OpenRouter's per-request breakdown under usage.costDetails. This is the cost OpenRouter itself reports for the request — it is not computed locally from token counts, so it already accounts for routing, fallback providers, BYOK, and cached-token pricing. See OpenRouter's Usage Accounting docs for the meaning and units of these fields.
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-5"),
messages: [{ role: "user", content: "Hello!" }],
})) {
if (isRunFinished(chunk)) {
console.log("cost:", chunk.usage?.cost);
console.log("breakdown:", chunk.usage?.costDetails);
}
}The same usage (including cost / costDetails) is passed to middleware via the onUsage and onFinish hooks. When OpenRouter does not report a cost, the fields are simply absent and the stream completes normally. Both openRouterText and openRouterResponsesText populate cost when OpenRouter returns it.
The openRouterText adapter's structuredOutputStream() method includes received usage on its terminal RUN_ERROR event. This covers parse failures, truncated responses, empty responses, and SDK errors. When chat() uses this path, middleware onChunk receives the error event even if the promise rejects. The onUsage hook reports successful calls. Each adapter call reports usage on one terminal event.
A reported completionTokensDetails.reasoningTokens value of 0 stays zero. A missing count stays absent. Errors before any usage arrives have no usage field. If the consumer stops reading the stream, it cannot receive later events.
Reranking#
OpenRouter exposes rerank models through its unified /v1/rerank endpoint (served via the @openrouter/sdk SDK). Any rerank model OpenRouter offers works by passing its slug — for example cohere/rerank-v3.5, cohere/rerank-4-fast, cohere/rerank-4-pro, or nvidia/llama-nemotron-rerank-vl-1b-v2. Use openRouterRerank with the rerank() activity to reorder candidate documents by relevance to a query:
import { rerank } from "@tanstack/ai";
import { openRouterRerank } from "@tanstack/ai-openrouter";
const { rerankedDocuments } = await rerank({
adapter: openRouterRerank("cohere/rerank-v3.5"),
query: "talk about rain",
documents: ["sunny day at the beach", "rainy afternoon in the city"],
topN: 2,
});
console.log(rerankedDocuments[0]); // 'rainy afternoon in the city'openRouterRerank reads OPENROUTER_API_KEY from the environment; pass a key explicitly with createOpenRouterRerank("cohere/rerank-v3.5", "sk-or-..."). The optional httpReferer / appTitle config fields are forwarded as OpenRouter attribution headers, just like the chat adapter.
See the Reranking guide for object documents, RAG pipelines, options, and the result shape.
Evaluate#
OpenRouter exposes TypeSafe Jev through POST /api/alpha/decisions. Use openRouterDecider with decide() to ask typed questions about a shared state:
import { decide, choice, score, boolean } from "@tanstack/ai";
import { openRouterDecider } from "@tanstack/ai-openrouter";
const ticket = {
subject: "Charged twice for the same invoice",
body: "Please refund the extra payment.",
};
const result = await decide({
adapter: openRouterDecider("~typesafe/jev-latest"),
state: ticket,
questions: {
queue: choice({
instructions: "Which team should handle this ticket?",
options: {
billing: "Payments, invoices, refunds",
tech: "Bugs, outages, integrations",
sales: "Pricing, upgrades, new accounts",
},
}),
urgency: score({
instructions: "How urgent is this ticket?",
levels: ["low", "medium", "high"],
}),
refund: boolean({
instructions: "Is the customer asking for a refund?",
}),
},
});
console.log(result.queue.value);
console.log(result.queue.probability);
console.log(result.queue.confidence);
console.log(result.meta.usage);To bill per request, read result.meta.usage.cost. To look the request up later with GET /api/v1/generation?id=, read result.meta.id. result.meta.provider names the upstream provider that served the request.
openRouterDecider reads OPENROUTER_API_KEY from the environment. Pass a key explicitly with createOpenRouterDecider("~typesafe/jev-latest", "sk-or-...").
Known slugs:
- ~typesafe/jev-latest
- typesafe/jev-1.13
- typesafe/jev-1.13.0
See the Evaluate guide for question helpers, the result shape, abort, and middleware.
Image Generation#
For a React + Start walkthrough with useGenerateImage, open Generate Image.
openRouterImage routes image generation through OpenRouter's chat-completions surface (modalities: ['image']). Multimodal prompts are supported — text and image parts are forwarded in order for image-conditioned generation:
import { generateImage } from "@tanstack/ai";
import { openRouterImage } from "@tanstack/ai-openrouter";
const result = await generateImage({
adapter: openRouterImage("google/gemini-2.5-flash-image"),
prompt: "A watercolor lighthouse at dusk",
size: "1344x768", // mapped to image_config.aspect_ratio ('16:9')
modelOptions: {
image_size: "2K", // resolution (Gemini models)
strength: 0.35, // image-to-image influence, i2i-capable models only
},
});Notes:
- The pathway returns exactly one image per request — numberOfImages > 1 throws instead of silently under-delivering. Make multiple requests if you need multiple candidates.
- size must be one of the ten supported WIDTHxHEIGHT values (it is converted to image_config.aspect_ratio); anything else throws with the supported list.
Video Generation (Experimental)#
openRouterVideo targets OpenRouter's dedicated async video API (POST /api/v1/videos) — Seedance, Veo 3.1, Wan, Kling, and Sora 2 Pro through your one OpenRouter key. It follows the jobs/polling architecture shared by all TanStack AI video adapters:
// Server: create the job, then poll
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.0");
// OpenRouter returns the finished video as a stream, not a URL. Persistence
// stores the stream and sets `url`. Pass the same options to both calls.
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 from the published metadata; coerce raw
// seconds with adapter.snapDuration() or enumerate via 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 billed
// cost that the gateway reports.// Client: track the job with the useGenerateVideo hook
import { useGenerateVideo, fetchServerSentEvents } from "@tanstack/ai-react";
const { generate, result, videoStatus, isLoading } = useGenerateVideo({
connection: fetchServerSentEvents("/api/generate/video"),
});
// result?.url renders directly: <video src={result.url} controls />Sizes, durations, and per-model options (resolution, aspectRatio, generateAudio, seed, …) are typed and validated per model from OpenRouter's video model metadata. See Video Generation for the full lifecycle, streaming mode, and the image-to-video role-mapping table.
Next Steps#
- Getting Started - Learn the basics
- Tools Guide - Learn about tools
- Reranking - Reorder documents by relevance
- Evaluate - Ask typed questions about shared state
Provider Tools#
Migrated from createWebSearchTool? This factory was renamed to webSearchTool and moved to the /tools subpath in this release. See Migration Guide §6 for the exact before/after.
OpenRouter's gateway exposes web search via a plugin that works across any proxied chat model. Import it from @tanstack/ai-openrouter/tools.
For the full concept, a comparison matrix, and type-gating details, see Provider Tools.
webSearchTool#
Adds web search capability to any OpenRouter-proxied chat model. The factory accepts OpenRouter's WebSearchConfig directly — pick the engine (auto, native, exa, firecrawl, or parallel), cap results with maxResults / maxTotalResults, restrict which sites can appear in results with allowedDomains / excludedDomains, and optionally pass searchContextSize or userLocation for finer control.
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-5"),
messages: [{ role: "user", content: "What's new in AI this week?" }],
tools: [
webSearchTool({
engine: "exa",
maxResults: 5,
allowedDomains: ["arxiv.org", "openai.com"],
}),
],
});Supported models: all OpenRouter chat models. See Provider Tools.
webFetchTool#
Lets any OpenRouter-proxied chat model fetch the full contents of a URL the model chooses, instead of running a search. The factory accepts OpenRouter's WebFetchServerToolConfig directly — pick the fetch engine (auto — the default, native, openrouter, exa, or firecrawl), cap how much page content the model receives with maxContentTokens, cap how many fetches the model can make per request with maxUses, and restrict which URLs the model can fetch with allowedDomains / blockedDomains.
The native engine routes to the underlying provider's own fetch (for example, Anthropic's web_fetch on Claude models). Native fetch capabilities vary, so allowedDomains and blockedDomains may be ignored. Use openrouter, exa, or firecrawl if you need consistent behaviour across models.
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-5"),
messages: [
{ role: "user", content: "Summarize https://example.com/article" },
],
tools: [
webFetchTool({
engine: "openrouter",
maxContentTokens: 4000,
allowedDomains: ["example.com"],
}),
],
});Supported models: all OpenRouter chat models. See Provider Tools.
Sign in with OpenRouter (BYOK)#
OpenRouter can mint a user-owned API key via OAuth PKCE. Import the helpers from @tanstack/ai-openrouter/pkce. The key is stored under openrouterByok.id (openrouter) and sent as x-byok-openrouter.
Client: start login, then on return 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>
);
}Server: read the same slug 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-5.5", apiKey),
messages: params.messages,
threadId: params.threadId,
runId: params.runId,
});
return toServerSentEventsResponse(stream);
}See Bring Your Own Key for the keyring and passkey storage.
Sign in from a CLI#
A CLI or a desktop app has no web page for the redirect, so the user would have to 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!" }],
});- signIn starts a one-time listener on 127.0.0.1, on a random port.
- It calls open with the OpenRouter sign-in URL. Open that URL in the browser.
- The user signs in and approves. The browser shows You are signed in to OpenRouter. You can close this tab.
- signIn resolves with the API key, and the listener closes.
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.