feat(ai): route copilot claude via anthropic messages api

This commit is contained in:
Nate Smyth 2026-02-06 05:09:52 -05:00 committed by Mario Zechner
parent cf1353b8e7
commit 0a132a30a1
9 changed files with 196 additions and 76 deletions

View file

@ -519,13 +519,21 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
if (m.tool_call !== true) continue; if (m.tool_call !== true) continue;
if (m.status === "deprecated") 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 // gpt-5 models require responses API, others use completions
const needsResponsesApi = modelId.startsWith("gpt-5") || modelId.startsWith("oswe"); const needsResponsesApi = modelId.startsWith("gpt-5") || modelId.startsWith("oswe");
const api: Api = isCopilotClaude4
? "anthropic-messages"
: needsResponsesApi
? "openai-responses"
: "openai-completions";
const copilotModel: Model<any> = { const copilotModel: Model<any> = {
id: modelId, id: modelId,
name: m.name || modelId, name: m.name || modelId,
api: needsResponsesApi ? "openai-responses" : "openai-completions", api,
provider: "github-copilot", provider: "github-copilot",
baseUrl: "https://api.individual.githubcopilot.com", baseUrl: "https://api.individual.githubcopilot.com",
reasoning: m.reasoning === true, reasoning: m.reasoning === true,
@ -540,13 +548,13 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
maxTokens: m.limit?.output || 8192, maxTokens: m.limit?.output || 8192,
headers: { ...COPILOT_STATIC_HEADERS }, headers: { ...COPILOT_STATIC_HEADERS },
// compat only applies to openai-completions // compat only applies to openai-completions
...(needsResponsesApi ? {} : { ...(api === "openai-completions" ? {
compat: { compat: {
supportsStore: false, supportsStore: false,
supportsDeveloperRole: false, supportsDeveloperRole: false,
supportsReasoningEffort: false, supportsReasoningEffort: false,
}, },
}), } : {}),
}; };
models.push(copilotModel); models.push(copilotModel);

View file

