diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index af2f7244..22caf51d 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -483,102 +483,6 @@ function isOAuthToken(apiKey: string): boolean { return apiKey.includes("sk-ant-oat"); } -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"); - } - - const oauthToken = isOAuthToken(apiKey); - if (oauthToken) { - const defaultHeaders = mergeHeaders( - { - accept: "application/json", - "anthropic-dangerous-direct-browser-access": "true", - "anthropic-beta": `claude-code-20250219,oauth-2025-04-20,${betaFeatures.join(",")}`, - "user-agent": `claude-cli/${claudeCodeVersion} (external, cli)`, - "x-app": "cli", - }, - model.headers, - optionsHeaders, - ); - - return { - apiKey: null, - authToken: apiKey, - baseURL: model.baseUrl, - defaultHeaders, - dangerouslyAllowBrowser: true, - isOAuthToken: true, - }; - } - - const defaultHeaders = mergeHeaders( - { - accept: "application/json", - "anthropic-dangerous-direct-browser-access": "true", - "anthropic-beta": betaFeatures.join(","), - }, - model.headers, - optionsHeaders, - ); - - return { - apiKey, - baseURL: model.baseUrl, - defaultHeaders, - dangerouslyAllowBrowser: true, - isOAuthToken: false, - }; -} - function createClient( model: Model<"anthropic-messages">, apiKey: string, @@ -586,23 +490,78 @@ function createClient( optionsHeaders?: Record, dynamicHeaders?: Record, ): { client: Anthropic; isOAuthToken: boolean } { - const config = buildAnthropicClientOptions({ - model, - apiKey, - interleavedThinking, - dynamicHeaders, - optionsHeaders, - }); + // Copilot: Bearer auth, selective betas (no fine-grained-tool-streaming) + if (model.provider === "github-copilot") { + const betaFeatures: string[] = []; + if (interleavedThinking) { + betaFeatures.push("interleaved-thinking-2025-05-14"); + } + const client = new Anthropic({ + apiKey: null, + authToken: apiKey, + baseURL: model.baseUrl, + dangerouslyAllowBrowser: true, + defaultHeaders: mergeHeaders( + { + accept: "application/json", + "anthropic-dangerous-direct-browser-access": "true", + ...(betaFeatures.length > 0 ? { "anthropic-beta": betaFeatures.join(",") } : {}), + }, + model.headers, + dynamicHeaders, + optionsHeaders, + ), + }); + + return { client, isOAuthToken: false }; + } + + const betaFeatures = ["fine-grained-tool-streaming-2025-05-14"]; + if (interleavedThinking) { + betaFeatures.push("interleaved-thinking-2025-05-14"); + } + + // OAuth: Bearer auth, Claude Code identity headers + if (isOAuthToken(apiKey)) { + const client = new Anthropic({ + apiKey: null, + authToken: apiKey, + baseURL: model.baseUrl, + dangerouslyAllowBrowser: true, + defaultHeaders: mergeHeaders( + { + accept: "application/json", + "anthropic-dangerous-direct-browser-access": "true", + "anthropic-beta": `claude-code-20250219,oauth-2025-04-20,${betaFeatures.join(",")}`, + "user-agent": `claude-cli/${claudeCodeVersion} (external, cli)`, + "x-app": "cli", + }, + model.headers, + optionsHeaders, + ), + }); + + return { client, isOAuthToken: true }; + } + + // API key auth const client = new Anthropic({ - apiKey: config.apiKey, - ...(config.authToken ? { authToken: config.authToken } : {}), - baseURL: config.baseURL, - defaultHeaders: config.defaultHeaders, - dangerouslyAllowBrowser: config.dangerouslyAllowBrowser, + apiKey, + baseURL: model.baseUrl, + dangerouslyAllowBrowser: true, + defaultHeaders: mergeHeaders( + { + accept: "application/json", + "anthropic-dangerous-direct-browser-access": "true", + "anthropic-beta": betaFeatures.join(","), + }, + model.headers, + optionsHeaders, + ), }); - return { client, isOAuthToken: config.isOAuthToken }; + return { client, isOAuthToken: false }; } function buildParams( diff --git a/packages/ai/src/providers/github-copilot-headers.ts b/packages/ai/src/providers/github-copilot-headers.ts index 38bdd5e6..4f01a9d2 100644 --- a/packages/ai/src/providers/github-copilot-headers.ts +++ b/packages/ai/src/providers/github-copilot-headers.ts @@ -1,31 +1,13 @@ 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"; +// Copilot expects X-Initiator to indicate whether the request is user-initiated +// or agent-initiated (e.g. follow-up after assistant/tool messages). +export function inferCopilotInitiator(messages: Message[]): "user" | "agent" { + const last = messages[messages.length - 1]; + return last && last.role !== "user" ? "agent" : "user"; } -/** Check whether any message in the conversation contains image content. */ +// Copilot requires Copilot-Vision-Request header when sending images export function hasCopilotVisionInput(messages: Message[]): boolean { return messages.some((msg) => { if (msg.role === "user" && Array.isArray(msg.content)) { @@ -38,12 +20,8 @@ export function hasCopilotVisionInput(messages: Message[]): boolean { }); } -/** - * 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[]; + messages: Message[]; hasImages: boolean; }): Record { const headers: Record = { diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index 33b9778a..7150af2b 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -508,10 +508,6 @@ export function convertMessages( } if (model.provider === "openai") return id.length > 40 ? id.slice(0, 40) : id; - // Copilot Claude models route to Claude backend which requires Anthropic ID format - if (model.provider === "github-copilot" && model.id.toLowerCase().includes("claude")) { - return id.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64); - } return id; }; diff --git a/packages/ai/test/context-overflow.test.ts b/packages/ai/test/context-overflow.test.ts index 8cb00486..3cf5da1e 100644 --- a/packages/ai/test/context-overflow.test.ts +++ b/packages/ai/test/context-overflow.test.ts @@ -153,7 +153,7 @@ describe("Context overflow error handling", () => { logResult(result); expect(result.stopReason).toBe("error"); - expect(result.errorMessage).toMatch(/exceeds the limit of \d+/i); + expect(result.errorMessage).toMatch(/exceeds the limit of \d+|input is too long/i); expect(isContextOverflow(result.response, model.contextWindow)).toBe(true); }, 120000, diff --git a/packages/ai/test/github-copilot-anthropic-auth.test.ts b/packages/ai/test/github-copilot-anthropic-auth.test.ts deleted file mode 100644 index 91d75abd..00000000 --- a/packages/ai/test/github-copilot-anthropic-auth.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -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-anthropic.test.ts b/packages/ai/test/github-copilot-anthropic.test.ts new file mode 100644 index 00000000..b0000beb --- /dev/null +++ b/packages/ai/test/github-copilot-anthropic.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, vi } from "vitest"; +import { getModel } from "../src/models.js"; +import type { Context } from "../src/types.js"; + +const mockState = vi.hoisted(() => ({ + constructorOpts: undefined as Record | undefined, + streamParams: undefined as Record | undefined, +})); + +vi.mock("@anthropic-ai/sdk", () => { + const fakeStream = { + async *[Symbol.asyncIterator]() { + yield { + type: "message_start", + message: { + usage: { input_tokens: 10, output_tokens: 0 }, + }, + }; + yield { + type: "message_delta", + delta: { stop_reason: "end_turn" }, + usage: { output_tokens: 5 }, + }; + }, + finalMessage: async () => ({ + usage: { input_tokens: 10, output_tokens: 5, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }, + }), + }; + + class FakeAnthropic { + constructor(opts: Record) { + mockState.constructorOpts = opts; + } + messages = { + stream: (params: Record) => { + mockState.streamParams = params; + return fakeStream; + }, + }; + } + + return { default: FakeAnthropic }; +}); + +describe("Copilot Claude via Anthropic Messages", () => { + const context: Context = { + systemPrompt: "You are a helpful assistant.", + messages: [{ role: "user", content: "Hello", timestamp: Date.now() }], + }; + + it("uses Bearer auth, Copilot headers, and valid Anthropic Messages payload", async () => { + const model = getModel("github-copilot", "claude-sonnet-4"); + expect(model.api).toBe("anthropic-messages"); + + const { streamAnthropic } = await import("../src/providers/anthropic.js"); + const s = streamAnthropic(model, context, { apiKey: "tid_copilot_session_test_token" }); + for await (const event of s) { + if (event.type === "error") break; + } + + const opts = mockState.constructorOpts!; + expect(opts).toBeDefined(); + + // Auth: apiKey null, authToken for Bearer + expect(opts.apiKey).toBeNull(); + expect(opts.authToken).toBe("tid_copilot_session_test_token"); + const headers = opts.defaultHeaders as Record; + + // Copilot static headers from model.headers + expect(headers["User-Agent"]).toContain("GitHubCopilotChat"); + expect(headers["Copilot-Integration-Id"]).toBe("vscode-chat"); + + // Dynamic headers + expect(headers["X-Initiator"]).toBe("user"); + expect(headers["Openai-Intent"]).toBe("conversation-edits"); + + // No fine-grained-tool-streaming (Copilot doesn't support it) + const beta = headers["anthropic-beta"] ?? ""; + expect(beta).not.toContain("fine-grained-tool-streaming"); + + // Payload is valid Anthropic Messages format + const params = mockState.streamParams!; + expect(params.model).toBe("claude-sonnet-4"); + expect(params.stream).toBe(true); + expect(params.max_tokens).toBeGreaterThan(0); + expect(Array.isArray(params.messages)).toBe(true); + }); + + it("includes interleaved-thinking beta when reasoning is enabled", async () => { + const model = getModel("github-copilot", "claude-sonnet-4"); + const { streamAnthropic } = await import("../src/providers/anthropic.js"); + const s = streamAnthropic(model, context, { + apiKey: "tid_copilot_session_test_token", + interleavedThinking: true, + }); + for await (const event of s) { + if (event.type === "error") break; + } + + const headers = mockState.constructorOpts!.defaultHeaders as Record; + expect(headers["anthropic-beta"]).toContain("interleaved-thinking-2025-05-14"); + }); +}); diff --git a/packages/ai/test/github-copilot-claude-messages-routing.test.ts b/packages/ai/test/github-copilot-claude-messages-routing.test.ts deleted file mode 100644 index 351e3552..00000000 --- a/packages/ai/test/github-copilot-claude-messages-routing.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -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("compat" in sonnet).toBe(false); - }); - - 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 deleted file mode 100644 index 7a74c78f..00000000 --- a/packages/ai/test/github-copilot-headers.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -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"); - }); - - 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", () => { - 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/stream.test.ts b/packages/ai/test/stream.test.ts index 3a7ba0fa..b29c6972 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -988,6 +988,34 @@ describe("Generate E2E Tests", () => { }); }); + describe("GitHub Copilot Provider (claude-sonnet-4 via Anthropic Messages)", () => { + const llm = getModel("github-copilot", "claude-sonnet-4"); + + it.skipIf(!githubCopilotToken)("should complete basic text generation", { retry: 3 }, async () => { + await basicTextGeneration(llm, { apiKey: githubCopilotToken }); + }); + + it.skipIf(!githubCopilotToken)("should handle tool calling", { retry: 3 }, async () => { + await handleToolCall(llm, { apiKey: githubCopilotToken }); + }); + + it.skipIf(!githubCopilotToken)("should handle streaming", { retry: 3 }, async () => { + await handleStreaming(llm, { apiKey: githubCopilotToken }); + }); + + it.skipIf(!githubCopilotToken)("should handle thinking", { retry: 2 }, async () => { + await handleThinking(llm, { apiKey: githubCopilotToken, thinkingEnabled: true }); + }); + + it.skipIf(!githubCopilotToken)("should handle multi-turn with thinking and tools", { retry: 3 }, async () => { + await multiTurn(llm, { apiKey: githubCopilotToken, thinkingEnabled: true }); + }); + + it.skipIf(!githubCopilotToken)("should handle image input", { retry: 3 }, async () => { + await handleImage(llm, { apiKey: githubCopilotToken }); + }); + }); + describe("Google Gemini CLI Provider (gemini-2.5-flash)", () => { const llm = getModel("google-gemini-cli", "gemini-2.5-flash"); diff --git a/packages/ai/test/tokens.test.ts b/packages/ai/test/tokens.test.ts index 65106fca..2c3ea6eb 100644 --- a/packages/ai/test/tokens.test.ts +++ b/packages/ai/test/tokens.test.ts @@ -74,8 +74,8 @@ async function testTokensOnAbort(llm: Model, options: St expect(msg.usage.input).toBeGreaterThan(0); expect(msg.usage.output).toBeGreaterThan(0); - // Antigravity Gemini and Claude models report token usage, but no cost - if (llm.provider !== "google-antigravity") { + // Some providers (Antigravity, Copilot) have zero cost rates + if (llm.cost.input > 0) { expect(msg.usage.cost.input).toBeGreaterThan(0); expect(msg.usage.cost.total).toBeGreaterThan(0); } 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 index 505a8174..2602b61a 100644 --- a/packages/ai/test/transform-messages-copilot-openai-to-anthropic.test.ts +++ b/packages/ai/test/transform-messages-copilot-openai-to-anthropic.test.ts @@ -3,7 +3,11 @@ 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 { +function anthropicNormalizeToolCallId( + id: string, + _model: Model<"anthropic-messages">, + _source: AssistantMessage, +): string { return id.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64); } @@ -63,64 +67,6 @@ describe("OpenAI to Anthropic session migration for Copilot Claude", () => { 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[] = [