test(ai): cover copilot claude messages routing + headers

This commit is contained in:
Nate Smyth 2026-02-06 05:07:07 -05:00 committed by Mario Zechner
parent 7eb969ddb1
commit cf1353b8e7
4 changed files with 477 additions and 0 deletions

View file

@ -0,0 +1,114 @@
import { describe, expect, it } from "vitest";
import { buildAnthropicClientOptions } from "../src/providers/anthropic.js";
import type { Model } from "../src/types.js";
const COPILOT_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",
};
function makeCopilotClaudeModel(): Model<"anthropic-messages"> {
return {
id: "claude-sonnet-4",
name: "Claude Sonnet 4",
api: "anthropic-messages",
provider: "github-copilot",
baseUrl: "https://api.individual.githubcopilot.com",
headers: { ...COPILOT_HEADERS },
reasoning: true,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 16000,
};
}
describe("Anthropic Copilot auth config", () => {
it("uses apiKey: null and Authorization Bearer for Copilot models", () => {
const model = makeCopilotClaudeModel();
const token = "ghu_test_token_12345";
const options = buildAnthropicClientOptions({
model,
apiKey: token,
interleavedThinking: true,
dynamicHeaders: {
"X-Initiator": "user",
"Openai-Intent": "conversation-edits",
},
});
expect(options.apiKey).toBeNull();
expect(options.defaultHeaders?.["Authorization"]).toBe(`Bearer ${token}`);
});
it("includes Copilot static headers from model.headers", () => {
const model = makeCopilotClaudeModel();
const options = buildAnthropicClientOptions({
model,
apiKey: "ghu_test",
interleavedThinking: false,
dynamicHeaders: {},
});
expect(options.defaultHeaders?.["User-Agent"]).toContain("GitHubCopilotChat");
expect(options.defaultHeaders?.["Copilot-Integration-Id"]).toBe("vscode-chat");
});
it("includes interleaved-thinking beta header when enabled", () => {
const model = makeCopilotClaudeModel();
const options = buildAnthropicClientOptions({
model,
apiKey: "ghu_test",
interleavedThinking: true,
dynamicHeaders: {},
});
const beta = options.defaultHeaders?.["anthropic-beta"];
expect(beta).toBeDefined();
expect(beta).toContain("interleaved-thinking-2025-05-14");
});
it("does not include interleaved-thinking beta when disabled", () => {
const model = makeCopilotClaudeModel();
const options = buildAnthropicClientOptions({
model,
apiKey: "ghu_test",
interleavedThinking: false,
dynamicHeaders: {},
});
const beta = options.defaultHeaders?.["anthropic-beta"];
if (beta) {
expect(beta).not.toContain("interleaved-thinking-2025-05-14");
}
});
it("does not include fine-grained-tool-streaming beta for Copilot", () => {
const model = makeCopilotClaudeModel();
const options = buildAnthropicClientOptions({
model,
apiKey: "ghu_test",
interleavedThinking: true,
dynamicHeaders: {},
});
const beta = options.defaultHeaders?.["anthropic-beta"];
if (beta) {
expect(beta).not.toContain("fine-grained-tool-streaming");
}
});
it("does not set isOAuthToken for Copilot models", () => {
const model = makeCopilotClaudeModel();
const result = buildAnthropicClientOptions({
model,
apiKey: "ghu_test",
interleavedThinking: true,
dynamicHeaders: {},
});
expect(result.isOAuthToken).toBe(false);
});
});

View file

@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import { getModel } from "../src/models.js";
describe("Copilot Claude model routing", () => {
it("routes claude-sonnet-4 via anthropic-messages API", () => {
const model = getModel("github-copilot", "claude-sonnet-4");
expect(model).toBeDefined();
expect(model.api).toBe("anthropic-messages");
});
it("routes claude-sonnet-4.5 via anthropic-messages API", () => {
const model = getModel("github-copilot", "claude-sonnet-4.5");
expect(model).toBeDefined();
expect(model.api).toBe("anthropic-messages");
});
it("routes claude-haiku-4.5 via anthropic-messages API", () => {
const model = getModel("github-copilot", "claude-haiku-4.5");
expect(model).toBeDefined();
expect(model.api).toBe("anthropic-messages");
});
it("routes claude-opus-4.5 via anthropic-messages API", () => {
const model = getModel("github-copilot", "claude-opus-4.5");
expect(model).toBeDefined();
expect(model.api).toBe("anthropic-messages");
});
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();
});
it("preserves static Copilot headers on Claude models", () => {
const model = getModel("github-copilot", "claude-sonnet-4");
expect(model.headers).toBeDefined();
expect(model.headers!["User-Agent"]).toContain("GitHubCopilotChat");
expect(model.headers!["Copilot-Integration-Id"]).toBe("vscode-chat");
});
it("keeps non-Claude Copilot models on their existing APIs", () => {
// Spot-check: gpt-4o should stay on openai-completions
const gpt4o = getModel("github-copilot", "gpt-4o");
expect(gpt4o).toBeDefined();
expect(gpt4o.api).toBe("openai-completions");
// Spot-check: gpt-5 should stay on openai-responses
const gpt5 = getModel("github-copilot", "gpt-5");
expect(gpt5).toBeDefined();
expect(gpt5.api).toBe("openai-responses");
});
});

