mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 06:04:40 +00:00
test(ai): cover copilot claude messages routing + headers
This commit is contained in:
parent
7eb969ddb1
commit
cf1353b8e7
4 changed files with 477 additions and 0 deletions
114
packages/ai/test/github-copilot-anthropic-auth.test.ts
Normal file
114
packages/ai/test/github-copilot-anthropic-auth.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
142
packages/ai/test/github-copilot-headers.test.ts
Normal file
142
packages/ai/test/github-copilot-headers.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue