The extendAdapter utility allows you to extend existing adapter factories (like openaiText, anthropicText) with custom model names while maintaining full type safety for input modalities and provider options.
import { createModel, extendAdapter } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
// Define your custom models using createModel helper
const myOpenaiModel = createModel('my-fine-tuned-gpt4',['text', 'image']);
const myOpenaiModelButCooler = createModel('my-fine-tuned-gpt5',['text', 'image']);
// Create an extended adapter factory - simple API, no type parameters needed
const myOpenai = extendAdapter(openaiText, [
myOpenaiModel,
myOpenaiModelButCooler
])
// Use with original models - full type inference preserved
const gpt5Adapter = myOpenai('gpt-5.5')
// Use with custom models - your custom types are applied
const customAdapter = myOpenai('my-fine-tuned-gpt4')
// Works seamlessly with chat()
import { chat } from '@tanstack/ai'
const stream = chat({
adapter: myOpenai('my-fine-tuned-gpt4'),
messages: [{ role: 'user', content: 'Hello!' }]
})The createModel function provides a clean way to define custom models with full type inference:
import { createModel } from '@tanstack/ai'
// Arguments define name and input modalities
const model = createModel(
'my-model', // model name (literal type inferred)
['text', 'image'] // input modalities (tuple type inferred)
)A custom model definition (ExtendedModelDef) has the required properties name, input, and modelOptions, plus optional features and tools. The two createModel overloads let you fill these in two ways.
The positional form takes a model name and an input array specifying which content types your model supports:
import { createModel } from '@tanstack/ai'
const models = [
createModel('text-only-model', ['text']),
createModel('multimodal-model', ['text', 'image', 'audio']),
] as constAvailable modalities: 'text', 'image', 'audio', 'video', 'document'
To attach typed modelOptions, declared features, or provider tools to a custom model, use the second createModel overload, which takes a capabilities object as its second argument:
import { createModel } from '@tanstack/ai'
import type { OpenAITextProviderOptions } from '@tanstack/ai-openai'
// Type brand for provider options — the value is unused at runtime.
const modelOptions: OpenAITextProviderOptions = {}
const reasoner = createModel('my-reasoner', {
input: ['text'],
features: ['reasoning', 'structured_outputs'],
tools: ['web_search'],
modelOptions,
})extendAdapter fully preserves the original factory's signature, including any configuration parameters:
import { createModel, extendAdapter } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
const customModels = [createModel('my-fine-tuned-gpt4', ['text', 'image'])] as const
const myOpenai = extendAdapter(openaiText, customModels)
// Config parameter is preserved
const adapter = myOpenai('my-fine-tuned-gpt4', {
baseURL: 'https://my-proxy.com/v1',
timeout: 30000
})The extended adapter provides full type safety:
import { extendAdapter, createModel } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
const myOpenai = extendAdapter(openaiText, [createModel('custom-model', ['text'])])
// ✅ Original models work with their original types
const a1 = myOpenai('gpt-5.5')
// ✅ Custom models work with your defined types
const a2 = myOpenai('custom-model')
// ❌ Type error: invalid model name
// Note: Type checking works when you assign the result to a variable
const invalid = myOpenai('nonexistent-model') // TypeScript error!At runtime, extendAdapter simply passes through to the original factory:
This design is intentional - it allows you to:
A user attaches an image, but the selected model only reads text. You want to know that before you send the request. Read inputModalities on the text adapter:
import { openaiText } from '@tanstack/ai-openai'
const adapter = openaiText('gpt-6.1-sol')
console.log(adapter.inputModalities) // ['image', 'text']
if (!adapter.inputModalities?.includes('image')) {
console.log('This model cannot read images. Pick another model.')
}If you write your own adapter, set inputModalities from your model list:
import { BaseTextAdapter } from '@tanstack/ai/adapters'
import type { DefaultMessageMetadataByModality, Modality } from '@tanstack/ai'
const INPUT_BY_MODEL: Record<string, ReadonlyArray<Modality>> = {
'my-vision-model': ['text', 'image'],
'my-text-model': ['text'],
}
class MyTextAdapter extends BaseTextAdapter<
string,
Record<string, never>,
ReadonlyArray<Modality>,
DefaultMessageMetadataByModality
> {
readonly name = 'my-provider'
override readonly inputModalities = INPUT_BY_MODEL[this.model]
constructor(model: string) {
super({}, model)
}
async *chatStream() {
// Call your provider and yield its chunks here.
}
structuredOutput() {
return Promise.resolve({ data: {}, rawText: '{}' })
}
}
console.log(new MyTextAdapter('my-vision-model').inputModalities) // ['text', 'image']Your adapter talks to a thinking model, and you want chat({ reasoning: 'high' }) to work, with a type error for a level the model does not have. Three steps:
On an adapter built on @tanstack/openai-base, that is all. The base sends reasoning_effort on Chat Completions, or reasoning: { effort, summary } on Responses:
import OpenAI from 'openai'
import { chat } from '@tanstack/ai'
import { OpenAIBaseChatCompletionsTextAdapter } from '@tanstack/openai-base'
import type {
DefaultMessageMetadataByModality,
Modality,
ModelReasoning,
} from '@tanstack/ai'
import type { ModelReasoningCapability } from '@tanstack/ai/adapter-internals'
// 1. The model's data: only the levels it has, with the value for each.
const MY_MODEL = {
name: 'my-model',
reasoning: {
levels: { off: 'none', low: 'low', medium: 'medium', high: 'high' },
budget: false,
},
} as const
// 2. The levels for the types, and the same data at run time.
const MY_REASONING = {
[MY_MODEL.name]: MY_MODEL.reasoning,
}
type MyModelReasoningByName = {
[K in keyof typeof MY_REASONING]: ModelReasoningCapability<
(typeof MY_REASONING)[K]
>
}
const MY_MODEL_REASONING: Readonly<Record<string, ModelReasoning>> =
MY_REASONING
type MyModel = keyof MyModelReasoningByName
// 3. The levels go in the last type parameter. The hook gives the data.
class MyTextAdapter<TModel extends MyModel> extends OpenAIBaseChatCompletionsTextAdapter<
TModel,
Record<string, unknown>,
ReadonlyArray<Modality>,
DefaultMessageMetadataByModality,
ReadonlyArray<string>,
MyModelReasoningByName[TModel]
> {
protected override modelReasoning(model: string) {
return MY_MODEL_REASONING[model]
}
}
const adapter = new MyTextAdapter(
'my-model',
'my-provider',
new OpenAI({ apiKey: 'my-key', baseURL: 'https://api.example.com/v1' }),
)
chat({ adapter, messages: [{ role: 'user', content: 'Hi' }], reasoning: 'high' })
chat({
adapter,
messages: [{ role: 'user', content: 'Hi' }],
// @ts-expect-error my-model has no max level
reasoning: 'max',
})A model with budget: true also takes budgetTokens.
Another wire format? Extend BaseTextAdapter, pass the levels as its last type parameter, and read options.reasoning in your request code. resolveReasoning from @tanstack/ai/adapter-internals moves the level to the nearest one the model has and gives its value. reasoningBudget gives the token budget for a level.
A model added with extendAdapter has no reasoning data, so it takes no reasoning.
chat() asks every adapter for prompt caching by default. It passes the setting as options.promptCache: a retention ('none', 'short', or 'long') and an optional key. Map it to your provider's cache fields when you build the request:
import type { TextOptions } from '@tanstack/ai'
export function cacheFields(options: TextOptions) {
const cache = options.promptCache
if (!cache || cache.retention === 'none') return {}
return {
...(cache.key ? { cache_key: cache.key } : {}),
...(cache.retention === 'long' ? { cache_ttl: '1h' } : {}),
}
}Tools or system prompts can grow between model calls. If your provider takes them inside the conversation, the cached start of the request can stay the same. To support it:
Extending the Responses adapter of @tanstack/openai-base? Set the field and you are done. The base sends additional_tools and developer messages:
import { OpenAIBaseResponsesTextAdapter } from '@tanstack/openai-base'
export class MyResponsesAdapter extends OpenAIBaseResponsesTextAdapter<'my-model'> {
override readonly midConversationChannels = { tools: true, systemPrompts: true }
}With another wire format, do the steps yourself:
import { splitMidConversationChanges } from '@tanstack/ai'
import type { ModelMessage, TextOptions } from '@tanstack/ai'
type WireTool = { name: string; description: string }
type WireItem =
| { kind: 'message'; message: ModelMessage }
| { kind: 'change'; tools: Array<WireTool>; prompts: Array<string> }
export function buildRequest(
options: TextOptions,
tools: Array<WireTool>,
prompts: Array<string>,
) {
const changes = options.midConversationChanges
const split = changes
? splitMidConversationChanges({ changes, tools, systemPrompts: prompts })
: undefined
if (!split) {
// No changes, or names that do not match: send the full lists.
const items = options.messages.map((message): WireItem => ({ kind: 'message', message }))
return { tools, system: prompts, items }
}
const items: Array<WireItem> = []
const pushChange = (index: number) => {
const change = split.at.get(index)
if (change) items.push({ kind: 'change', tools: change.tools, prompts: change.systemPrompts })
}
options.messages.forEach((message, index) => {
pushChange(index)
items.push({ kind: 'message', message })
})
pushChange(options.messages.length)
return { tools: split.startTools, system: split.startSystemPrompts, items }
}Your provider wants thinking, text, and tool calls in the order the model sent them. A ModelMessage keeps them in three separate fields: thinking, content, and toolCalls. Use orderedAssistantBlocks to get them back in order:
import { orderedAssistantBlocks } from '@tanstack/ai'
import type { ModelMessage } from '@tanstack/ai'
type WireBlock =
| { type: 'reasoning'; text: string; signature?: string }
| { type: 'text'; text: string }
| { type: 'call'; id: string; name: string; args: string }
export function orderedWireBlocks(message: ModelMessage): Array<WireBlock> | undefined {
const blocks = orderedAssistantBlocks(message)
if (!blocks) return undefined
return blocks.map((block): WireBlock => {
if (block.type === 'thinking') {
return { type: 'reasoning', text: block.thinking.content, signature: block.thinking.signature }
}
if (block.type === 'text') return { type: 'text', text: block.text }
return {
type: 'call',
id: block.toolCall.id,
name: block.toolCall.function.name,
args: block.toolCall.function.arguments,
}
})
}A common use case is typing models for an OpenAI-compatible proxy:
import { extendAdapter, createModel } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
// Models available through your proxy
const proxyModels = [
createModel(
'llama-3.1-70b',
['text']
),
createModel(
'mixtral-8x7b',
['text']
),
] as const
const proxyAdapter = extendAdapter(openaiText, proxyModels)
// Use with your proxy's base URL
const adapter = proxyAdapter('llama-3.1-70b', {
baseURL: 'https://my-llm-proxy.com/v1'
})Adding type safety for your fine-tuned models:
import { chat, createModel, extendAdapter } from '@tanstack/ai'
import { anthropicText } from '@tanstack/ai-anthropic'
const fineTunedModels = [
createModel(
'ft:claude-3-opus:my-org:custom-task:abc123',
['text', 'image']
),
] as const
const myAnthropic = extendAdapter(anthropicText, fineTunedModels)
chat({
adapter: myAnthropic('ft:claude-3-opus:my-org:custom-task:abc123'),
messages: [{ role: 'user', content: 'Analyze this...' }]
})