본문으로 건너뛰기

타입 안전성을 갖춘 런타임 어댑터 전환

완전한 TypeScript 타입 안전성을 유지하면서 런타임에 LLM 공급자를 전환할 수 있는 인터페이스를 구축하는 방법을 알아봅니다.

간단한 접근 방식

TanStack AI에서는 모델을 어댑터 팩토리 함수에 직접 전달합니다. 따라서 정의하는 시점에 완전한 타입 안전성과 자동 완성을 사용할 수 있습니다.

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

이렇게 작동하는 이유

각 어댑터 팩토리 함수는 첫 번째 인수로 모델 이름을 받아 완전히 타입이 지정된 어댑터를 반환합니다.

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'

어댑터를 chat()에 전달하면 adapter.model의 모델을 사용합니다. 따라서 다음과 같은 이점이 있습니다.

  • 완전한 자동 완성 - 모델 이름을 입력할 때 TypeScript가 유효한 옵션을 알고 있습니다.
  • 타입 검증 - 유효하지 않은 모델 이름은 컴파일 오류를 발생시킵니다.
  • 깔끔한 코드 - 별도의 model 매개변수가 필요하지 않습니다.

전체 예제

다중 공급자 채팅 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 })
},
},
},
})

이미지 어댑터 사용

동일한 패턴이 이미지 생성에도 적용됩니다. 위의 텍스트 및 요약 어댑터와 달리 이미지 어댑터는 모두 동일한 형태의 size를 받지 않으므로, 모든 분기에 한 번씩 전달하는 대신 공급자 맵에서 어댑터와 함께 전달합니다.

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는 공급자별이므로 분기 전체에서 공유하는 단일 리터럴이 될 수 없습니다. Gemini 3.x 네이티브 이미지 모델은 '<aspectRatio>_<tier>' 문자열(예: '16:9_4K')을 사용합니다. gemini-2.5-flash-image는 접미사가 없는 비율(예: '16:9')을 사용합니다. OpenAI 및 Imagen 모델은 픽셀 크기(예: '1024x1024')를 사용합니다.

요약 어댑터 사용

요약에도 동일하게 적용됩니다.

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

switch 문에서 마이그레이션

기존에 switch 문을 사용하는 코드가 있다면 다음과 같이 마이그레이션합니다.

이전

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

주요 변경 사항은 다음과 같습니다.

  1. switch 문을 팩토리 함수 객체로 대체합니다.
  2. 각 팩토리 함수가 모델을 포함한 어댑터를 생성합니다.
  3. 더 이상 as any 캐스트가 필요하지 않으므로 완전한 타입 안전성을 확보합니다.