# Extend Adapter

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.

## Basic Usage

```typescript
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` Helper

The `createModel` function provides a clean way to define custom models with full type inference:

```typescript
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)
)
```
 
 
## Model Definition Structure

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.

### Defining Input Modalities

The positional form takes a model name and an `input` array specifying which content types your model supports:

```typescript
import { createModel } from '@tanstack/ai'

const models = [
  createModel('text-only-model', ['text']),
  createModel('multimodal-model', ['text', 'image', 'audio']),
] as const
```

Available modalities: `'text'`, `'image'`, `'audio'`, `'video'`, `'document'`

### Capabilities-object form

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:

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

- `input` — supported input modalities (same as the positional form).
- `features` — declared feature flags (e.g. `'reasoning'`, `'structured_outputs'`).
- `tools` — declared provider tools (e.g. `'web_search'`).
- `modelOptions` — a type brand for the provider options accepted by this model; the value is unused at runtime, so declare an empty object typed as the provider options (e.g. `const modelOptions: OpenAITextProviderOptions = {}`).
 
## Preserving Original Factory Behavior

`extendAdapter` fully preserves the original factory's signature, including any configuration parameters:

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

## Type Safety

The extended adapter provides full type safety:

```typescript ignore
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!
```
 

## Runtime Behavior

At runtime, `extendAdapter` simply passes through to the original factory:

- No validation is performed on custom model names
- The original factory receives exactly what you pass
- This allows the original provider's API to handle the model name

This design is intentional - it allows you to:
- Use fine-tuned model names that the provider accepts but TypeScript doesn't know about
- Proxy requests to different backends that accept custom model identifiers
- Add type safety without runtime overhead

## Check what a model accepts

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:

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

- The list comes from the model metadata of the provider package.
- It is `undefined` when the package does not know the model, for example a custom model from `extendAdapter`. Treat `undefined` as "not known", not as "text only".

If you write your own adapter, set `inputModalities` from your model list:

```typescript
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']
```

## Add reasoning to your adapter

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:

1. Put a `reasoning` field on each model's metadata.
2. Build a type map of the levels with `ModelReasoningCapability`, and a runtime map with the same keys.
3. Pass the levels to the adapter type, and return the runtime data from the `modelReasoning` hook.

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:

```typescript
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`.

## Send the prompt cache

`chat()` asks every adapter for [prompt caching](./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:

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

- If `promptCache` is `undefined` (a call outside `chat()`), send no cache fields.
- Put the cache fields first and the user's `modelOptions` after them, so a value the user sets wins.

## Send mid-conversation changes

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:

1. Set `midConversationChannels` on the adapter: `{ tools, systemPrompts }`. Then `chat()` passes the changes as `options.midConversationChanges`.
2. Read them with `splitMidConversationChanges`. You get the start lists and the changes by message index. You get `undefined` when the names or counts do not match the current lists.
3. Send the start lists at the top of the request. Send each change right before the message at its index. A change at `options.messages.length` goes at the end.

Extending the Responses adapter of `@tanstack/openai-base`? Set the field and you are done. The base sends `additional_tools` and `developer` messages:

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

```typescript
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 }
}
```

- `split.addedTools` lists every added tool, in order. Use it if your provider also wants them in its tool list.
- If your provider cannot add a kind of tool later (a hosted search tool, for example), send the full tool list for that request. Keep the prompt changes in the conversation.

## Send assistant blocks in order

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:

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

- The order comes from `message.blockOrder`. The library writes it only when the order is not the default: all thinking, then the text, then the tool calls.
- `orderedAssistantBlocks` returns `undefined` when the message has no map, or when the map does not match the message. Then send your default order.

## Example: OpenAI-Compatible Proxy

A common use case is typing models for an OpenAI-compatible proxy:

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

## Example: Fine-tuned Models

Adding type safety for your fine-tuned models:

```typescript
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...' }]
})
```
