diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 2690411c..e9e34a1c 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -519,13 +519,21 @@ async function loadModelsDevData(): Promise[]> { if (m.tool_call !== true) continue; if (m.status === "deprecated") continue; + // Claude 4.x models route to Anthropic Messages API + const isCopilotClaude4 = /^claude-(haiku|sonnet|opus)-4([.\-]|$)/.test(modelId); // gpt-5 models require responses API, others use completions const needsResponsesApi = modelId.startsWith("gpt-5") || modelId.startsWith("oswe"); + const api: Api = isCopilotClaude4 + ? "anthropic-messages" + : needsResponsesApi + ? "openai-responses" + : "openai-completions"; + const copilotModel: Model = { id: modelId, name: m.name || modelId, - api: needsResponsesApi ? "openai-responses" : "openai-completions", + api, provider: "github-copilot", baseUrl: "https://api.individual.githubcopilot.com", reasoning: m.reasoning === true, @@ -540,13 +548,13 @@ async function loadModelsDevData(): Promise[]> { maxTokens: m.limit?.output || 8192, headers: { ...COPILOT_STATIC_HEADERS }, // compat only applies to openai-completions - ...(needsResponsesApi ? {} : { + ...(api === "openai-completions" ? { compat: { supportsStore: false, supportsDeveloperRole: false, supportsReasoningEffort: false, }, - }), + } : {}), }; models.push(copilotModel); diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 90264f1f..a7dfe82e 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -2260,11 +2260,10 @@ export const MODELS = { "claude-haiku-4.5": { id: "claude-haiku-4.5", name: "Claude Haiku 4.5", - api: "openai-completions", + api: "anthropic-messages", provider: "github-copilot", baseUrl: "https://api.individual.githubcopilot.com", headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, reasoning: true, input: ["text", "image"], cost: { @@ -2275,15 +2274,14 @@ export const MODELS = { }, contextWindow: 128000, maxTokens: 16000, - } satisfies Model<"openai-completions">, + } satisfies Model<"anthropic-messages">, "claude-opus-4.5": { id: "claude-opus-4.5", name: "Claude Opus 4.5", - api: "openai-completions", + api: "anthropic-messages", provider: "github-copilot", baseUrl: "https://api.individual.githubcopilot.com", headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, reasoning: true, input: ["text", "image"], cost: { @@ -2294,15 +2292,14 @@ export const MODELS = { }, contextWindow: 128000, maxTokens: 16000, - } satisfies Model<"openai-completions">, + } satisfies Model<"anthropic-messages">, "claude-opus-4.6": { id: "claude-opus-4.6", name: "Claude Opus 4.6", - api: "openai-completions", + api: "anthropic-messages", provider: "github-copilot", baseUrl: "https://api.individual.githubcopilot.com", headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, reasoning: true, input: ["text", "image"], cost: { @@ -2313,15 +2310,14 @@ export const MODELS = { }, contextWindow: 128000, maxTokens: 64000, - } satisfies Model<"openai-completions">, + } satisfies Model<"anthropic-messages">, "claude-sonnet-4": { id: "claude-sonnet-4", name: "Claude Sonnet 4", - api: "openai-completions", + api: "anthropic-messages", provider: "github-copilot", baseUrl: "https://api.individual.githubcopilot.com", headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, reasoning: true, input: ["text", "image"], cost: { @@ -2332,15 +2328,14 @@ export const MODELS = { }, contextWindow: 128000, maxTokens: 16000, - } satisfies Model<"openai-completions">, + } satisfies Model<"anthropic-messages">, "claude-sonnet-4.5": { id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5", - api: "openai-completions", + api: "anthropic-messages", provider: "github-copilot", baseUrl: "https://api.individual.githubcopilot.com", headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, reasoning: true, input: ["text", "image"], cost: { @@ -2351,7 +2346,7 @@ export const MODELS = { }, contextWindow: 128000, maxTokens: 16000, - } satisfies Model<"openai-completions">, + } satisfies Model<"anthropic-messages">, "gemini-2.5-pro": { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro", diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 2280a215..af2f7244 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -28,6 +28,7 @@ import { AssistantMessageEventStream } from "../utils/event-stream.js"; import { parseStreamingJson } from "../utils/json-parse.js"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.js"; +import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.js"; import { adjustMaxTokensForThinking, buildBaseOptions } from "./simple-options.js"; import { transformMessages } from "./transform-messages.js"; @@ -217,11 +218,22 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti try { const apiKey = options?.apiKey ?? getEnvApiKey(model.provider) ?? ""; + + let copilotDynamicHeaders: Record | undefined; + if (model.provider === "github-copilot") { + const hasImages = hasCopilotVisionInput(context.messages); + copilotDynamicHeaders = buildCopilotDynamicHeaders({ + messages: context.messages, + hasImages, + }); + } + const { client, isOAuthToken } = createClient( model, apiKey, options?.interleavedThinking ?? true, options?.headers, + copilotDynamicHeaders, ); const params = buildParams(model, context, isOAuthToken, options); options?.onPayload?.(params); @@ -471,12 +483,54 @@ function isOAuthToken(apiKey: string): boolean { return apiKey.includes("sk-ant-oat"); } -function createClient( - model: Model<"anthropic-messages">, - apiKey: string, - interleavedThinking: boolean, - optionsHeaders?: Record, -): { client: Anthropic; isOAuthToken: boolean } { +export interface BuildAnthropicClientOptionsParams { + model: Model<"anthropic-messages">; + apiKey: string; + interleavedThinking: boolean; + dynamicHeaders?: Record; + optionsHeaders?: Record; +} + +export interface AnthropicClientConfig { + apiKey: string | null; + authToken?: string; + baseURL: string; + defaultHeaders: Record; + dangerouslyAllowBrowser: boolean; + isOAuthToken: boolean; +} + +export function buildAnthropicClientOptions(params: BuildAnthropicClientOptionsParams): AnthropicClientConfig { + const { model, apiKey, interleavedThinking, dynamicHeaders, optionsHeaders } = params; + + // Copilot: Bearer auth, selective betas + if (model.provider === "github-copilot") { + const betaFeatures: string[] = []; + if (interleavedThinking) { + betaFeatures.push("interleaved-thinking-2025-05-14"); + } + + const defaultHeaders = mergeHeaders( + { + accept: "application/json", + "anthropic-dangerous-direct-browser-access": "true", + ...(betaFeatures.length > 0 ? { "anthropic-beta": betaFeatures.join(",") } : {}), + Authorization: `Bearer ${apiKey}`, + }, + dynamicHeaders, + model.headers, + optionsHeaders, + ); + + return { + apiKey: null, + baseURL: model.baseUrl, + defaultHeaders, + dangerouslyAllowBrowser: true, + isOAuthToken: false, + }; + } + const betaFeatures = ["fine-grained-tool-streaming-2025-05-14"]; if (interleavedThinking) { betaFeatures.push("interleaved-thinking-2025-05-14"); @@ -484,7 +538,6 @@ function createClient( const oauthToken = isOAuthToken(apiKey); if (oauthToken) { - // Stealth mode: Mimic Claude Code's headers exactly const defaultHeaders = mergeHeaders( { accept: "application/json", @@ -497,15 +550,14 @@ function createClient( optionsHeaders, ); - const client = new Anthropic({ + return { apiKey: null, authToken: apiKey, baseURL: model.baseUrl, defaultHeaders, dangerouslyAllowBrowser: true, - }); - - return { client, isOAuthToken: true }; + isOAuthToken: true, + }; } const defaultHeaders = mergeHeaders( @@ -518,14 +570,39 @@ function createClient( optionsHeaders, ); - const client = new Anthropic({ + return { apiKey, baseURL: model.baseUrl, - dangerouslyAllowBrowser: true, defaultHeaders, + dangerouslyAllowBrowser: true, + isOAuthToken: false, + }; +} + +function createClient( + model: Model<"anthropic-messages">, + apiKey: string, + interleavedThinking: boolean, + optionsHeaders?: Record, + dynamicHeaders?: Record, +): { client: Anthropic; isOAuthToken: boolean } { + const config = buildAnthropicClientOptions({ + model, + apiKey, + interleavedThinking, + dynamicHeaders, + optionsHeaders, }); - return { client, isOAuthToken: false }; + const client = new Anthropic({ + apiKey: config.apiKey, + ...(config.authToken ? { authToken: config.authToken } : {}), + baseURL: config.baseURL, + defaultHeaders: config.defaultHeaders, + dangerouslyAllowBrowser: config.dangerouslyAllowBrowser, + }); + + return { client, isOAuthToken: config.isOAuthToken }; } function buildParams( diff --git a/packages/ai/src/providers/github-copilot-headers.ts b/packages/ai/src/providers/github-copilot-headers.ts new file mode 100644 index 00000000..38bdd5e6 --- /dev/null +++ b/packages/ai/src/providers/github-copilot-headers.ts @@ -0,0 +1,59 @@ +import type { Message } from "../types.js"; + +/** + * Infer whether the current request to Copilot is user-initiated or agent-initiated. + * Accepts `unknown[]` because providers may pass pre-converted message shapes. + */ +export function inferCopilotInitiator(messages: unknown[]): "user" | "agent" { + if (messages.length === 0) return "user"; + + const last = messages[messages.length - 1] as Record; + const role = last.role as string | undefined; + if (!role) return "user"; + + if (role !== "user") return "agent"; + + // Check if last content block is a tool_result (Anthropic-converted shape) + const content = last.content; + if (Array.isArray(content) && content.length > 0) { + const lastBlock = content[content.length - 1] as Record; + if (lastBlock.type === "tool_result") { + return "agent"; + } + } + + return "user"; +} + +/** Check whether any message in the conversation contains image content. */ +export function hasCopilotVisionInput(messages: Message[]): boolean { + return messages.some((msg) => { + if (msg.role === "user" && Array.isArray(msg.content)) { + return msg.content.some((c) => c.type === "image"); + } + if (msg.role === "toolResult" && Array.isArray(msg.content)) { + return msg.content.some((c) => c.type === "image"); + } + return false; + }); +} + +/** + * Build dynamic Copilot headers that vary per-request. + * Static headers (User-Agent, Editor-Version, etc.) come from model.headers. + */ +export function buildCopilotDynamicHeaders(params: { + messages: unknown[]; + hasImages: boolean; +}): Record { + const headers: Record = { + "X-Initiator": inferCopilotInitiator(params.messages), + "Openai-Intent": "conversation-edits", + }; + + if (params.hasImages) { + headers["Copilot-Vision-Request"] = "true"; + } + + return headers; +} diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index d8e008e5..33b9778a 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -29,6 +29,7 @@ import type { import { AssistantMessageEventStream } from "../utils/event-stream.js"; import { parseStreamingJson } from "../utils/json-parse.js"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.js"; +import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.js"; import { buildBaseOptions, clampReasoning } from "./simple-options.js"; import { transformMessages } from "./transform-messages.js"; @@ -359,28 +360,12 @@ function createClient( const headers = { ...model.headers }; if (model.provider === "github-copilot") { - // Copilot expects X-Initiator to indicate whether the request is user-initiated - // or agent-initiated (e.g. follow-up after assistant/tool messages). If there is - // no prior message, default to user-initiated. - const messages = context.messages || []; - const lastMessage = messages[messages.length - 1]; - const isAgentCall = lastMessage ? lastMessage.role !== "user" : false; - headers["X-Initiator"] = isAgentCall ? "agent" : "user"; - headers["Openai-Intent"] = "conversation-edits"; - - // Copilot requires this header when sending images - const hasImages = messages.some((msg) => { - if (msg.role === "user" && Array.isArray(msg.content)) { - return msg.content.some((c) => c.type === "image"); - } - if (msg.role === "toolResult" && Array.isArray(msg.content)) { - return msg.content.some((c) => c.type === "image"); - } - return false; + const hasImages = hasCopilotVisionInput(context.messages); + const copilotHeaders = buildCopilotDynamicHeaders({ + messages: context.messages, + hasImages, }); - if (hasImages) { - headers["Copilot-Vision-Request"] = "true"; - } + Object.assign(headers, copilotHeaders); } // Merge options headers last so they can override defaults diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index 2d4b114d..b83bfe87 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -14,6 +14,7 @@ import type { Usage, } from "../types.js"; import { AssistantMessageEventStream } from "../utils/event-stream.js"; +import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.js"; import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.js"; import { buildBaseOptions, clampReasoning } from "./simple-options.js"; @@ -159,28 +160,12 @@ function createClient( const headers = { ...model.headers }; if (model.provider === "github-copilot") { - // Copilot expects X-Initiator to indicate whether the request is user-initiated - // or agent-initiated (e.g. follow-up after assistant/tool messages). If there is - // no prior message, default to user-initiated. - const messages = context.messages || []; - const lastMessage = messages[messages.length - 1]; - const isAgentCall = lastMessage ? lastMessage.role !== "user" : false; - headers["X-Initiator"] = isAgentCall ? "agent" : "user"; - headers["Openai-Intent"] = "conversation-edits"; - - // Copilot requires this header when sending images - const hasImages = messages.some((msg) => { - if (msg.role === "user" && Array.isArray(msg.content)) { - return msg.content.some((c) => c.type === "image"); - } - if (msg.role === "toolResult" && Array.isArray(msg.content)) { - return msg.content.some((c) => c.type === "image"); - } - return false; + const hasImages = hasCopilotVisionInput(context.messages); + const copilotHeaders = buildCopilotDynamicHeaders({ + messages: context.messages, + hasImages, }); - if (hasImages) { - headers["Copilot-Vision-Request"] = "true"; - } + Object.assign(headers, copilotHeaders); } // Merge options headers last so they can override defaults diff --git a/packages/ai/test/github-copilot-anthropic-auth.test.ts b/packages/ai/test/github-copilot-anthropic-auth.test.ts index 3fe3e0cc..91d75abd 100644 --- a/packages/ai/test/github-copilot-anthropic-auth.test.ts +++ b/packages/ai/test/github-copilot-anthropic-auth.test.ts @@ -40,7 +40,7 @@ describe("Anthropic Copilot auth config", () => { }); expect(options.apiKey).toBeNull(); - expect(options.defaultHeaders?.["Authorization"]).toBe(`Bearer ${token}`); + expect(options.defaultHeaders?.Authorization).toBe(`Bearer ${token}`); }); it("includes Copilot static headers from model.headers", () => { diff --git a/packages/ai/test/github-copilot-claude-messages-routing.test.ts b/packages/ai/test/github-copilot-claude-messages-routing.test.ts index 8e2d942a..351e3552 100644 --- a/packages/ai/test/github-copilot-claude-messages-routing.test.ts +++ b/packages/ai/test/github-copilot-claude-messages-routing.test.ts @@ -28,7 +28,7 @@ describe("Copilot Claude model routing", () => { it("does not have compat block on Claude models (completions-API-specific)", () => { const sonnet = getModel("github-copilot", "claude-sonnet-4"); - expect((sonnet as any).compat).toBeUndefined(); + expect("compat" in sonnet).toBe(false); }); it("preserves static Copilot headers on Claude models", () => { diff --git a/packages/ai/test/github-copilot-headers.test.ts b/packages/ai/test/github-copilot-headers.test.ts index d0f11a0a..7a74c78f 100644 --- a/packages/ai/test/github-copilot-headers.test.ts +++ b/packages/ai/test/github-copilot-headers.test.ts @@ -75,6 +75,17 @@ describe("inferCopilotInitiator", () => { ]; expect(inferCopilotInitiator(messages)).toBe("agent"); }); + + it("returns 'agent' for any non-user role (e.g. 'tool' in OpenAI format)", () => { + const messages: unknown[] = [ + { + role: "tool", + tool_call_id: "call_abc123", + content: "tool output", + }, + ]; + expect(inferCopilotInitiator(messages)).toBe("agent"); + }); }); describe("hasCopilotVisionInput", () => {