copilot-api / tests /anthropic-request.test.ts
imspsycho's picture
Initial upload from Google Colab
98c9143 verified
Raw
History Blame Contribute Delete
24.8 kB
import { describe, test, expect } from "bun:test"
import { z } from "zod"
import type { AnthropicMessagesPayload } from "~/routes/messages/anthropic-types"
import { COMPACT_REQUEST } from "../src/lib/compact"
import {
RICH_TOOL_RESULT_MOVED_TEXT,
translateToOpenAI,
} from "../src/routes/messages/non-stream-translation"
import { getCompactType } from "../src/routes/messages/preprocess"
// Zod schema for a single message in the chat completion request.
const messageSchema = z.object({
role: z.enum([
"system",
"user",
"assistant",
"tool",
"function",
"developer",
]),
content: z.union([z.string(), z.object({}), z.array(z.any())]),
name: z.string().optional(),
tool_calls: z.array(z.any()).optional(),
tool_call_id: z.string().optional(),
})
// Zod schema for the entire chat completion request payload.
// This is derived from the openapi.documented.yml specification.
const chatCompletionRequestSchema = z.object({
messages: z.array(messageSchema).min(1, "Messages array cannot be empty."),
model: z.string(),
frequency_penalty: z.number().min(-2).max(2).optional().nullable(),
logit_bias: z.record(z.string(), z.number()).optional().nullable(),
logprobs: z.boolean().optional().nullable(),
top_logprobs: z.number().int().min(0).max(20).optional().nullable(),
max_tokens: z.number().int().optional().nullable(),
n: z.number().int().min(1).max(128).optional().nullable(),
presence_penalty: z.number().min(-2).max(2).optional().nullable(),
response_format: z
.object({
type: z.enum(["text", "json_object", "json_schema"]),
json_schema: z.object({}).optional(),
})
.optional(),
seed: z.number().int().optional().nullable(),
stop: z
.union([z.string(), z.array(z.string())])
.optional()
.nullable(),
stream: z.boolean().optional().nullable(),
temperature: z.number().min(0).max(2).optional().nullable(),
top_p: z.number().min(0).max(1).optional().nullable(),
tools: z.array(z.any()).optional(),
tool_choice: z.union([z.string(), z.object({})]).optional(),
user: z.string().optional(),
})
/**
* Validates if a request payload conforms to the OpenAI Chat Completion v1 shape using Zod.
* @param payload The request payload to validate.
* @returns True if the payload is valid, false otherwise.
*/
function isValidChatCompletionRequest(payload: unknown): boolean {
const result = chatCompletionRequestSchema.safeParse(payload)
return result.success
}
function getTextParts(
content: string | Array<{ type: string; text?: string }> | null | undefined,
): Array<string> {
if (!Array.isArray(content)) {
return typeof content === "string" ? [content] : []
}
return content.flatMap((part) =>
part.type === "text" && typeof part.text === "string" ? [part.text] : [],
)
}
describe("Anthropic to OpenAI translation logic", () => {
test("should translate minimal Anthropic payload to valid OpenAI payload", () => {
const anthropicPayload: AnthropicMessagesPayload = {
model: "gpt-4o",
messages: [{ role: "user", content: "Hello!" }],
max_tokens: 0,
}
const openAIPayload = translateToOpenAI(anthropicPayload)
expect(isValidChatCompletionRequest(openAIPayload)).toBe(true)
})
test("should translate comprehensive Anthropic payload to valid OpenAI payload", () => {
const anthropicPayload: AnthropicMessagesPayload = {
model: "gpt-4o",
system: "You are a helpful assistant.",
messages: [
{ role: "user", content: "What is the weather like in Boston?" },
{
role: "assistant",
content: "The weather in Boston is sunny and 75°F.",
},
],
temperature: 0.7,
max_tokens: 150,
top_p: 1,
stream: false,
metadata: { user_id: "user-123" },
tools: [
{
name: "getWeather",
description: "Gets weather info",
input_schema: { location: { type: "string" } },
},
],
tool_choice: { type: "auto" },
}
const openAIPayload = translateToOpenAI(anthropicPayload)
expect(isValidChatCompletionRequest(openAIPayload)).toBe(true)
})
test("should handle missing fields gracefully", () => {
const anthropicPayload: AnthropicMessagesPayload = {
model: "gpt-4o",
messages: [{ role: "user", content: "Hello!" }],
max_tokens: 0,
}
const openAIPayload = translateToOpenAI(anthropicPayload)
expect(isValidChatCompletionRequest(openAIPayload)).toBe(true)
})
test("should handle invalid types in Anthropic payload", () => {
const anthropicPayload = {
model: "gpt-4o",
messages: [{ role: "user", content: "Hello!" }],
temperature: "hot", // Should be a number
}
// @ts-expect-error intended to be invalid
const openAIPayload = translateToOpenAI(anthropicPayload)
// Should fail validation
expect(isValidChatCompletionRequest(openAIPayload)).toBe(false)
})
test("should handle thinking blocks in assistant messages", () => {
const anthropicPayload: AnthropicMessagesPayload = {
model: "claude-3-5-sonnet-20241022",
messages: [
{ role: "user", content: "What is 2+2?" },
{
role: "assistant",
content: [
{
type: "thinking",
thinking: "Let me think about this simple math problem...",
signature: "abc123",
},
{ type: "text", text: "2+2 equals 4." },
],
},
],
max_tokens: 100,
}
const openAIPayload = translateToOpenAI(anthropicPayload)
expect(isValidChatCompletionRequest(openAIPayload)).toBe(true)
// Check that thinking content is combined with text content
const assistantMessage = openAIPayload.messages.find(
(m) => m.role === "assistant",
)
expect(assistantMessage?.reasoning_text).toContain(
"Let me think about this simple math problem...",
)
expect(getTextParts(assistantMessage?.content)).toContain("2+2 equals 4.")
})
test("should handle thinking blocks with tool calls", () => {
const anthropicPayload: AnthropicMessagesPayload = {
model: "claude-3-5-sonnet-20241022",
messages: [
{ role: "user", content: "What's the weather?" },
{
role: "assistant",
content: [
{
type: "thinking",
thinking:
"I need to call the weather API to get current weather information.",
signature: "def456",
},
{ type: "text", text: "I'll check the weather for you." },
{
type: "tool_use",
id: "call_123",
name: "get_weather",
input: { location: "New York" },
},
],
},
],
max_tokens: 100,
}
const openAIPayload = translateToOpenAI(anthropicPayload)
expect(isValidChatCompletionRequest(openAIPayload)).toBe(true)
// Check that thinking content is included in the message content
const assistantMessage = openAIPayload.messages.find(
(m) => m.role === "assistant",
)
expect(assistantMessage?.reasoning_text).toContain(
"I need to call the weather API",
)
expect(getTextParts(assistantMessage?.content)).toContain(
"I'll check the weather for you.",
)
expect(assistantMessage?.tool_calls).toHaveLength(1)
expect(assistantMessage?.tool_calls?.[0].function.name).toBe("get_weather")
})
test("should map tool_reference tool results into chat tool messages", () => {
const anthropicPayload: AnthropicMessagesPayload = {
model: "gpt-4o",
messages: [
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool_123",
content: [
{
type: "tool_reference",
tool_name: "AskUserQuestion",
},
],
},
],
},
],
max_tokens: 100,
}
const openAIPayload = translateToOpenAI(anthropicPayload)
expect(openAIPayload.messages).toEqual([
{
role: "tool",
tool_call_id: "tool_123",
content: [
{
type: "text",
text: "Tool AskUserQuestion loaded",
},
],
},
])
})
})
describe("tool content support translation", () => {
test("keeps Copilot chat translation compatible with array and image tool results", () => {
const anthropicPayload: AnthropicMessagesPayload = {
model: "gpt-4o",
messages: [
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool_image",
content: [
{
type: "text",
text: "screenshot",
},
{
type: "image",
source: {
type: "base64",
media_type: "image/png",
data: "image-data",
},
},
],
},
],
},
],
max_tokens: 100,
}
const openAIPayload = translateToOpenAI(anthropicPayload)
expect(openAIPayload.messages).toEqual([
{
role: "tool",
tool_call_id: "tool_image",
content: [
{
type: "text",
text: "screenshot",
},
{
type: "image_url",
image_url: {
url: "data:image/png;base64,image-data",
},
},
],
},
])
})
test("keeps Copilot image tool content while downgrading unsupported PDFs", () => {
const anthropicPayload: AnthropicMessagesPayload = {
model: "gpt-4o",
messages: [
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool_pdf",
content: [
{
type: "text",
text: "screenshot",
},
{
type: "image",
source: {
type: "base64",
media_type: "image/png",
data: "image-data",
},
},
{
type: "text",
text: "PDF file read: report.pdf",
},
{
type: "document",
source: {
type: "base64",
media_type: "application/pdf",
data: "pdf-data",
},
title: "report.pdf",
},
],
},
],
},
],
max_tokens: 100,
}
const openAIPayload = translateToOpenAI(anthropicPayload)
expect(openAIPayload.messages).toEqual([
{
role: "tool",
tool_call_id: "tool_pdf",
content: [
{
type: "text",
text: "screenshot",
},
{
type: "image_url",
image_url: {
url: "data:image/png;base64,image-data",
},
},
{
type: "text",
text: "PDF file read: report.pdf",
},
{
type: "text",
text: "PDF/document content is not supported by this Chat Completions upstream. Use the available text extracted from the document.",
},
],
},
])
})
})
describe("provider tool content support translation", () => {
test("uses string-only tool content when provider support is empty", () => {
const anthropicPayload: AnthropicMessagesPayload = {
model: "qwen-plus",
messages: [
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool_text",
content: [
{
type: "text",
text: "line one",
},
{
type: "text",
text: "line two",
},
],
},
],
},
],
max_tokens: 100,
}
const openAIPayload = translateToOpenAI(anthropicPayload, {
toolContentSupportType: [],
})
expect(openAIPayload.messages).toEqual([
{
role: "tool",
tool_call_id: "tool_text",
content: "line one\nline two",
},
])
})
test("rewrites provider image tool results when image support is not configured", () => {
const anthropicPayload: AnthropicMessagesPayload = {
model: "qwen-plus",
messages: [
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool_image",
content: [
{
type: "image",
source: {
type: "base64",
media_type: "image/jpeg",
data: "image-data",
},
},
],
},
],
},
],
max_tokens: 100,
}
const openAIPayload = translateToOpenAI(anthropicPayload, {
toolContentSupportType: [],
})
expect(openAIPayload.messages).toEqual([
{
role: "tool",
tool_call_id: "tool_image",
content: RICH_TOOL_RESULT_MOVED_TEXT,
},
{
role: "user",
content: [
{
type: "text",
text: "Tool result for tool_image:",
},
{
type: "image_url",
image_url: {
url: "data:image/jpeg;base64,image-data",
},
},
],
},
])
})
test("keeps a matching tool message before moved rich tool content", () => {
const anthropicPayload: AnthropicMessagesPayload = {
model: "qwen-plus",
messages: [
{
role: "assistant",
content: [
{
type: "tool_use",
id: "tool_image",
name: "read_image",
input: { path: "screenshot.png" },
},
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool_image",
content: [
{
type: "text",
text: "screenshot captured",
},
{
type: "image",
source: {
type: "base64",
media_type: "image/png",
data: "image-data",
},
},
],
},
],
},
],
max_tokens: 100,
}
const openAIPayload = translateToOpenAI(anthropicPayload, {
toolContentSupportType: [],
})
expect(openAIPayload.messages).toHaveLength(3)
expect(openAIPayload.messages[0]).toMatchObject({
role: "assistant",
tool_calls: [
{
id: "tool_image",
type: "function",
function: {
name: "read_image",
arguments: '{"path":"screenshot.png"}',
},
},
],
})
expect(openAIPayload.messages[1]).toEqual({
role: "tool",
tool_call_id: "tool_image",
content: "screenshot captured",
})
expect(openAIPayload.messages[2]).toEqual({
role: "user",
content: [
{
type: "text",
text: "Tool result for tool_image:",
},
{
type: "text",
text: "screenshot captured",
},
{
type: "image_url",
image_url: {
url: "data:image/png;base64,image-data",
},
},
],
})
})
})
describe("provider tool result ordering", () => {
test("keeps all tool result messages contiguous before moved rich user content", () => {
const anthropicPayload: AnthropicMessagesPayload = {
model: "qwen-plus",
messages: [
{
role: "assistant",
content: [
{
type: "tool_use",
id: "tool_image",
name: "read_image",
input: { path: "screenshot.png" },
},
{
type: "tool_use",
id: "tool_text",
name: "read_text",
input: { path: "log.txt" },
},
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool_image",
content: [
{
type: "text",
text: "screenshot captured",
},
{
type: "image",
source: {
type: "base64",
media_type: "image/png",
data: "image-data",
},
},
],
},
{
type: "tool_result",
tool_use_id: "tool_text",
content: [
{
type: "text",
text: "line one",
},
],
},
],
},
],
max_tokens: 100,
}
const openAIPayload = translateToOpenAI(anthropicPayload, {
toolContentSupportType: [],
})
expect(openAIPayload.messages).toHaveLength(4)
expect(openAIPayload.messages[1]).toEqual({
role: "tool",
tool_call_id: "tool_image",
content: "screenshot captured",
})
expect(openAIPayload.messages[2]).toEqual({
role: "tool",
tool_call_id: "tool_text",
content: "line one",
})
expect(openAIPayload.messages[3]).toEqual({
role: "user",
content: [
{
type: "text",
text: "Tool result for tool_image:",
},
{
type: "text",
text: "screenshot captured",
},
{
type: "image_url",
image_url: {
url: "data:image/png;base64,image-data",
},
},
],
})
})
})
describe("compact request detection", () => {
test("detects current compact summary prompts in string content", () => {
const anthropicPayload: AnthropicMessagesPayload = {
model: "claude-3-5-sonnet",
messages: [
{
role: "user",
content: `CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.\n\nYour task is to create a detailed summary of the conversation so far, paying close attention to the user's explicit requests and your previous actions.\n\n7. Pending Tasks:\n - [Task 1]\n\n8. Current Work:\n [Current work]`,
},
],
max_tokens: 1024,
}
expect(getCompactType(anthropicPayload)).toBe(COMPACT_REQUEST)
})
test("detects compact prompts in user text blocks while ignoring system reminders", () => {
const anthropicPayload: AnthropicMessagesPayload = {
model: "claude-3-5-sonnet",
messages: [
{
role: "user",
content: [
{
type: "text",
text: "<system-reminder>\nThe user opened a file.\n</system-reminder>",
},
{
type: "text",
text: `CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.\n\nYour task is to create a detailed summary of the conversation so far, paying close attention to the user's explicit requests and your previous actions.\n\n7. Pending Tasks:\n - [Task 1]\n\n8. Current Work:\n [Current work]`,
},
],
},
],
max_tokens: 1024,
}
expect(getCompactType(anthropicPayload)).toBe(COMPACT_REQUEST)
})
test("does not treat ordinary user quotes as compact prompts", () => {
const anthropicPayload: AnthropicMessagesPayload = {
model: "claude-3-5-sonnet",
messages: [
{
role: "user",
content:
'Please explain this prompt: "Your task is to create a detailed summary of the conversation so far"',
},
],
max_tokens: 1024,
}
expect(getCompactType(anthropicPayload)).toBe(0)
})
test("keeps legacy system prompt compact detection", () => {
const anthropicPayload: AnthropicMessagesPayload = {
model: "claude-3-5-sonnet",
system:
"You are a helpful AI assistant tasked with summarizing conversations for future continuation.",
messages: [{ role: "user", content: "continue" }],
max_tokens: 1024,
}
expect(getCompactType(anthropicPayload)).toBe(COMPACT_REQUEST)
})
})
describe("OpenAI Chat Completion v1 Request Payload Validation with Zod", () => {
test("should return true for a minimal valid request payload", () => {
const validPayload = {
model: "gpt-4o",
messages: [{ role: "user", content: "Hello!" }],
}
expect(isValidChatCompletionRequest(validPayload)).toBe(true)
})
test("should return true for a comprehensive valid request payload", () => {
const validPayload = {
model: "gpt-4o",
messages: [
{ role: "system", content: "You are a helpful assistant." },
{ role: "user", content: "What is the weather like in Boston?" },
],
temperature: 0.7,
max_tokens: 150,
top_p: 1,
frequency_penalty: 0,
presence_penalty: 0,
stream: false,
n: 1,
}
expect(isValidChatCompletionRequest(validPayload)).toBe(true)
})
test('should return false if the "model" field is missing', () => {
const invalidPayload = {
messages: [{ role: "user", content: "Hello!" }],
}
expect(isValidChatCompletionRequest(invalidPayload)).toBe(false)
})
test('should return false if the "messages" field is missing', () => {
const invalidPayload = {
model: "gpt-4o",
}
expect(isValidChatCompletionRequest(invalidPayload)).toBe(false)
})
test('should return false if the "messages" array is empty', () => {
const invalidPayload = {
model: "gpt-4o",
messages: [],
}
expect(isValidChatCompletionRequest(invalidPayload)).toBe(false)
})
test('should return false if "model" is not a string', () => {
const invalidPayload = {
model: 12345,
messages: [{ role: "user", content: "Hello!" }],
}
expect(isValidChatCompletionRequest(invalidPayload)).toBe(false)
})
test('should return false if "messages" is not an array', () => {
const invalidPayload = {
model: "gpt-4o",
messages: { role: "user", content: "Hello!" },
}
expect(isValidChatCompletionRequest(invalidPayload)).toBe(false)
})
test('should return false if a message in the "messages" array is missing a "role"', () => {
const invalidPayload = {
model: "gpt-4o",
messages: [{ content: "Hello!" }],
}
expect(isValidChatCompletionRequest(invalidPayload)).toBe(false)
})
test('should return false if a message in the "messages" array is missing "content"', () => {
const invalidPayload = {
model: "gpt-4o",
messages: [{ role: "user" }],
}
// Note: Zod considers 'undefined' as missing, so this will fail as expected.
const result = chatCompletionRequestSchema.safeParse(invalidPayload)
expect(result.success).toBe(false)
})
test('should return false if a message has an invalid "role"', () => {
const invalidPayload = {
model: "gpt-4o",
messages: [{ role: "customer", content: "Hello!" }],
}
expect(isValidChatCompletionRequest(invalidPayload)).toBe(false)
})
test("should return false if an optional field has an incorrect type", () => {
const invalidPayload = {
model: "gpt-4o",
messages: [{ role: "user", content: "Hello!" }],
temperature: "hot", // Should be a number
}
expect(isValidChatCompletionRequest(invalidPayload)).toBe(false)
})
test("should return false for a completely empty object", () => {
const invalidPayload = {}
expect(isValidChatCompletionRequest(invalidPayload)).toBe(false)
})
test("should return false for null or non-object payloads", () => {
expect(isValidChatCompletionRequest(null)).toBe(false)
expect(isValidChatCompletionRequest(undefined)).toBe(false)
expect(isValidChatCompletionRequest("a string")).toBe(false)
expect(isValidChatCompletionRequest(123)).toBe(false)
})
})