Many providers expose the OpenAI Chat Completions API (/chat/completions) — DeepSeek, Moonshot/Kimi, Together, Fireworks, Cerebras, Alibaba Qwen, Perplexity, NVIDIA NIM, and local servers like LM Studio, Ollama, and vLLM. Instead of a dedicated package per provider, TanStack AI ships one generic adapter: point it at any compatible baseURL, give it your models, and you get the same type-safe chat() experience as the first-class adapters.
Use this when your provider speaks the OpenAI Chat Completions wire format but doesn't have its own @tanstack/ai-* package. If a dedicated adapter exists (OpenAI, Grok, Groq, OpenRouter), prefer it. Those carry curated per-model metadata. For Vercel AI Gateway, install @tanstack/ai-vercel-gateway and use vercelGatewayText. See Vercel AI Gateway. For Lovable AI Gateway, install @tanstack/ai-lovable and use lovableText (plus image, video, embeddings, and speech factories). See Lovable AI Gateway.
Perplexity Sonar chat stays on this adapter. @tanstack/ai-perplexity is Search/grounding only — it does not replace openaiCompatible for chat(). Optional: pass defaultHeaders: getPerplexityIntegrationHeaders() from that package to send Perplexity's X-Pplx-Integration attribution header.
The adapter ships inside @tanstack/ai-openai under the /compatible subpath — no extra install:
npm i @tanstack/ai-openaipnpm add @tanstack/ai-openaiyarn add @tanstack/ai-openaibun add @tanstack/ai-openaiConfigure the provider once with openaiCompatible({ baseURL, apiKey, models }), then select a model per call. The returned model name is a type-safe union of the models you declared:
import { chat } from "@tanstack/ai";
import { openaiCompatible } from "@tanstack/ai-openai/compatible";
const deepseek = openaiCompatible({
name: "deepseek", // optional label shown in devtools/errors (default: "openai-compatible")
baseURL: "https://api.deepseek.com/v1",
apiKey: process.env.DEEPSEEK_API_KEY!,
models: ["deepseek-chat", "deepseek-reasoner"],
});
const stream = chat({
adapter: deepseek("deepseek-chat"),
messages: [{ role: "user", content: "Hello!" }],
});deepseek("deepseek-reasoner") is valid; deepseek("gpt-4o") is a type error — only declared models are accepted.
For a single model, skip the provider-factory and build the adapter inline with openaiCompatibleText:
import { chat } from "@tanstack/ai";
import { openaiCompatibleText } from "@tanstack/ai-openai/compatible";
const stream = chat({
adapter: openaiCompatibleText("deepseek-chat", {
baseURL: "https://api.deepseek.com/v1",
apiKey: process.env.DEEPSEEK_API_KEY!,
}),
messages: [{ role: "user", content: "Hello!" }],
});The models array accepts two forms, which you can mix:
import { openaiCompatible } from "@tanstack/ai-openai/compatible";
import { createModel } from "@tanstack/ai";
const provider = openaiCompatible({
baseURL: "https://api.deepseek.com/v1",
apiKey: process.env.DEEPSEEK_API_KEY!,
models: [
"deepseek-chat", // string → optimistic defaults
createModel("deepseek-reasoner", {
input: ["text"], // text only
features: ["reasoning", "structured_outputs"],
}),
],
});Capabilities are enforced at the type level. If a provider rejects a feature at runtime (e.g. tools on a model that doesn't support them), declare that model with createModel and omit the unsupported feature so the types stop you from calling it.
openaiCompatible accepts every OpenAI SDK ClientOptions field besides apiKey/baseURL (which are required and promoted to the top level). The most useful are defaultHeaders and defaultQuery, for providers that need extra auth or routing parameters:
import { openaiCompatible } from "@tanstack/ai-openai/compatible";
const provider = openaiCompatible({
baseURL: "https://api.example.com/v1",
apiKey: process.env.EXAMPLE_API_KEY!,
models: ["some-model"],
defaultHeaders: { "X-Custom-Header": "value" },
defaultQuery: { "api-version": "2026-01-01" },
});Need a header on each request, or a log of each model call? The chat adapters on this page support wrapFetch.
By default the adapter targets the Chat Completions API (/chat/completions). For providers that implement the Responses API, select api: "responses". This API choice also controls how ChatStreamSummarizeAdapter forwards maxLength, regardless of the wrapper name:
An explicit token limit in modelOptions takes precedence over maxLength.
import { openaiCompatible } from "@tanstack/ai-openai/compatible";
const provider = openaiCompatible({
baseURL: "https://my-resource.openai.azure.com/openai/v1",
apiKey: process.env.AZURE_OPENAI_API_KEY!,
models: ["gpt-4o"],
api: "responses", // default is "chat-completions"
});Reasoning: Reasoning deltas stream as thinking content. Endpoints frozen on the pre-July-2025 OpenAI spec may still emit the legacy event name response.reasoning.delta (removed from the spec in favor of response.reasoning_text.delta); the adapter recognizes both and maps them identically.
Any provider implementing the OpenAI Chat Completions API works. Common ones are below — verify the baseURL and model ids against each provider's current docs, since they change over time. Set the API key via the provider's own environment variable and pass it as apiKey.
| Provider | baseURL | Example model |
|---|---|---|
| DeepSeek | https://api.deepseek.com/v1 | deepseek-chat, deepseek-reasoner |
| Moonshot / Kimi | https://api.moonshot.ai/v1 | kimi-k2-0711-preview |
| Alibaba Qwen (DashScope, intl) | https://dashscope-intl.aliyuncs.com/compatible-mode/v1 | qwen-max, qwen-plus |
| Alibaba Qwen (DashScope, China) | https://dashscope.aliyuncs.com/compatible-mode/v1 | qwen-max |
| Together AI | https://api.together.xyz/v1 | meta-llama/Llama-3.3-70B-Instruct-Turbo |
| Fireworks AI | https://api.fireworks.ai/inference/v1 | accounts/fireworks/models/llama-v3p3-70b-instruct |
| Cerebras | https://api.cerebras.ai/v1 | llama-3.3-70b |
| DeepInfra | https://api.deepinfra.com/v1/openai | meta-llama/Llama-3.3-70B-Instruct |
| Perplexity | https://api.perplexity.ai | sonar, sonar-pro |
| Mistral | https://api.mistral.ai/v1 | mistral-large-latest |
| Nebius | https://api.studio.nebius.ai/v1 | meta-llama/Llama-3.3-70B-Instruct |
| Z.AI (GLM) | https://api.z.ai/api/paas/v4 | glm-4.6 |
| Baseten | https://inference.baseten.co/v1 | model-dependent |
| Hugging Face (router) | https://router.huggingface.co/v1 | meta-llama/Llama-3.3-70B-Instruct |
| NVIDIA NIM | https://integrate.api.nvidia.com/v1 | meta/llama-3.3-70b-instruct |
| Kilo Gateway | https://api.kilo.ai/api/gateway | anthropic/claude-opus-5.5 |
| ZenMux | https://zenmux.ai/api/v1 | openai/gpt-5.5 |
| Poe | https://api.poe.com/v1 | openai/gpt-5.5 |
| DigitalOcean | https://inference.do-ai.run/v1 | openai-gpt-5.5 |
| Modal | https://inference.us-west.modal.direct/v1 | moonshotai/Kimi-K3 |
| Snowflake Cortex | https://<account>.snowflakecomputing.com/api/v2/cortex/v1 | claude-opus-5 |
Some providers sign the user in with OAuth instead of an API key. For Poe, DigitalOcean, and Snowflake Cortex, pass the access token from your sign-in code as apiKey.
On Chat Completions, RUN_FINISHED.usage carries the prompt cache counts that the provider reports. Cache reads arrive on promptTokensDetails.cachedTokens, and cache writes arrive on promptTokensDetails.cacheWriteTokens. Moonshot / Kimi reports both.
Point the adapter at any local OpenAI-compatible server. The API key is usually a placeholder:
import { openaiCompatible } from "@tanstack/ai-openai/compatible";
// LM Studio
const lmstudio = openaiCompatible({
name: "lmstudio",
baseURL: "http://localhost:1234/v1",
apiKey: "lm-studio",
models: ["local-model"],
});
// vLLM
const vllm = openaiCompatible({
name: "vllm",
baseURL: "http://localhost:8000/v1",
apiKey: "not-needed",
models: ["meta-llama/Llama-3.3-70B-Instruct"],
});
// Ollama's OpenAI-compatible endpoint
const ollama = openaiCompatible({
name: "ollama",
baseURL: "http://localhost:11434/v1",
apiKey: "ollama",
models: ["llama3.3"],
});Ollama also has a dedicated adapter, @tanstack/ai-ollama, which understands its native API. Use openaiCompatible only if you specifically want Ollama's OpenAI-compatible surface.
LiteLLM is a self-hosted gateway that exposes a single OpenAI Chat Completions endpoint in front of 100+ providers (OpenAI, Anthropic, Google, Azure, AWS Bedrock, Mistral, Groq, and more). Because the proxy speaks the OpenAI wire format, it needs no dedicated package — point openaiCompatible at your proxy's baseURL (default http://localhost:4000/v1) and route to a provider with LiteLLM's provider/model naming:
import { openaiCompatible } from "@tanstack/ai-openai/compatible";
const litellm = openaiCompatible({
name: "litellm",
baseURL: "http://localhost:4000/v1", // your LiteLLM proxy
apiKey: process.env.LITELLM_API_KEY!, // a virtual key issued by the proxy
models: [
"anthropic/claude-sonnet-5",
"openai/gpt-5.5",
"gemini/gemini-3.5-flash",
],
});litellm("anthropic/claude-sonnet-5") selects the Anthropic route; litellm("openai/gpt-5.5") selects OpenAI — all through the one proxy. Declare only the model routes you configured on the proxy; for precise per-model capabilities (e.g. a reasoning route without image input), use createModel as shown under Declaring Models.
The proxy holds each upstream provider's real credentials; the apiKey here is the proxy's own virtual/master key, not the upstream provider's.
Use createAzureOpenaiText from @tanstack/ai-openai. It sets the api-key header, the endpoint, the API version, and the deployment name for you:
import { createAzureOpenaiText } from "@tanstack/ai-openai";
const adapter = createAzureOpenaiText("gpt-5.6", process.env.AZURE_OPENAI_API_KEY!, {
resourceName: "my-resource",
deploymentName: "production-chat",
});See Azure OpenAI for azureOpenaiText, which reads the key and the other values from the environment.
Your users can pay for model calls with their GitHub Copilot plan. Each user signs in with GitHub. Your server sends their GitHub token to Copilot as Authorization: Bearer.
If you keep Expire user access tokens on, a token lasts 8 hours. Refresh it with its refresh token, as Authorizing OAuth apps describes.
Pick the flow for your app:
Both flows ask for the read:user scope only.
copilot-auth.ts has the two sign-in routes and a reader for your chat routes. The token goes into an HttpOnly cookie, so browser code cannot read it:
import { copilotBaseURL } from "./copilot-host";
function env(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Set ${name}.`);
return value;
}
function readCookie(request: Request, name: string): string | undefined {
for (const pair of (request.headers.get("cookie") ?? "").split(";")) {
const [key, ...rest] = pair.trim().split("=");
if (key !== name) continue;
try {
return decodeURIComponent(rest.join("="));
} catch {
return undefined; // A malformed value counts as no cookie.
}
}
return undefined;
}
function cookie(name: string, value: string, maxAge: number): string {
return `${name}=${encodeURIComponent(value)}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${maxAge}`;
}
/** GET /auth/github: send the user to GitHub. */
export function startGithubSignIn(): Response {
const state = crypto.randomUUID();
const url = new URL("https://github.com/login/oauth/authorize");
url.searchParams.set("client_id", env("GITHUB_CLIENT_ID"));
url.searchParams.set("scope", "read:user");
url.searchParams.set("state", state);
return new Response(null, {
status: 302,
headers: {
Location: url.toString(),
"Set-Cookie": cookie("github_state", state, 600),
},
});
}
/** GET /auth/github/callback: swap the code for a token. */
export async function finishGithubSignIn(request: Request): Promise<Response> {
const url = new URL(request.url);
const state = url.searchParams.get("state");
if (!state || state !== readCookie(request, "github_state")) {
return new Response("The sign-in failed. Try again.", { status: 400 });
}
const reply = await fetch("https://github.com/login/oauth/access_token", {
method: "POST",
headers: { Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify({
client_id: env("GITHUB_CLIENT_ID"),
client_secret: env("GITHUB_CLIENT_SECRET"),
code: url.searchParams.get("code"),
}),
});
const { access_token }: { access_token?: string } = await reply.json();
if (!access_token) {
return new Response("The sign-in failed. Try again.", { status: 400 });
}
const baseURL = await copilotBaseURL(access_token);
if (!baseURL) {
return new Response("This GitHub account has no Copilot access.", {
status: 403,
});
}
const month = 60 * 60 * 24 * 30;
const headers = new Headers({ Location: "/" });
headers.append("Set-Cookie", cookie("copilot_token", access_token, month));
headers.append("Set-Cookie", cookie("copilot_base_url", baseURL, month));
headers.append("Set-Cookie", cookie("github_state", "", 0));
return new Response(null, { status: 302, headers });
}
/** The Copilot sign-in of the request, or `undefined`. */
export function getCopilotSignIn(request: Request) {
const token = readCookie(request, "copilot_token");
const baseURL = readCookie(request, "copilot_base_url");
if (!token || !baseURL || !URL.canParse(baseURL)) return undefined;
// The user can change cookies. Send the token only to a Copilot host.
const { protocol, hostname } = new URL(baseURL);
if (protocol !== "https:" || !hostname.endsWith(".githubcopilot.com")) {
return undefined;
}
return { token, baseURL };
}copilot-device.ts shows a code. Then it waits until the user types the code on GitHub:
interface DeviceCode {
device_code?: string;
user_code?: string;
verification_uri?: string;
interval?: number;
error_description?: string;
}
interface TokenReply {
access_token?: string;
error?: string;
error_description?: string;
interval?: number;
}
async function postGithub(path: string, body: Record<string, string>) {
const response = await fetch(`https://github.com${path}`, {
method: "POST",
headers: { Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return response.json();
}
export async function signInWithDevice(): Promise<string> {
const clientId = process.env.GITHUB_CLIENT_ID;
if (!clientId) throw new Error("Set GITHUB_CLIENT_ID.");
const code: DeviceCode = await postGithub("/login/device/code", {
client_id: clientId,
scope: "read:user",
});
if (!code.device_code) {
throw new Error(code.error_description ?? "The GitHub sign-in failed.");
}
console.log(`Open ${code.verification_uri} and type ${code.user_code}`);
let interval = code.interval ?? 5;
for (;;) {
await new Promise((resolve) => setTimeout(resolve, interval * 1000));
const reply: TokenReply = await postGithub("/login/oauth/access_token", {
client_id: clientId,
device_code: code.device_code,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
});
if (reply.access_token) return reply.access_token;
if (reply.error === "slow_down") {
interval = reply.interval ?? interval + 5;
} else if (reply.error !== "authorization_pending") {
throw new Error(reply.error_description ?? "The GitHub sign-in failed.");
}
}
}Keep the token in the OS keychain, or in a file that only the user can read. With Expire user access tokens cleared, the token does not expire, so the user signs in one time.
Any GitHub account can sign in, but only some accounts have Copilot. Business and enterprise accounts can also have a different host. copilot-host.ts reads both from GitHub:
interface CopilotUser {
chat_enabled?: boolean;
endpoints?: { api?: string };
}
/**
* The Copilot API host of the user, or `undefined` when the account has no
* Copilot. `githubHost` is your GitHub Enterprise domain, or `github.com`.
*/
export async function copilotBaseURL(
token: string,
githubHost = "github.com",
): Promise<string | undefined> {
const api =
githubHost === "github.com"
? "https://api.github.com"
: `https://api.${githubHost}`;
const response = await fetch(`${api}/copilot_internal/user`, {
headers: {
Accept: "application/json",
Authorization: `Bearer ${token}`,
"X-GitHub-Api-Version": "2025-04-01",
},
});
// A failed lookup uses the default host. Only GitHub can say "no Copilot".
const user: CopilotUser = response.ok ? await response.json() : {};
if (user.chat_enabled === false) return undefined;
if (user.endpoints?.api) return user.endpoints.api.replace(/\/+$/, "");
return githubHost === "github.com"
? "https://api.githubcopilot.com"
: `https://copilot-api.${githubHost}`;
}copilot_internal/user is not a public GitHub API. GitHub can change it.
Copilot reads a set of headers on every request. One wrapFetch in copilot-headers.ts sets all of them for each route:
import type { FetchWrapper } from "@tanstack/ai";
/** The Copilot interaction types: the main loop, a subagent, a title, or a compaction. */
type Interaction =
| "conversation-agent"
| "conversation-subagent"
| "conversation-background"
| "conversation-compaction";
export function copilotHeaders(
sessionId: string,
interaction: Interaction = "conversation-agent",
): FetchWrapper {
return (next) => (input, init) => {
const body = typeof init?.body === "string" ? JSON.parse(init.body) : {};
const items = body.input ?? body.messages ?? [];
const last = items.at(-1);
// A user turn has text from the user. A tool result alone is an agent turn.
const userTurn =
last?.role === "user" &&
(!Array.isArray(last.content) ||
last.content.some(
(part: { type?: string }) => part.type !== "tool_result",
));
const url = input instanceof Request ? input.url : String(input);
const headers = new Headers(init?.headers);
headers.set("User-Agent", "my-app/1.0.0");
headers.set("Openai-Intent", "conversation-edits");
headers.set("X-GitHub-Api-Version", "2026-08-01");
headers.set("X-Interaction-Type", interaction);
headers.set("X-Interaction-Id", sessionId);
headers.set(
"x-initiator",
interaction === "conversation-agent" && userTurn ? "user" : "agent",
);
if (/"type":"(image|image_url|input_image)"/.test(JSON.stringify(items))) {
headers.set("Copilot-Vision-Request", "true");
}
if (url.includes("/v1/messages")) {
// Keep the betas that the adapter already set.
const betas = headers.get("anthropic-beta");
const thinking = "interleaved-thinking-2025-05-14";
headers.set("anthropic-beta", betas ? `${betas},${thinking}` : thinking);
}
return next(input, { ...init, headers });
};
}Copilot serves each model on one of three APIs. GET {baseURL}/models lists the supported_endpoints of each model. Choose the adapter in this order:
The examples below read the sign-in from the web app cookies. In a terminal app, use the saved token and the host from step 3 in place of signIn.
Set the Copilot host as baseURL. The Anthropic SDK adds /v1/messages. Set auth: "bearer". Do not use "oauth", because it adds the Claude Code headers:
import {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
} from "@tanstack/ai";
import { createAnthropicChat } from "@tanstack/ai-anthropic";
import { copilotHeaders } from "./copilot-headers";
import { getCopilotSignIn } from "./copilot-auth";
export async function POST(request: Request) {
const signIn = getCopilotSignIn(request);
if (!signIn) return new Response("Sign in with GitHub.", { status: 401 });
const params = await chatParamsFromRequest(request);
const stream = chat({
adapter: createAnthropicChat("claude-sonnet-5-5", signIn.token, {
baseURL: signIn.baseURL,
auth: "bearer",
}),
messages: params.messages,
wrapFetch: copilotHeaders(params.threadId),
});
return toServerSentEventsResponse(stream);
}auth: "bearer" sends Authorization: Bearer and no x-api-key. With a custom baseURL, the adapter sends no mid-conversation betas. It adds cache markers only when you set promptCache.
Pass the token as apiKey:
import {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
} from "@tanstack/ai";
import { openaiCompatible } from "@tanstack/ai-openai/compatible";
import { copilotHeaders } from "./copilot-headers";
import { getCopilotSignIn } from "./copilot-auth";
export async function POST(request: Request) {
const signIn = getCopilotSignIn(request);
if (!signIn) return new Response("Sign in with GitHub.", { status: 401 });
const params = await chatParamsFromRequest(request);
const copilot = openaiCompatible({
name: "github-copilot",
baseURL: signIn.baseURL,
apiKey: signIn.token,
api: "responses",
models: ["gpt-6.1-sol"],
});
const stream = chat({
adapter: copilot("gpt-6.1-sol"),
messages: params.messages,
modelOptions: { store: false, include: ["reasoning.encrypted_content"] },
wrapFetch: copilotHeaders(params.threadId),
});
return toServerSentEventsResponse(stream);
}Click Sign in with GitHub, approve, then send a message. The model call goes to the Copilot plan of the user.
A user with a ChatGPT plan can sign in with ChatGPT instead of an API key. Sign in with ChatGPT adds the sign-in button and keeps the access token in the openai BYOK slot. Your server reads the token like a pasted key:
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 apiKey = getByokKey(request, openaiByok);
if (!apiKey) return byokMissing(openaiByok);
const params = await chatParamsFromRequest(request);
const stream = chat({
adapter: createOpenaiChat("gpt-6.1-sol", apiKey),
messages: params.messages,
modelOptions: { store: false },
});
return toServerSentEventsResponse(stream);
}The ChatGPT route requires store: false. Do not set max_output_tokens. Sign in with ChatGPT lists the other limits.
Tools work exactly as they do with any other adapter, for models that support function calling:
import { chat, toolDefinition } from "@tanstack/ai";
import { openaiCompatible } from "@tanstack/ai-openai/compatible";
import { z } from "zod";
const getWeatherDef = toolDefinition({
name: "get_weather",
description: "Get the current weather",
inputSchema: z.object({ location: z.string() }),
});
const getWeather = getWeatherDef.server(async ({ location }) => {
return { temperature: 72, conditions: "sunny" };
});
const deepseek = openaiCompatible({
baseURL: "https://api.deepseek.com/v1",
apiKey: process.env.DEEPSEEK_API_KEY!,
models: ["deepseek-chat"],
});
const stream = chat({
adapter: deepseek("deepseek-chat"),
messages: [{ role: "user", content: "What's the weather in Tokyo?" }],
tools: [getWeather],
});