Documentation
Framework
Version
Class References
Function References
Interface References
Type Alias References
Variable References

Runtime Adapter Switching

Runtime Adapter Switching with Type Safety

Learn how to build interfaces where users can switch between LLM providers at runtime while maintaining full TypeScript type safety.

The Simple Approach

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:

typescript
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,
})

Why This Works

Each adapter factory function accepts a model name as its first argument and returns a fully typed adapter:

typescript
// 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:

  • Full autocomplete - When typing the model name, TypeScript knows valid options
  • Type validation - Invalid model names cause compile errors
  • Clean code - No separate model parameter needed

Full Example

Here's a complete example showing a multi-provider chat API:

typescript
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 })
      },
    },
  },
})

Using with Image Adapters

The same pattern works for image generation:

typescript
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',
})

Using with Summarize Adapters

And for summarization:

typescript
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',
})

Migration from Switch Statements

If you have existing code using switch statements, here's how to migrate:

Before

typescript
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,
})

After

typescript
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:

  1. Replace the switch statement with an object of factory functions
  2. Each factory function creates an adapter with the model included
  3. No more as any casts - full type safety!