View file

@ -0,0 +1,142 @@
import { describe, expect, it } from "vitest";
import {
buildCopilotDynamicHeaders,
hasCopilotVisionInput,
inferCopilotInitiator,
} from "../src/providers/github-copilot-headers.js";
import type { Message } from "../src/types.js";
describe("inferCopilotInitiator", () => {
it("returns 'user' when there are no messages", () => {
expect(inferCopilotInitiator([])).toBe("user");
});
it("returns 'agent' when last message role is assistant", () => {
const messages: Message[] = [
{ role: "user", content: "hello", timestamp: Date.now() },
{
role: "assistant",
content: [{ type: "text", text: "hi" }],
api: "openai-completions",
provider: "github-copilot",
model: "gpt-4o",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
},
];
expect(inferCopilotInitiator(messages)).toBe("agent");
});
it("returns 'agent' when last message is toolResult", () => {
const messages: Message[] = [
{
role: "toolResult",
toolCallId: "tc_1",
toolName: "bash",
content: [{ type: "text", text: "output" }],
isError: false,
timestamp: Date.now(),
},
];
expect(inferCopilotInitiator(messages)).toBe("agent");
});
it("returns 'user' when last message is user with text content", () => {
const messages: Message[] = [{ role: "user", content: "what time is it?", timestamp: Date.now() }];
expect(inferCopilotInitiator(messages)).toBe("user");
});
it("returns 'user' when last message is user with text content blocks", () => {
const messages: Message[] = [
{
role: "user",
content: [{ type: "text", text: "explain this image" }],
timestamp: Date.now(),
},
];
expect(inferCopilotInitiator(messages)).toBe("user");
});
it("returns 'agent' when last message is user but last content block is tool_result (Anthropic conversion)", () => {
// After Anthropic conversion, tool results become user messages with tool_result blocks
const messages: unknown[] = [
{
role: "user",
content: [{ type: "tool_result", tool_use_id: "tc_1", content: "done" }],
},
];
expect(inferCopilotInitiator(messages)).toBe("agent");
});
});
describe("hasCopilotVisionInput", () => {
it("returns false when no messages have images", () => {
const messages: Message[] = [{ role: "user", content: "hello", timestamp: Date.now() }];
expect(hasCopilotVisionInput(messages)).toBe(false);
});
it("returns true when a user message has image content", () => {
const messages: Message[] = [
{
role: "user",
content: [
{ type: "text", text: "describe this" },
{ type: "image", data: "abc123", mimeType: "image/png" },
],
timestamp: Date.now(),
},
];
expect(hasCopilotVisionInput(messages)).toBe(true);
});
it("returns true when a toolResult has image content", () => {
const messages: Message[] = [
{
role: "toolResult",
toolCallId: "tc_1",
toolName: "screenshot",
content: [{ type: "image", data: "def456", mimeType: "image/jpeg" }],
isError: false,
timestamp: Date.now(),
},
];
expect(hasCopilotVisionInput(messages)).toBe(true);
});
it("returns false when user message has only text content", () => {
const messages: Message[] = [
{
role: "user",
content: [{ type: "text", text: "just text" }],
timestamp: Date.now(),
},
];
expect(hasCopilotVisionInput(messages)).toBe(false);
});
});
describe("buildCopilotDynamicHeaders", () => {
it("sets X-Initiator and Openai-Intent", () => {
const headers = buildCopilotDynamicHeaders({ messages: [], hasImages: false });
expect(headers["X-Initiator"]).toBe("user");
expect(headers["Openai-Intent"]).toBe("conversation-edits");
});
it("sets Copilot-Vision-Request when hasImages is true", () => {
const headers = buildCopilotDynamicHeaders({ messages: [], hasImages: true });
expect(headers["Copilot-Vision-Request"]).toBe("true");
});
it("does not set Copilot-Vision-Request when hasImages is false", () => {
const headers = buildCopilotDynamicHeaders({ messages: [], hasImages: false });
expect(headers["Copilot-Vision-Request"]).toBeUndefined();
});
});

