This page has the details of the OpenAI adapter. To get a first chat working, start at OpenAI.
@tanstack/ai-openai has two text adapters. They call different OpenAI endpoints:
| openaiText (Responses) | openaiChatCompletions (Chat Completions) | |
|---|---|---|
| Endpoint | /v1/responses | /v1/chat/completions |
| Reasoning summaries | Yes. With reasoning set, the summary text streams as REASONING_* events | No. reasoning goes out as reasoning_effort, and no text streams back |
| Wire format | OpenAI only | The older shape that many providers use (Grok, Groq, OpenRouter, many local model servers) |
| Structured output streaming | text.format: { type: 'json_schema', strict: true } + stream: true | response_format: { type: 'json_schema', strict: true } + stream: true |
import { chat } from "@tanstack/ai";
import { openaiChatCompletions } from "@tanstack/ai-openai";
const stream = chat({
adapter: openaiChatCompletions("gpt-6.1-sol"),
messages: [{ role: "user", content: "Hello!" }],
threadId: "thread-1",
});With an explicit API key:
import { chat } from "@tanstack/ai";
import { createOpenaiChatCompletions } from "@tanstack/ai-openai";
const adapter = createOpenaiChatCompletions("gpt-6.1-sol", process.env.OPENAI_API_KEY!, {
organization: "org-...", // optional
});
const stream = chat({
adapter,
messages: [{ role: "user", content: "Hello!" }],
threadId: "thread-1",
});Both adapters work with Structured Outputs, also with stream: true. Both take the same modelOptions (temperature, top_p, max_tokens, stop, and more) and reasoning. openaiChatCompletions cannot stream the thinking text.
Does your OpenAI model run as a deployment in Azure? Use createAzureOpenaiText. It calls the Azure Responses API and sends your key in the api-key header.
import { chat, toServerSentEventsResponse } from "@tanstack/ai";
import { createAzureOpenaiText } from "@tanstack/ai-openai";
export async function POST(request: Request) {
const { messages, threadId } = await request.json();
const stream = chat({
adapter: createAzureOpenaiText("gpt-6.1-sol", process.env.AZURE_OPENAI_API_KEY!, {
resourceName: "my-resource",
deploymentName: "production-chat",
}),
messages,
threadId,
});
return toServerSentEventsResponse(stream);
}To keep all of it in the environment, use azureOpenaiText. Like openaiText, it reads the key from the environment:
AZURE_OPENAI_API_KEY=your-key
AZURE_OPENAI_RESOURCE_NAME=my-resource
AZURE_OPENAI_DEPLOYMENT_NAME_MAP=gpt-6.1-sol=production-chatimport { azureOpenaiText } from "@tanstack/ai-openai";
const adapter = azureOpenaiText("gpt-6.1-sol");azureOpenaiText always reads the key from AZURE_OPENAI_API_KEY. If that variable is not set, it throws. For the other values, the config wins over the environment:
An Azure resource URL always goes to the /openai/v1 path. A proxy URL keeps its own path.
Many of your users have a ChatGPT plan but no API key. Let them sign in with ChatGPT. The token bills their ChatGPT plan, not your account.
The helpers in @tanstack/ai-openai/siwc put the access token into the openai BYOK slot. Your relay reads it like a pasted key.
Before you start, know these limits:
Call startChatGptSignIn from a click. agentName is your app name. OpenAI shows it on the consent screen.
import { startChatGptSignIn } from "@tanstack/ai-openai/siwc";
export function ChatGptButton() {
return (
<button
type="button"
onClick={() => {
void startChatGptSignIn({ agentName: "My App" });
}}
>
Continue with ChatGPT
</button>
);
}Render this component on the /auth/callback route. It exchanges the code when the page loads. Then the user clicks to save.
import { useEffect, useRef, useState } from "react";
import {
completeChatGptSignIn,
saveChatGptSignIn,
} from "@tanstack/ai-openai/siwc";
import type { ChatGptSignIn } from "@tanstack/ai-openai/siwc";
import { byok } from "./byok";
export function ChatGptCallback() {
const started = useRef(false);
const [signIn, setSignIn] = useState<ChatGptSignIn | null>(null);
const [error, setError] = useState("");
useEffect(() => {
// The code works one time. Strict Mode runs effects two times.
if (started.current) return;
started.current = true;
completeChatGptSignIn().then(setSignIn, (caught: unknown) => {
setError(caught instanceof Error ? caught.message : "Sign-in failed");
});
}, []);
if (error) return <p>{error}</p>;
if (!signIn) return <p>Finishing sign-in...</p>;
return (
<button
type="button"
onClick={() => {
void saveChatGptSignIn(byok, signIn);
}}
>
Save ChatGPT sign-in
</button>
);
}The save needs a click, because passkey storage shows a browser prompt. The refresh token goes into a second keyring slot. No send attaches that slot, so the refresh token stays in the browser.
The access token lasts one hour. Call refreshChatGptSignIn on a timer:
import { useEffect } from "react";
import { refreshChatGptSignIn } from "@tanstack/ai-openai/siwc";
import { byok } from "./byok";
export function useChatGptRefresh() {
useEffect(() => {
const refresh = () => {
refreshChatGptSignIn(byok).catch(console.error);
};
refresh();
const timer = setInterval(refresh, 60_000);
return () => clearInterval(timer);
}, []);
}The call does nothing until the token has less than five minutes left. If OpenAI rejects the refresh token, the call clears the sign-in. Then the user must sign in again.
The relay code is the same as for a pasted key. Add store: false, because the ChatGPT route requires it.
import {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
} from "@tanstack/ai";
import { createOpenaiChat } from "@tanstack/ai-openai";
import { openaiByok } from "@tanstack/ai-openai/byok";
import { byokMissing, getByokKey } from "@tanstack/ai/byok/server";
export async function POST(request: Request) {
const params = await chatParamsFromRequest(request);
const apiKey = getByokKey(request, openaiByok);
if (!apiKey) return byokMissing(openaiByok);
const stream = chat({
adapter: createOpenaiChat("gpt-6-astra", apiKey),
messages: params.messages,
threadId: params.threadId,
runId: params.runId,
modelOptions: { store: false },
});
return toServerSentEventsResponse(stream);
}store: false also works with an API key. A ChatGPT token does not start with sk-. If you must send different options, use that test.
The ChatGPT route has these limits:
Click Continue with ChatGPT, approve, save, then send a message. The relay calls OpenAI on the ChatGPT plan of the user.
The ts-react-chat example has this flow in its key dialog.
import { createOpenaiChat, type OpenAITextConfig } from "@tanstack/ai-openai";
const config: Omit<OpenAITextConfig, "apiKey"> = {
organization: "org-...", // Optional
baseURL: "https://api.openai.com/v1", // Optional, for custom endpoints
};
const adapter = createOpenaiChat("gpt-6.1-sol", process.env.OPENAI_API_KEY!, config);Need a header on each request, or a log of each model call? The chat adapters of this package support wrapFetch.
OpenAI holds tool arguments to the schema only when the tool goes out with strict: true. Some schemas cannot be strict, for example a schema that uses $ref, z.any(), or z.record(). The adapter sends these tools with strict: false, so the tool still works. The model can then return arguments that do not match the schema.
In development, the adapter logs a warning one time for each of these tools:
tool "lookup_user" sent with strict: false: schema uses $ref, which strict mode does not supportTo get strict mode back, change the schema so that the reason goes away. If you accept a non-strict tool, turn the warning off in the adapter config:
import { createOpenaiChat } from "@tanstack/ai-openai";
const adapter = createOpenaiChat("gpt-6-astra", process.env.OPENAI_API_KEY!, {
strictFallbackWarning: false,
});The warning never runs when NODE_ENV is production. The same option works on every adapter built on the OpenAI API, for example Groq, Bedrock, and Vercel AI Gateway.
chat() sends prompt_cache_key by default, set to the threadId that you pass. OpenAI uses the key to send requests with the same start to the same cache. With promptCache: 'long', it also asks for the long retention. See Prompt Caching.
To use your own key, set it in modelOptions. Your value wins:
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
const stream = chat({
adapter: openaiText("gpt-6.1-sol"),
messages: [{ role: "user", content: "Hello!" }],
threadId: "thread-1",
modelOptions: {
prompt_cache_key: "acme-support",
},
});On some models, a tool or a system prompt that you add between model calls goes into the conversation. The cached start stays the same. These models are gpt-5.4-mini, gpt-5.5, gpt-5.6-luna, gpt-5.6-sol, gpt-5.6-terra, gpt-6-astra, gpt-6-luna, and gpt-6-sol.
What openaiText sends on these models:
The channels are on by default with the API of OpenAI. A custom baseURL, a custom fetch, or OPENAI_BASE_URL turns them off. Set midConversationChannels to choose:
import { openaiText } from "@tanstack/ai-openai";
export const fullLists = openaiText("gpt-6-astra", { midConversationChannels: false });
export const throughProxy = openaiText("gpt-6-astra", {
baseURL: "https://llm-proxy.example.com/v1",
midConversationChannels: true,
});If that endpoint sends the request on to OpenAI without changes, set true. See Mid-Conversation Changes.
OpenAI can compact a long conversation on its side, with POST /responses/compact. The Responses adapters (openaiText and createOpenaiChat) have a compact method for it. Turn it on with the native option of withCompaction:
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { withCompaction } from "@tanstack/ai-compaction";
const adapter = openaiText("gpt-6.1-sol");
const stream = chat({
adapter,
messages: [{ role: "user", content: "Hello!" }],
threadId: "thread-1",
middleware: [withCompaction({ maxTokens: 100_000, native: adapter })],
});OpenAI gives each answer item an id and a phase: commentary for text before a tool call, and final_answer for the answer. The Responses adapters (openaiText and createOpenaiChat) keep both in the assistant message, in metadata.tanstack.responseItems.
When the same model gets that message again, for example on the next turn, the adapter sends each item back with its id and phase. Another model gets the plain text.
When the model call also sent a reasoning item, the adapter sets metadata.tanstack.reasoningItem. If a stored message lost that reasoning item, the adapter sends the text and tool calls without their item ids. OpenAI refuses a msg_ or fc_ item without the reasoning item that made it.
import { generateTranscription } from "@tanstack/ai";
import { openaiTranscription } from "@tanstack/ai-openai";
import { audioFile } from "./audio";
const result = await generateTranscription({
adapter: openaiTranscription("whisper-1"),
audio: audioFile,
responseFormat: "verbose_json",
prompt: "Technical terms: API, SDK",
threadId: "thread-1",
modelOptions: {
temperature: 0,
timestamp_granularities: ["word", "segment"],
},
});
console.log(result.text);Use gpt-4o-transcribe-diarize for a transcript with speaker labels:
import { generateTranscription } from "@tanstack/ai";
import { openaiTranscription } from "@tanstack/ai-openai";
import { meetingAudioFile } from "./audio";
const result = await generateTranscription({
adapter: openaiTranscription("gpt-4o-transcribe-diarize"),
audio: meetingAudioFile,
modelOptions: {
known_speaker_names: ["agent", "customer"],
known_speaker_references: [
"data:audio/wav;base64,...",
"data:audio/wav;base64,...",
],
},
threadId: "thread-1",
});
for (const segment of result.segments ?? []) {
console.log(segment.speaker, segment.start, segment.end, segment.text);
}OpenAI has tools of its own, next to the function tools that you define. Import them from @tanstack/ai-openai/tools and pass them into chat({ tools: [...] }).
For the concept, a comparison table, and type gating, see Provider Tools.
The model runs a web search and returns grounded results with citations. Pass a WebSearchToolConfig object (typed from the OpenAI SDK).
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { webSearchTool } from "@tanstack/ai-openai/tools";
const stream = chat({
adapter: openaiText("gpt-6.1-sol"),
messages: [{ role: "user", content: "What's new in AI this week?" }],
threadId: "thread-1",
tools: [webSearchTool({ type: "web_search" })],
});Supported models: GPT-4o, GPT-5, and the models that use the Responses API. See Provider Tools.
The preview version of web search. It has more options to set the search context size and the user location.
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { webSearchPreviewTool } from "@tanstack/ai-openai/tools";
const stream = chat({
adapter: openaiText("gpt-6.1-sol"),
messages: [{ role: "user", content: "Latest news about TypeScript" }],
threadId: "thread-1",
tools: [
webSearchPreviewTool({
type: "web_search_preview_2025_03_11",
search_context_size: "high",
}),
],
});Supported models: GPT-4o and later. See Provider Tools.
When the model uses hosted web search:
Searches OpenAI vector stores that you filled before. The model gets the matching document chunks. Give the vector_store_ids to search. You can set max_num_results (1 to 50) to limit the results.
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { fileSearchTool } from "@tanstack/ai-openai/tools";
const stream = chat({
adapter: openaiText("gpt-6.1-sol"),
messages: [{ role: "user", content: "What does the handbook say about PTO?" }],
threadId: "thread-1",
tools: [
fileSearchTool({
type: "file_search",
vector_store_ids: ["vs_abc123"],
max_num_results: 5,
}),
],
});Supported models: GPT-4o and later. See Provider Tools.
The model makes images during a conversation with GPT-Image. Set quality, size, and style in the config object.
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { imageGenerationTool } from "@tanstack/ai-openai/tools";
const stream = chat({
adapter: openaiText("gpt-6.1-sol"),
messages: [{ role: "user", content: "Draw a logo for my app" }],
threadId: "thread-1",
tools: [
imageGenerationTool({
quality: "high",
size: "1024x1024",
}),
],
});Supported models: GPT-5 and the models that can use GPT-Image. See Provider Tools.
Gives the model a sandbox that runs Python. The container field sets the environment. Pass the full CodeInterpreterToolConfig object.
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { codeInterpreterTool } from "@tanstack/ai-openai/tools";
const stream = chat({
adapter: openaiText("gpt-6.1-sol"),
messages: [{ role: "user", content: "Analyse this CSV and plot a chart" }],
threadId: "thread-1",
tools: [
codeInterpreterTool({ type: "code_interpreter", container: { type: "auto" } }),
],
});Supported models: GPT-4o and later. See Provider Tools.
Connects the model to a remote MCP (Model Context Protocol) server. The model can call all tools of that server. Give server_url or connector_id, not both.
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { mcpTool } from "@tanstack/ai-openai/tools";
const stream = chat({
adapter: openaiText("gpt-6.1-sol"),
messages: [{ role: "user", content: "List my GitHub issues" }],
threadId: "thread-1",
tools: [
mcpTool({
server_url: "https://mcp.example.com",
server_label: "github",
}),
],
});Supported models: GPT-4o and later. See Provider Tools.
The model can look at a screen and use the mouse and the keyboard. Your app runs each action and sends back a screenshot. createComputerUse from @tanstack/ai-computer-use does this on a Playwright browser or an E2B desktop. Each thread gets its own machine:
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { createComputerUse } from "@tanstack/ai-computer-use";
import { playwrightComputer } from "@tanstack/ai-computer-playwright";
declare const threadId: string;
const computerUse = createComputerUse({
driver: playwrightComputer({ startUrl: "https://tanstack.com" }),
});
const stream = chat({
adapter: openaiText("gpt-6.1-sol"),
messages: [{ role: "user", content: "Find the TanStack AI docs" }],
threadId,
tools: computerUse.tools,
middleware: [computerUse.middleware],
interrupts: [computerUse.interrupt],
});The adapter sends the computer tool to OpenAI as its native computer tool.
computerUseTool from @tanstack/ai-openai/tools is the native tool with no execute. Use it when your app runs the actions itself:
import { computerUseTool } from "@tanstack/ai-openai/tools";
const computer = computerUseTool({ type: "computer" });Add an execute that runs the actions and returns a screenshot as an image part. The input of each call has two fields:
The adapter sends the first image of the result back as the screenshot. When the call does not fail, the adapter also tells OpenAI that the safety checks are accepted. When the call fails, they stay unaccepted.
Supported models: type: "computer" works with the models that list computer use, for example gpt-6.1-sol. See Provider Tools.
The model returns a local_shell tool call. Your app runs the command. Then your app sends the output back on the next request. localShellTool() takes no arguments. Add it to tools.
Match toolCallName on TOOL_CALL_START. Read input on the TOOL_CALL_END with the same toolCallId. input.command is the command. input.env is the environment variables.
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { localShellTool } from "@tanstack/ai-openai/tools";
const stream = chat({
adapter: openaiText("gpt-6.1-sol"),
messages: [{ role: "user", content: "Run the test suite and summarise failures" }],
threadId: "thread-1",
tools: [localShellTool()],
});
let localShellCallId: string | undefined;
for await (const chunk of stream) {
if (chunk.type === "TOOL_CALL_START" && chunk.toolCallName === "local_shell") {
localShellCallId = chunk.toolCallId;
}
if (chunk.type === "TOOL_CALL_END" && chunk.toolCallId === localShellCallId) {
console.log(chunk.input);
}
}Send the command output as the tool result. Use { output: string }, or a string. The next request sends local_shell_call_output.
Supported models: GPT-5.x and other models that can act as agents. See Provider Tools.
shellTool() gives the model a shell. A container environment runs on OpenAI. A local environment runs in your app.
If your app must run the commands, pass environment: { type: "local" }. Then:
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { shellTool } from "@tanstack/ai-openai/tools";
const stream = chat({
adapter: openaiText("gpt-6.1-sol"),
messages: [{ role: "user", content: "Count lines in all JS files" }],
threadId: "thread-1",
tools: [shellTool({ environment: { type: "local" } })],
});
let shellCallId: string | undefined;
for await (const chunk of stream) {
if (chunk.type === "TOOL_CALL_START" && chunk.toolCallName === "shell") {
shellCallId = chunk.toolCallId;
}
if (chunk.type === "TOOL_CALL_END" && chunk.toolCallId === shellCallId) {
console.log(chunk.input);
}
}Return one result for each command:
const shellResult = {
output: [
{
stdout: "12\n",
stderr: "",
outcome: { type: "exit" as const, exit_code: 0 },
},
],
};Supported models: GPT-5.x and other models that can act as agents. Responses API only: Chat Completions does not support the shell tool. See Provider Tools.
Pass environment.skills to load skill bundles that the provider manages into the container of the shell (Responses API only).
import { chat, toServerSentEventsResponse } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { shellTool } from "@tanstack/ai-openai/tools";
export async function POST(request: Request) {
const { messages, threadId } = await request.json();
const stream = chat({
adapter: openaiText("gpt-6.1-sol"),
messages,
threadId,
tools: [
shellTool({
environment: {
type: "container_auto",
skills: [
{ type: "skill_reference", skill_id: "skill_abc", version: "2" },
],
},
}),
],
});
return toServerSentEventsResponse(stream);
}For the skill shape, the version string format, and the Anthropic equivalent, see Provider Skills.
The model returns an apply_patch tool call. Your app applies the diff. Then your app sends the result back. applyPatchTool() takes no arguments. Add it to tools.
Match toolCallName on TOOL_CALL_START. Read input on the TOOL_CALL_END with the same toolCallId:
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { applyPatchTool } from "@tanstack/ai-openai/tools";
const stream = chat({
adapter: openaiText("gpt-6.1-sol"),
messages: [{ role: "user", content: "Fix the import paths in src/index.ts" }],
threadId: "thread-1",
tools: [applyPatchTool()],
});
let patchCallId: string | undefined;
for await (const chunk of stream) {
if (chunk.type === "TOOL_CALL_START" && chunk.toolCallName === "apply_patch") {
patchCallId = chunk.toolCallId;
}
if (chunk.type === "TOOL_CALL_END" && chunk.toolCallId === patchCallId) {
console.log(chunk.input);
}
}Send this object as the tool result. The next request sends apply_patch_call_output.
const patchResult = {
status: "completed" as const,
output: "Updated src/index.ts",
};Supported models: GPT-5.x and other models that can act as agents. See Provider Tools.
Defines a custom Responses API tool with an explicit name, description, and format. If none of the other tool types fits, use it. customTool returns a plain Tool, so every chat model accepts it.
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { customTool } from "@tanstack/ai-openai/tools";
const stream = chat({
adapter: openaiText("gpt-6.1-sol"),
messages: [{ role: "user", content: "Look up order #1234" }],
threadId: "thread-1",
tools: [
customTool({
type: "custom",
name: "lookup_order",
description: "Look up the status of a customer order by order ID",
}),
],
});Supported models: all Responses API models. See Provider Tools.
Each factory pair has the same shape. The short factory (openaiText, openaiImage, and the others) reads OPENAI_API_KEY from the environment. The create* factory takes an explicit API key. Both take model as the first argument.
| Factories | Creates |
|---|---|
| openaiText(model, config?) / createOpenaiChat(model, apiKey, config?) | Text adapter on the Responses API (/v1/responses) |
| openaiChatCompletions(model, config?) / createOpenaiChatCompletions(model, apiKey, config?) | Text adapter on /v1/chat/completions. See Chat Completions API |
| azureOpenaiText(model, config?) / createAzureOpenaiText(model, apiKey, config) | Text adapter on Azure. See Azure OpenAI |
| openaiSummarize(model, config?) / createOpenaiSummarize(model, apiKey, config?) | Summarization adapter |
| openaiImage(model, config?) / createOpenaiImage(model, apiKey, config?) | Image adapter (gpt-image) |
| openaiSpeech(model, config?) / createOpenaiSpeech(model, apiKey, config?) | Text-to-speech adapter |
| openaiTranscription(model, config?) / createOpenaiTranscription(model, apiKey, config?) | Transcription adapter (Whisper, GPT-4o transcription, and GPT-4o diarized transcription) |
| openaiEmbedding(model, config?) / createOpenaiEmbedding(model, apiKey, config?) | Embedding adapter |
| openaiVideo(model, config?) / createOpenaiVideo(model, apiKey, config?) | Video adapter (Sora). Experimental |
| openaiDecider(model, config?) / createOpenaiDecider(model, apiKey, config?) | Evaluate adapter for decide(). See Evaluate |
| openaiFiles / createOpenaiFiles | Files API adapter. See Files API |
| openaiRealtime(...) / openaiRealtimeToken(...) | Realtime voice adapters. See Realtime Voice Chat |
Text adapter config:
adapter.inputModalities lists the input kinds of the selected model. It is undefined for a model that this package does not know. See Check what a model accepts.