This page has the details of the OpenAI-compatible adapter. To get a first chat working, start at OpenAI-Compatible Adapter.
openaiCompatible accepts every ClientOptions field of the OpenAI SDK. apiKey and baseURL are required, and they are at the top level. The most useful other fields 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 adapters that openaiCompatible makes support wrapFetch.
By default, the adapter calls the Chat Completions API (/chat/completions). For a provider with the Responses API, set api: "responses":
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"
});The api choice also sets how ChatStreamSummarizeAdapter sends maxLength, with any wrapper name:
An explicit token limit in modelOptions wins over maxLength.
Reasoning deltas stream as thinking content. Endpoints on the OpenAI spec from before July 2025 can still send the old event name response.reasoning.delta, which the spec replaced with response.reasoning_text.delta. The adapter reads both names the same way.
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-6.1-sol", 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 the GitHub token of the user 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 tells you.
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 cookies of the web app. 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,
threadId: params.threadId,
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,
threadId: params.threadId,
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, with no 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,
threadId: params.threadId,
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.