refactor(ai): fix inconsistencies, trim ai code+replace tests, remove unnceccessary tool_result check

This commit is contained in:
Nate Smyth 2026-02-06 16:52:02 -05:00 committed by Mario Zechner
parent 0a132a30a1
commit 2419412483
11 changed files with 214 additions and 523 deletions

View file

@ -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<string, string>;
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"];
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<string, string>,
dynamicHeaders?: Record<string, string>,
): { 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(

View file

@ -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<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";
// 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<string, string> {
const headers: Record<string, string> = {

View file

@ -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;
};

View file

@ -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,

View file

@ -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);
});
});

View file

@ -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<string, unknown> | undefined,
streamParams: undefined as Record<string, unknown> | 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<string, unknown>) {
mockState.constructorOpts = opts;
}
messages = {
stream: (params: Record<string, unknown>) => {
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<string, string>;
// 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<string, string>;
expect(headers["anthropic-beta"]).toContain("interleaved-thinking-2025-05-14");
});
});

View file

@ -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");
});
});

View file

@ -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();
});
});

View file

@ -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");

View file

@ -74,8 +74,8 @@ async function testTokensOnAbort<TApi extends Api>(llm: Model<TApi>, 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);
}

View file

@ -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[] = [