mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 07:04:45 +00:00
refactor(ai): fix inconsistencies, trim ai code+replace tests, remove unnceccessary tool_result check
This commit is contained in:
parent
0a132a30a1
commit
2419412483
11 changed files with 214 additions and 523 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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> = {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
103
packages/ai/test/github-copilot-anthropic.test.ts
Normal file
103
packages/ai/test/github-copilot-anthropic.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[] = [
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue