Learn how to build interfaces where users can switch between LLM providers at runtime while maintaining full TypeScript type safety.
With TanStack AI, the model is passed directly to the adapter factory function. This gives you full type safety and autocomplete at the point of definition:
import { chat, toServerSentEventsResponse } from '@tanstack/ai'
import { anthropicText } from '@tanstack/ai-anthropic'
import { openaiText } from '@tanstack/ai-openai'
type Provider = 'openai' | 'anthropic'
// Define adapters with their models - autocomplete works here!
const adapters = {
anthropic: () => anthropicText('claude-sonnet-4-6'), // ✅ Autocomplete!
openai: () => openaiText('gpt-5.5'), // ✅ Autocomplete!
}
async function handleRequest(request: Request) {
// In your request handler:
const body = await request.json()
const provider: Provider = body.forwardedProps?.provider || 'openai'
const stream = chat({
adapter: adapters[provider](),
messages: body.messages,
})
}You start a thread on Claude, then switch to GPT. Claude's thinking signatures and tool call IDs mean nothing to OpenAI. If you send them as they are, the request fails.
You do not have to fix this yourself. Keep the message metadata when you save and send the history. chat() does the rest.
Each assistant message records where it came from, in metadata.tanstack.source:
{ "provider": "anthropic", "api": "anthropic-messages", "model": "claude-sonnet-5-5" }When the next call goes to a different source, the adapter changes the request:
Each request also cleans the history:
All of this changes only the request. Your saved history stays the same. A message with no source counts as same-source, so its signatures go back as they are.
On the server, read the request with chatParamsFromRequest. It keeps the message metadata:
import { chat, chatParamsFromRequest, toServerSentEventsResponse } from '@tanstack/ai'
import { anthropicText } from '@tanstack/ai-anthropic'
import { openaiText } from '@tanstack/ai-openai'
export async function POST(request: Request) {
const params = await chatParamsFromRequest(request)
const adapter =
params.forwardedProps.provider === 'anthropic'
? anthropicText('claude-sonnet-5-5')
: openaiText('gpt-6.1-sol')
return toServerSentEventsResponse(
chat({ adapter, messages: params.messages, threadId: params.threadId }),
)
}On the client, send the provider that the user picked:
import { useState } from 'react'
import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
export function ProviderChat() {
const [provider, setProvider] = useState('anthropic')
const { sendMessage } = useChat({
connection: fetchServerSentEvents('/api/chat'),
forwardedProps: { provider },
})
return (
<>
<select
aria-label="Provider"
value={provider}
onChange={(event) => setProvider(event.target.value)}
>
<option value="anthropic">Claude</option>
<option value="openai">GPT</option>
</select>
<button onClick={() => sendMessage('Keep going')}>Send</button>
</>
)
}Switch the provider in the middle of a thread. The next answer comes from the new model, with the full history.
Each adapter factory function accepts a model name as its first argument and returns a fully typed adapter:
import { openaiText, OpenAITextAdapter } from '@tanstack/ai-openai'
// These are equivalent:
const adapter1 = openaiText('gpt-5.5')
const adapter2 = new OpenAITextAdapter({ apiKey: process.env.OPENAI_API_KEY! }, 'gpt-5.5')
// The model is stored on the adapter
console.log(adapter1.model) // 'gpt-5.5'When you pass an adapter to chat(), it uses the model from adapter.model. This means:
Here's a complete example showing a multi-provider chat API:
import { createFileRoute } from '@tanstack/react-router'
import { chat, maxIterations, toServerSentEventsResponse } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { anthropicText } from '@tanstack/ai-anthropic'
import { geminiText } from '@tanstack/ai-gemini'
import { ollamaText } from '@tanstack/ai-ollama'
type Provider = 'openai' | 'anthropic' | 'gemini' | 'ollama'
// Define adapters with their models
const adapters = {
anthropic: () => anthropicText('claude-sonnet-4-6'),
gemini: () => geminiText('gemini-3-flash-preview'),
ollama: () => ollamaText('mistral:7b'),
openai: () => openaiText('gpt-5.5'),
}
export const Route = createFileRoute('/api/chat')({
server: {
handlers: {
POST: async ({ request }) => {
const abortController = new AbortController()
const body = await request.json()
// `forwardedProps` is the AG-UI field set by `useChat({ forwardedProps })`.
// The legacy `body.data.provider` access still works (mirrored on the
// wire for backward compatibility) but `forwardedProps` is preferred.
const provider: Provider = body.forwardedProps?.provider || 'openai'
const stream = chat({
adapter: adapters[provider](),
tools: [...],
systemPrompts: [...],
messages: body.messages,
abortController,
})
return toServerSentEventsResponse(stream, { abortController })
},
},
},
})The same pattern works for image generation. Unlike the text and summarize adapters above, image adapters don't all accept the same shape of size — so it travels alongside its adapter in the provider map instead of being passed once for every branch:
import { generateImage } from '@tanstack/ai'
import { openaiImage } from '@tanstack/ai-openai'
import { geminiImage } from '@tanstack/ai-gemini'
type ImageProvider = 'openai' | 'gemini'
const imageAdapters = {
openai: () => ({ adapter: openaiImage('gpt-image-2'), size: '1024x1024' as const }),
gemini: () => ({ adapter: geminiImage('gemini-3.1-flash-image'), size: '16:9_4K' as const }),
}
export async function POST(request: Request) {
const body = await request.json()
const provider: ImageProvider = body.provider ?? 'openai'
const { adapter, size } = imageAdapters[provider]()
const result = await generateImage({
adapter,
prompt: 'A beautiful sunset over mountains',
size,
})
return Response.json(result)
}size is provider-specific, which is why it cannot be a single literal shared across branches. Gemini 3.x native image models take a '<aspectRatio>_<tier>' string (for example '16:9_4K'). gemini-2.5-flash-image takes a bare ratio with no suffix (for example '16:9'). OpenAI and Imagen models take pixel dimensions (for example '1024x1024').
And for summarization:
import { summarize } from '@tanstack/ai'
import { openaiSummarize } from '@tanstack/ai-openai'
import { anthropicSummarize } from '@tanstack/ai-anthropic'
type SummarizeProvider = 'openai' | 'anthropic'
const summarizeAdapters: Record<SummarizeProvider, () => ReturnType<typeof openaiSummarize | typeof anthropicSummarize>> = {
openai: () => openaiSummarize('gpt-5.4-mini'),
anthropic: () => anthropicSummarize('claude-sonnet-4-6'),
}
export async function POST(request: Request) {
const body = await request.json()
const provider: SummarizeProvider = body.provider ?? 'openai'
const longDocument: string = body.text
const result = await summarize({
adapter: summarizeAdapters[provider](),
text: longDocument,
maxLength: 100,
style: 'concise',
})
return Response.json(result)
}If you have existing code using switch statements, here's how to migrate:
let adapter
let model
switch (provider) {
case 'anthropic':
adapter = anthropicText()
model = 'claude-sonnet-4-6'
break
case 'openai':
default:
adapter = openaiText()
model = 'gpt-5.5'
break
}
const stream = chat({
adapter: adapter as any,
model: model as any,
messages,
})import { chat, toServerSentEventsResponse } from '@tanstack/ai'
import { anthropicText } from '@tanstack/ai-anthropic'
import { openaiText } from '@tanstack/ai-openai'
type AfterProvider = 'openai' | 'anthropic'
const adapters = {
anthropic: () => anthropicText('claude-sonnet-4-6'),
openai: () => openaiText('gpt-5.5'),
}
export async function POST(request: Request) {
const body = await request.json()
const provider: AfterProvider = body.forwardedProps?.provider ?? 'openai'
const stream = chat({
adapter: adapters[provider](),
messages: body.messages,
})
return toServerSentEventsResponse(stream)
}The key changes: