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-5'), // ✅ Autocomplete!
openai: () => openaiText('gpt-4o'), // ✅ Autocomplete!
}
// In your request handler:
const provider: Provider = request.body.provider || 'openai'
const stream = chat({
adapter: adapters[provider](),
messages,
})
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-5'), // ✅ Autocomplete!
openai: () => openaiText('gpt-4o'), // ✅ Autocomplete!
}
// In your request handler:
const provider: Provider = request.body.provider || 'openai'
const stream = chat({
adapter: adapters[provider](),
messages,
})
Each adapter factory function accepts a model name as its first argument and returns a fully typed adapter:
// These are equivalent:
const adapter1 = openaiText('gpt-4o')
const adapter2 = new OpenAITextAdapter({ apiKey: process.env.OPENAI_API_KEY }, 'gpt-4o')
// The model is stored on the adapter
console.log(adapter1.selectedModel) // 'gpt-4o'
// These are equivalent:
const adapter1 = openaiText('gpt-4o')
const adapter2 = new OpenAITextAdapter({ apiKey: process.env.OPENAI_API_KEY }, 'gpt-4o')
// The model is stored on the adapter
console.log(adapter1.selectedModel) // 'gpt-4o'
When you pass an adapter to chat(), it uses the model from adapter.selectedModel. 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-5'),
gemini: () => geminiText('gemini-2.0-flash-exp'),
ollama: () => ollamaText('mistral:7b'),
openai: () => openaiText('gpt-4o'),
}
export const Route = createFileRoute('/api/chat')({
server: {
handlers: {
POST: async ({ request }) => {
const abortController = new AbortController()
const body = await request.json()
const { messages, data } = body
const provider: Provider = data?.provider || 'openai'
const stream = chat({
adapter: adapters[provider](),
tools: [...],
systemPrompts: [...],
messages,
abortController,
})
return toServerSentEventsResponse(stream, { abortController })
},
},
},
})
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-5'),
gemini: () => geminiText('gemini-2.0-flash-exp'),
ollama: () => ollamaText('mistral:7b'),
openai: () => openaiText('gpt-4o'),
}
export const Route = createFileRoute('/api/chat')({
server: {
handlers: {
POST: async ({ request }) => {
const abortController = new AbortController()
const body = await request.json()
const { messages, data } = body
const provider: Provider = data?.provider || 'openai'
const stream = chat({
adapter: adapters[provider](),
tools: [...],
systemPrompts: [...],
messages,
abortController,
})
return toServerSentEventsResponse(stream, { abortController })
},
},
},
})
The same pattern works for image generation:
import { generateImage } from '@tanstack/ai'
import { openaiImage } from '@tanstack/ai-openai'
import { geminiImage } from '@tanstack/ai-gemini'
const imageAdapters = {
openai: () => openaiImage('gpt-image-1'),
gemini: () => geminiImage('gemini-2.0-flash-preview-image-generation'),
}
// Usage
const result = await generateImage({
adapter: imageAdapters[provider](),
prompt: 'A beautiful sunset over mountains',
size: '1024x1024',
})
import { generateImage } from '@tanstack/ai'
import { openaiImage } from '@tanstack/ai-openai'
import { geminiImage } from '@tanstack/ai-gemini'
const imageAdapters = {
openai: () => openaiImage('gpt-image-1'),
gemini: () => geminiImage('gemini-2.0-flash-preview-image-generation'),
}
// Usage
const result = await generateImage({
adapter: imageAdapters[provider](),
prompt: 'A beautiful sunset over mountains',
size: '1024x1024',
})
And for summarization:
import { summarize } from '@tanstack/ai'
import { openaiSummarize } from '@tanstack/ai-openai'
import { anthropicSummarize } from '@tanstack/ai-anthropic'
const summarizeAdapters = {
openai: () => openaiSummarize('gpt-4o-mini'),
anthropic: () => anthropicSummarize('claude-sonnet-4-5'),
}
// Usage
const result = await summarize({
adapter: summarizeAdapters[provider](),
text: longDocument,
maxLength: 100,
style: 'concise',
})
import { summarize } from '@tanstack/ai'
import { openaiSummarize } from '@tanstack/ai-openai'
import { anthropicSummarize } from '@tanstack/ai-anthropic'
const summarizeAdapters = {
openai: () => openaiSummarize('gpt-4o-mini'),
anthropic: () => anthropicSummarize('claude-sonnet-4-5'),
}
// Usage
const result = await summarize({
adapter: summarizeAdapters[provider](),
text: longDocument,
maxLength: 100,
style: 'concise',
})
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-5'
break
case 'openai':
default:
adapter = openaiText()
model = 'gpt-4o'
break
}
const stream = chat({
adapter: adapter as any,
model: model as any,
messages,
})
let adapter
let model
switch (provider) {
case 'anthropic':
adapter = anthropicText()
model = 'claude-sonnet-4-5'
break
case 'openai':
default:
adapter = openaiText()
model = 'gpt-4o'
break
}
const stream = chat({
adapter: adapter as any,
model: model as any,
messages,
})
const adapters = {
anthropic: () => anthropicText('claude-sonnet-4-5'),
openai: () => openaiText('gpt-4o'),
}
const stream = chat({
adapter: adapters[provider](),
messages,
})
const adapters = {
anthropic: () => anthropicText('claude-sonnet-4-5'),
openai: () => openaiText('gpt-4o'),
}
const stream = chat({
adapter: adapters[provider](),
messages,
})
The key changes:
