From cf1353b8e74c85899e91bf9ca4f14c3cb7b68fdc Mon Sep 17 00:00:00 2001 From: Nate Smyth Date: Fri, 6 Feb 2026 05:07:07 -0500 Subject: [PATCH] test(ai): cover copilot claude messages routing + headers --- .../github-copilot-anthropic-auth.test.ts | 114 ++++++++++++ ...ub-copilot-claude-messages-routing.test.ts | 52 ++++++ .../ai/test/github-copilot-headers.test.ts | 142 +++++++++++++++ ...ssages-copilot-openai-to-anthropic.test.ts | 169 ++++++++++++++++++ 4 files changed, 477 insertions(+) create mode 100644 packages/ai/test/github-copilot-anthropic-auth.test.ts create mode 100644 packages/ai/test/github-copilot-claude-messages-routing.test.ts create mode 100644 packages/ai/test/github-copilot-headers.test.ts create mode 100644 packages/ai/test/transform-messages-copilot-openai-to-anthropic.test.ts diff --git a/packages/ai/test/github-copilot-anthropic-auth.test.ts b/packages/ai/test/github-copilot-anthropic-auth.test.ts new file mode 100644 index 00000000..3fe3e0cc --- /dev/null +++ b/packages/ai/test/github-copilot-anthropic-auth.test.ts @@ -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); + }); +}); diff --git a/packages/ai/test/github-copilot-claude-messages-routing.test.ts b/packages/ai/test/github-copilot-claude-messages-routing.test.ts new file mode 100644 index 00000000..8e2d942a --- /dev/null +++ b/packages/ai/test/github-copilot-claude-messages-routing.test.ts @@ -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"); + }); +}); diff --git a/packages/ai/test/github-copilot-headers.test.ts b/packages/ai/test/github-copilot-headers.test.ts new file mode 100644 index 00000000..d0f11a0a --- /dev/null +++ b/packages/ai/test/github-copilot-headers.test.ts @@ -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(); + }); +}); diff --git a/packages/ai/test/transform-messages-copilot-openai-to-anthropic.test.ts b/packages/ai/test/transform-messages-copilot-openai-to-anthropic.test.ts new file mode 100644 index 00000000..505a8174 --- /dev/null +++ b/packages/ai/test/transform-messages-copilot-openai-to-anthropic.test.ts @@ -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(); + }); +});