@ -2260,11 +2260,10 @@ export const MODELS = {
"claude-haiku-4.5": { "claude-haiku-4.5": {
id: "claude-haiku-4.5", id: "claude-haiku-4.5",
name: "Claude Haiku 4.5", name: "Claude Haiku 4.5",
api: "openai-completions", api: "anthropic-messages",
provider: "github-copilot", provider: "github-copilot",
baseUrl: "https://api.individual.githubcopilot.com", 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"}, 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, reasoning: true,
input: ["text", "image"], input: ["text", "image"],
cost: { cost: {
@ -2275,15 +2274,14 @@ export const MODELS = {
}, },
contextWindow: 128000, contextWindow: 128000,
maxTokens: 16000, maxTokens: 16000,
} satisfies Model<"openai-completions">, } satisfies Model<"anthropic-messages">,
"claude-opus-4.5": { "claude-opus-4.5": {
id: "claude-opus-4.5", id: "claude-opus-4.5",
name: "Claude Opus 4.5", name: "Claude Opus 4.5",
api: "openai-completions", api: "anthropic-messages",
provider: "github-copilot", provider: "github-copilot",
baseUrl: "https://api.individual.githubcopilot.com", 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"}, 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, reasoning: true,
input: ["text", "image"], input: ["text", "image"],
cost: { cost: {
@ -2294,15 +2292,14 @@ export const MODELS = {
}, },
contextWindow: 128000, contextWindow: 128000,
maxTokens: 16000, maxTokens: 16000,
} satisfies Model<"openai-completions">, } satisfies Model<"anthropic-messages">,
"claude-opus-4.6": { "claude-opus-4.6": {
id: "claude-opus-4.6", id: "claude-opus-4.6",
name: "Claude Opus 4.6", name: "Claude Opus 4.6",
api: "openai-completions", api: "anthropic-messages",
provider: "github-copilot", provider: "github-copilot",
baseUrl: "https://api.individual.githubcopilot.com", 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"}, 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, reasoning: true,
input: ["text", "image"], input: ["text", "image"],
cost: { cost: {
@ -2313,15 +2310,14 @@ export const MODELS = {
}, },
contextWindow: 128000, contextWindow: 128000,
maxTokens: 64000, maxTokens: 64000,
} satisfies Model<"openai-completions">, } satisfies Model<"anthropic-messages">,
"claude-sonnet-4": { "claude-sonnet-4": {
id: "claude-sonnet-4", id: "claude-sonnet-4",
name: "Claude Sonnet 4", name: "Claude Sonnet 4",
api: "openai-completions", api: "anthropic-messages",
provider: "github-copilot", provider: "github-copilot",
baseUrl: "https://api.individual.githubcopilot.com", 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"}, 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, reasoning: true,
input: ["text", "image"], input: ["text", "image"],
cost: { cost: {
@ -2332,15 +2328,14 @@ export const MODELS = {
}, },
contextWindow: 128000, contextWindow: 128000,
maxTokens: 16000, maxTokens: 16000,
} satisfies Model<"openai-completions">, } satisfies Model<"anthropic-messages">,
"claude-sonnet-4.5": { "claude-sonnet-4.5": {
id: "claude-sonnet-4.5", id: "claude-sonnet-4.5",
name: "Claude Sonnet 4.5", name: "Claude Sonnet 4.5",
api: "openai-completions", api: "anthropic-messages",
provider: "github-copilot", provider: "github-copilot",
baseUrl: "https://api.individual.githubcopilot.com", 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"}, 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, reasoning: true,
input: ["text", "image"], input: ["text", "image"],
cost: { cost: {
@ -2351,7 +2346,7 @@ export const MODELS = {
}, },
contextWindow: 128000, contextWindow: 128000,
maxTokens: 16000, maxTokens: 16000,
} satisfies Model<"openai-completions">, } satisfies Model<"anthropic-messages">,
"gemini-2.5-pro": { "gemini-2.5-pro": {
id: "gemini-2.5-pro", id: "gemini-2.5-pro",
name: "Gemini 2.5 Pro", name: "Gemini 2.5 Pro",

View file

@ -28,6 +28,7 @@ import { AssistantMessageEventStream } from "../utils/event-stream.js";
import { parseStreamingJson } from "../utils/json-parse.js"; import { parseStreamingJson } from "../utils/json-parse.js";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.js"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.js";
import { adjustMaxTokensForThinking, buildBaseOptions } from "./simple-options.js"; import { adjustMaxTokensForThinking, buildBaseOptions } from "./simple-options.js";
import { transformMessages } from "./transform-messages.js"; import { transformMessages } from "./transform-messages.js";
@ -217,11 +218,22 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti
try { try {
const apiKey = options?.apiKey ?? getEnvApiKey(model.provider) ?? ""; const apiKey = options?.apiKey ?? getEnvApiKey(model.provider) ?? "";
let copilotDynamicHeaders: Record<string, string> | undefined;
if (model.provider === "github-copilot") {
const hasImages = hasCopilotVisionInput(context.messages);
copilotDynamicHeaders = buildCopilotDynamicHeaders({
messages: context.messages,
hasImages,
});
}
const { client, isOAuthToken } = createClient( const { client, isOAuthToken } = createClient(
model, model,
apiKey, apiKey,
options?.interleavedThinking ?? true, options?.interleavedThinking ?? true,
options?.headers, options?.headers,
copilotDynamicHeaders,
); );
const params = buildParams(model, context, isOAuthToken, options); const params = buildParams(model, context, isOAuthToken, options);
options?.onPayload?.(params); options?.onPayload?.(params);
@ -471,12 +483,54 @@ function isOAuthToken(apiKey: string): boolean {
return apiKey.includes("sk-ant-oat"); return apiKey.includes("sk-ant-oat");
} }
function createClient( export interface BuildAnthropicClientOptionsParams {
model: Model<"anthropic-messages">, model: Model<"anthropic-messages">;
apiKey: string, apiKey: string;
interleavedThinking: boolean, interleavedThinking: boolean;
optionsHeaders?: Record<string, string>, dynamicHeaders?: Record<string, string>;
): { client: Anthropic; isOAuthToken: boolean } { optionsHeaders?: Record<string, string>;
}
export interface AnthropicClientConfig {
apiKey: string | null;
authToken?: string;
baseURL: string;
defaultHeaders: Record<string, string>;
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"]; const betaFeatures = ["fine-grained-tool-streaming-2025-05-14"];
if (interleavedThinking) { if (interleavedThinking) {
betaFeatures.push("interleaved-thinking-2025-05-14"); betaFeatures.push("interleaved-thinking-2025-05-14");
@ -484,7 +538,6 @@ function createClient(
const oauthToken = isOAuthToken(apiKey); const oauthToken = isOAuthToken(apiKey);
if (oauthToken) { if (oauthToken) {
// Stealth mode: Mimic Claude Code's headers exactly
const defaultHeaders = mergeHeaders( const defaultHeaders = mergeHeaders(
{ {
accept: "application/json", accept: "application/json",
@ -497,15 +550,14 @@ function createClient(
optionsHeaders, optionsHeaders,
); );
const client = new Anthropic({ return {
apiKey: null, apiKey: null,
authToken: apiKey, authToken: apiKey,
baseURL: model.baseUrl, baseURL: model.baseUrl,
defaultHeaders, defaultHeaders,
dangerouslyAllowBrowser: true, dangerouslyAllowBrowser: true,
}); isOAuthToken: true,
};
return { client, isOAuthToken: true };
} }
const defaultHeaders = mergeHeaders( const defaultHeaders = mergeHeaders(
@ -518,14 +570,39 @@ function createClient(
optionsHeaders, optionsHeaders,
); );
const client = new Anthropic({ return {
apiKey, apiKey,
baseURL: model.baseUrl, baseURL: model.baseUrl,
dangerouslyAllowBrowser: true,
defaultHeaders, defaultHeaders,
dangerouslyAllowBrowser: true,
isOAuthToken: false,
};
}
function createClient(
model: Model<"anthropic-messages">,
apiKey: string,
interleavedThinking: boolean,
optionsHeaders?: Record<string, string>,
dynamicHeaders?: Record<string, string>,
): { 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( function buildParams(

View file

@ -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<string, unknown>;
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<string, unknown>;
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<string, string> {
const headers: Record<string, string> = {
"X-Initiator": inferCopilotInitiator(params.messages),
"Openai-Intent": "conversation-edits",
};
if (params.hasImages) {
headers["Copilot-Vision-Request"] = "true";
}
return headers;
}

View file

@ -29,6 +29,7 @@ import type {
import { AssistantMessageEventStream } from "../utils/event-stream.js"; import { AssistantMessageEventStream } from "../utils/event-stream.js";
import { parseStreamingJson } from "../utils/json-parse.js"; import { parseStreamingJson } from "../utils/json-parse.js";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.js"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.js";
import { buildBaseOptions, clampReasoning } from "./simple-options.js"; import { buildBaseOptions, clampReasoning } from "./simple-options.js";
import { transformMessages } from "./transform-messages.js"; import { transformMessages } from "./transform-messages.js";
@ -359,28 +360,12 @@ function createClient(
const headers = { ...model.headers }; const headers = { ...model.headers };
if (model.provider === "github-copilot") { if (model.provider === "github-copilot") {
// Copilot expects X-Initiator to indicate whether the request is user-initiated const hasImages = hasCopilotVisionInput(context.messages);
// or agent-initiated (e.g. follow-up after assistant/tool messages). If there is const copilotHeaders = buildCopilotDynamicHeaders({
// no prior message, default to user-initiated. messages: context.messages,
const messages = context.messages || []; hasImages,
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;
}); });
if (hasImages) { Object.assign(headers, copilotHeaders);
headers["Copilot-Vision-Request"] = "true";
}
} }
// Merge options headers last so they can override defaults // Merge options headers last so they can override defaults

View file

@ -14,6 +14,7 @@ import type {
Usage, Usage,
} from "../types.js"; } from "../types.js";
import { AssistantMessageEventStream } from "../utils/event-stream.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 { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.js";
import { buildBaseOptions, clampReasoning } from "./simple-options.js"; import { buildBaseOptions, clampReasoning } from "./simple-options.js";
@ -159,28 +160,12 @@ function createClient(
const headers = { ...model.headers }; const headers = { ...model.headers };
if (model.provider === "github-copilot") { if (model.provider === "github-copilot") {
// Copilot expects X-Initiator to indicate whether the request is user-initiated const hasImages = hasCopilotVisionInput(context.messages);
// or agent-initiated (e.g. follow-up after assistant/tool messages). If there is const copilotHeaders = buildCopilotDynamicHeaders({
// no prior message, default to user-initiated. messages: context.messages,
const messages = context.messages || []; hasImages,
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;
}); });
if (hasImages) { Object.assign(headers, copilotHeaders);
headers["Copilot-Vision-Request"] = "true";
}
} }
// Merge options headers last so they can override defaults // Merge options headers last so they can override defaults

View file

@ -40,7 +40,7 @@ describe("Anthropic Copilot auth config", () => {
}); });
expect(options.apiKey).toBeNull(); 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", () => { it("includes Copilot static headers from model.headers", () => {

View file

@ -28,7 +28,7 @@ describe("Copilot Claude model routing", () => {
it("does not have compat block on Claude models (completions-API-specific)", () => { it("does not have compat block on Claude models (completions-API-specific)", () => {
const sonnet = getModel("github-copilot", "claude-sonnet-4"); 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", () => { it("preserves static Copilot headers on Claude models", () => {

View file

@ -75,6 +75,17 @@ describe("inferCopilotInitiator", () => {
]; ];
expect(inferCopilotInitiator(messages)).toBe("agent"); 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", () => { describe("hasCopilotVisionInput", () => {