View file

@ -0,0 +1,169 @@
import { describe, expect, it } from "vitest";
import { transformMessages } from "../src/providers/transform-messages.js";
import type { AssistantMessage, Message, Model, ToolCall } from "../src/types.js";
// Normalize function matching what anthropic.ts uses
function anthropicNormalizeToolCallId(id: string): string {
return id.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64);
}
function makeCopilotClaudeModel(): Model<"anthropic-messages"> {
return {
id: "claude-sonnet-4",
name: "Claude Sonnet 4",
api: "anthropic-messages",
provider: "github-copilot",
baseUrl: "https://api.individual.githubcopilot.com",
reasoning: true,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 16000,
};
}
describe("OpenAI to Anthropic session migration for Copilot Claude", () => {
it("converts thinking blocks to plain text when source model differs", () => {
const model = makeCopilotClaudeModel();
const messages: Message[] = [
{ role: "user", content: "hello", timestamp: Date.now() },
{
role: "assistant",
content: [
{
type: "thinking",
thinking: "Let me think about this...",
thinkingSignature: "reasoning_content",
},
{ type: "text", text: "Hi there!" },
],
api: "openai-completions",
provider: "github-copilot",
model: "gpt-4o",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
},
];
const result = transformMessages(messages, model, anthropicNormalizeToolCallId);
const assistantMsg = result.find((m) => m.role === "assistant") as AssistantMessage;
// Thinking block should be converted to text since models differ
const textBlocks = assistantMsg.content.filter((b) => b.type === "text");
const thinkingBlocks = assistantMsg.content.filter((b) => b.type === "thinking");
expect(thinkingBlocks).toHaveLength(0);
expect(textBlocks.length).toBeGreaterThanOrEqual(2);
});
it("normalizes tool call IDs with disallowed characters", () => {
const model = makeCopilotClaudeModel();
const toolCallId = "call_abc+123/def=456|some_very_long_id_that_exceeds_limits";
const messages: Message[] = [
{ role: "user", content: "run a command", timestamp: Date.now() },
{
role: "assistant",
content: [
{
type: "toolCall",
id: toolCallId,
name: "bash",
arguments: { command: "ls" },
},
],
api: "openai-completions",
provider: "github-copilot",
model: "gpt-4o",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
timestamp: Date.now(),
},
{
role: "toolResult",
toolCallId,
toolName: "bash",
content: [{ type: "text", text: "file1.txt\nfile2.txt" }],
isError: false,
timestamp: Date.now(),
},
];
const result = transformMessages(messages, model, anthropicNormalizeToolCallId);
// Get the normalized tool call ID
const assistantMsg = result.find((m) => m.role === "assistant") as AssistantMessage;
const toolCall = assistantMsg.content.find((b) => b.type === "toolCall") as ToolCall;
const normalizedId = toolCall.id;
// Verify it only has allowed characters and is <= 64 chars
expect(normalizedId).toMatch(/^[a-zA-Z0-9_-]+$/);
expect(normalizedId.length).toBeLessThanOrEqual(64);
// Verify tool result references the normalized ID
const toolResultMsg = result.find((m) => m.role === "toolResult");
expect(toolResultMsg).toBeDefined();
if (toolResultMsg && toolResultMsg.role === "toolResult") {
expect(toolResultMsg.toolCallId).toBe(normalizedId);
}
});
it("removes thoughtSignature from tool calls when migrating between models", () => {
const model = makeCopilotClaudeModel();
const messages: Message[] = [
{ role: "user", content: "run a command", timestamp: Date.now() },
{
role: "assistant",
content: [
{
type: "toolCall",
id: "call_123",
name: "bash",
arguments: { command: "ls" },
thoughtSignature: JSON.stringify({ type: "reasoning.encrypted", id: "call_123", data: "encrypted" }),
},
],
api: "openai-responses",
provider: "github-copilot",
model: "gpt-5",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
timestamp: Date.now(),
},
{
role: "toolResult",
toolCallId: "call_123",
toolName: "bash",
content: [{ type: "text", text: "output" }],
isError: false,
timestamp: Date.now(),
},
];
const result = transformMessages(messages, model, anthropicNormalizeToolCallId);
const assistantMsg = result.find((m) => m.role === "assistant") as AssistantMessage;
const toolCall = assistantMsg.content.find((b) => b.type === "toolCall") as ToolCall;
expect(toolCall.thoughtSignature).toBeUndefined();
});
});