mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-22 00:00:27 +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");
|
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(
|
function createClient(
|
||||||
model: Model<"anthropic-messages">,
|
model: Model<"anthropic-messages">,
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
|
|
@ -586,23 +490,78 @@ function createClient(
|
||||||
optionsHeaders?: Record<string, string>,
|
optionsHeaders?: Record<string, string>,
|
||||||
dynamicHeaders?: Record<string, string>,
|
dynamicHeaders?: Record<string, string>,
|
||||||
): { client: Anthropic; isOAuthToken: boolean } {
|
): { client: Anthropic; isOAuthToken: boolean } {
|
||||||
const config = buildAnthropicClientOptions({
|
// Copilot: Bearer auth, selective betas (no fine-grained-tool-streaming)
|
||||||
model,
|
if (model.provider === "github-copilot") {
|
||||||
apiKey,
|
const betaFeatures: string[] = [];
|
||||||
interleavedThinking,
|
if (interleavedThinking) {
|
||||||
dynamicHeaders,
|
betaFeatures.push("interleaved-thinking-2025-05-14");
|
||||||
optionsHeaders,
|
}
|
||||||
});
|
|
||||||
|
|
||||||
|
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({
|
const client = new Anthropic({
|
||||||
apiKey: config.apiKey,
|
apiKey,
|
||||||
...(config.authToken ? { authToken: config.authToken } : {}),
|
baseURL: model.baseUrl,
|
||||||
baseURL: config.baseURL,
|
dangerouslyAllowBrowser: true,
|
||||||
defaultHeaders: config.defaultHeaders,
|
defaultHeaders: mergeHeaders(
|
||||||
dangerouslyAllowBrowser: config.dangerouslyAllowBrowser,
|
{
|
||||||
|
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(
|
function buildParams(
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,13 @@
|
||||||
import type { Message } from "../types.js";
|
import type { Message } from "../types.js";
|
||||||
|
|
||||||
/**
|
// Copilot expects X-Initiator to indicate whether the request is user-initiated
|
||||||
* Infer whether the current request to Copilot is user-initiated or agent-initiated.
|
// or agent-initiated (e.g. follow-up after assistant/tool messages).
|
||||||
* Accepts `unknown[]` because providers may pass pre-converted message shapes.
|
export function inferCopilotInitiator(messages: Message[]): "user" | "agent" {
|
||||||
*/
|
const last = messages[messages.length - 1];
|
||||||
export function inferCopilotInitiator(messages: unknown[]): "user" | "agent" {
|
return last && last.role !== "user" ? "agent" : "user";
|
||||||
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";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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 {
|
export function hasCopilotVisionInput(messages: Message[]): boolean {
|
||||||
return messages.some((msg) => {
|
return messages.some((msg) => {
|
||||||
if (msg.role === "user" && Array.isArray(msg.content)) {
|
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: {
|
export function buildCopilotDynamicHeaders(params: {
|
||||||
messages: unknown[];
|
messages: Message[];
|
||||||
hasImages: boolean;
|
hasImages: boolean;
|
||||||
}): Record<string, string> {
|
}): Record<string, string> {
|
||||||
const headers: 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;
|
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;
|
return id;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -153,7 +153,7 @@ describe("Context overflow error handling", () => {
|
||||||
logResult(result);
|
logResult(result);
|
||||||
|
|
||||||
expect(result.stopReason).toBe("error");
|
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);
|
expect(isContextOverflow(result.response, model.contextWindow)).toBe(true);
|
||||||
},
|
},
|
||||||
120000,
|
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)", () => {
|
describe("Google Gemini CLI Provider (gemini-2.5-flash)", () => {
|
||||||
const llm = getModel("google-gemini-cli", "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.input).toBeGreaterThan(0);
|
||||||
expect(msg.usage.output).toBeGreaterThan(0);
|
expect(msg.usage.output).toBeGreaterThan(0);
|
||||||
|
|
||||||
// Antigravity Gemini and Claude models report token usage, but no cost
|
// Some providers (Antigravity, Copilot) have zero cost rates
|
||||||
if (llm.provider !== "google-antigravity") {
|
if (llm.cost.input > 0) {
|
||||||
expect(msg.usage.cost.input).toBeGreaterThan(0);
|
expect(msg.usage.cost.input).toBeGreaterThan(0);
|
||||||
expect(msg.usage.cost.total).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";
|
import type { AssistantMessage, Message, Model, ToolCall } from "../src/types.js";
|
||||||
|
|
||||||
// Normalize function matching what anthropic.ts uses
|
// 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);
|
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);
|
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", () => {
|
it("removes thoughtSignature from tool calls when migrating between models", () => {
|
||||||
const model = makeCopilotClaudeModel();
|
const model = makeCopilotClaudeModel();
|
||||||
const messages: Message[] = [
|
const messages: Message[] = [
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue