This page is for people who write a text adapter for a new provider. To add model names to an adapter that exists, see Extend Adapter. For the full adapter walkthrough, see the community adapter guide.
chat() and your app read adapter.inputModalities to know which content types a model can read. Set it 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']For a model that is not in the list, the value is undefined. Apps read undefined as "not known".
Your adapter talks to a thinking model. You want chat({ reasoning: 'high' }) to work, and you want a type error for a level that the model does not have.
On an adapter that extends @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 data of the model: 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', threadId: 'thread-1' })
chat({
adapter,
messages: [{ role: 'user', content: 'Hi' }],
threadId: 'thread-1',
// @ts-expect-error my-model has no max level
reasoning: 'max',
})Does your provider use another wire format? Then do these steps:
chat() asks every adapter for prompt caching by default. It passes the setting as options.promptCache:
When you build the request, map it to the cache fields of your provider:
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:
Do you extend the Responses adapter of @tanstack/openai-base? Then 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 that 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,
}
})
}