mirror of
https://github.com/7836246/cursor2api.git
synced 2026-06-02 03:51:50 +08:00
Tighten image path normalization, preserve multimodal request content across OpenAI-compatible endpoints, and fail fast on unsupported image_file inputs so clients get predictable behavior instead of silent degradation. Made-with: Cursor
580 lines
21 KiB
JavaScript
580 lines
21 KiB
JavaScript
/**
|
||
* test/unit-openai-compat.mjs
|
||
*
|
||
* 单元测试:OpenAI 处理器兼容性功能
|
||
* - responsesToChatCompletions 转换
|
||
* - Cursor 扁平格式工具兼容
|
||
* - 消息角色合并
|
||
*
|
||
* 运行方式:node test/unit-openai-compat.mjs
|
||
*/
|
||
|
||
// ─── 测试框架 ──────────────────────────────────────────────────────────
|
||
let passed = 0;
|
||
let failed = 0;
|
||
|
||
function test(name, fn) {
|
||
try {
|
||
fn();
|
||
console.log(` ✅ ${name}`);
|
||
passed++;
|
||
} catch (e) {
|
||
console.error(` ❌ ${name}`);
|
||
console.error(` ${e.message}`);
|
||
failed++;
|
||
}
|
||
}
|
||
|
||
function assert(condition, msg) {
|
||
if (!condition) throw new Error(msg || 'Assertion failed');
|
||
}
|
||
|
||
function assertEqual(a, b, msg) {
|
||
const as = JSON.stringify(a), bs = JSON.stringify(b);
|
||
if (as !== bs) throw new Error(msg || `Expected ${bs}, got ${as}`);
|
||
}
|
||
|
||
function stringifyUnknownContent(value) {
|
||
if (value === null || value === undefined) return '';
|
||
if (typeof value === 'string') return value;
|
||
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
|
||
return String(value);
|
||
}
|
||
try {
|
||
return JSON.stringify(value);
|
||
} catch {
|
||
return String(value);
|
||
}
|
||
}
|
||
|
||
function extractOpenAIContentBlocks(msg) {
|
||
if (msg.content === null || msg.content === undefined) return '';
|
||
if (typeof msg.content === 'string') return msg.content;
|
||
if (Array.isArray(msg.content)) {
|
||
const blocks = [];
|
||
for (const p of msg.content) {
|
||
if ((p.type === 'text' || p.type === 'input_text') && p.text) {
|
||
blocks.push({ type: 'text', text: p.text });
|
||
} else if (p.type === 'image_url' && p.image_url?.url) {
|
||
blocks.push({
|
||
type: 'image',
|
||
source: { type: 'url', media_type: 'image/jpeg', data: p.image_url.url },
|
||
});
|
||
} else if (p.type === 'input_image' && p.image_url?.url) {
|
||
blocks.push({
|
||
type: 'image',
|
||
source: { type: 'url', media_type: 'image/jpeg', data: p.image_url.url },
|
||
});
|
||
}
|
||
}
|
||
return blocks.length > 0 ? blocks : '';
|
||
}
|
||
return stringifyUnknownContent(msg.content);
|
||
}
|
||
|
||
function extractOpenAIContent(msg) {
|
||
const blocks = extractOpenAIContentBlocks(msg);
|
||
if (typeof blocks === 'string') return blocks;
|
||
return blocks.filter(b => b.type === 'text').map(b => b.text).join('\n');
|
||
}
|
||
|
||
// ─── 内联 mergeConsecutiveRoles(与 src/openai-handler.ts 保持同步)────
|
||
function toBlocks(content) {
|
||
if (typeof content === 'string') {
|
||
return content ? [{ type: 'text', text: content }] : [];
|
||
}
|
||
return content || [];
|
||
}
|
||
|
||
function mergeConsecutiveRoles(messages) {
|
||
if (messages.length <= 1) return messages;
|
||
const merged = [];
|
||
for (const msg of messages) {
|
||
const last = merged[merged.length - 1];
|
||
if (last && last.role === msg.role) {
|
||
const lastBlocks = toBlocks(last.content);
|
||
const newBlocks = toBlocks(msg.content);
|
||
last.content = [...lastBlocks, ...newBlocks];
|
||
} else {
|
||
merged.push({ ...msg });
|
||
}
|
||
}
|
||
return merged;
|
||
}
|
||
|
||
// ─── 内联 responsesToChatCompletions(与 src/openai-handler.ts 保持同步)
|
||
function responsesToChatCompletions(body) {
|
||
const messages = [];
|
||
|
||
if (body.instructions && typeof body.instructions === 'string') {
|
||
messages.push({ role: 'system', content: body.instructions });
|
||
}
|
||
|
||
const input = body.input;
|
||
if (typeof input === 'string') {
|
||
messages.push({ role: 'user', content: input });
|
||
} else if (Array.isArray(input)) {
|
||
for (const item of input) {
|
||
// function_call_output has type but no role — check first
|
||
if (item.type === 'function_call_output') {
|
||
messages.push({
|
||
role: 'tool',
|
||
content: stringifyUnknownContent(item.output),
|
||
tool_call_id: item.call_id || '',
|
||
});
|
||
continue;
|
||
}
|
||
const role = item.role || 'user';
|
||
if (role === 'system' || role === 'developer') {
|
||
const text = extractOpenAIContent({
|
||
role: 'system',
|
||
content: item.content ?? null,
|
||
});
|
||
messages.push({ role: 'system', content: text });
|
||
} else if (role === 'user') {
|
||
const rawContent = item.content ?? null;
|
||
const normalizedContent = typeof rawContent === 'string'
|
||
? rawContent
|
||
: Array.isArray(rawContent) && rawContent.every(b => b.type === 'input_text')
|
||
? rawContent.map(b => b.text || '').join('\n')
|
||
: rawContent;
|
||
messages.push({
|
||
role: 'user',
|
||
content: normalizedContent,
|
||
});
|
||
} else if (role === 'assistant') {
|
||
const blocks = Array.isArray(item.content) ? item.content : [];
|
||
const text = blocks.filter(b => b.type === 'output_text').map(b => b.text).join('\n');
|
||
const toolCallBlocks = blocks.filter(b => b.type === 'function_call');
|
||
const toolCalls = toolCallBlocks.map(b => ({
|
||
id: b.call_id || `call_${Math.random().toString(36).slice(2)}`,
|
||
type: 'function',
|
||
function: {
|
||
name: b.name || '',
|
||
arguments: b.arguments || '{}',
|
||
},
|
||
}));
|
||
messages.push({
|
||
role: 'assistant',
|
||
content: text || null,
|
||
...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
const tools = Array.isArray(body.tools)
|
||
? body.tools.map(t => ({
|
||
type: 'function',
|
||
function: {
|
||
name: t.name || '',
|
||
description: t.description,
|
||
parameters: t.parameters,
|
||
},
|
||
}))
|
||
: undefined;
|
||
|
||
return {
|
||
model: body.model || 'gpt-4',
|
||
messages,
|
||
stream: body.stream ?? true,
|
||
temperature: body.temperature,
|
||
max_tokens: body.max_output_tokens || 8192,
|
||
tools,
|
||
};
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════
|
||
// 1. responsesToChatCompletions — 基本转换
|
||
// ════════════════════════════════════════════════════════════════════
|
||
console.log('\n📦 [1] responsesToChatCompletions — 基本转换\n');
|
||
|
||
test('简单字符串 input → user 消息', () => {
|
||
const result = responsesToChatCompletions({
|
||
model: 'gpt-4',
|
||
input: 'Hello, how are you?',
|
||
});
|
||
assertEqual(result.model, 'gpt-4');
|
||
assertEqual(result.messages.length, 1);
|
||
assertEqual(result.messages[0].role, 'user');
|
||
assertEqual(result.messages[0].content, 'Hello, how are you?');
|
||
});
|
||
|
||
test('带 instructions → system 消息', () => {
|
||
const result = responsesToChatCompletions({
|
||
model: 'gpt-4',
|
||
instructions: 'You are a helpful assistant.',
|
||
input: 'Hello',
|
||
});
|
||
assertEqual(result.messages.length, 2);
|
||
assertEqual(result.messages[0].role, 'system');
|
||
assertEqual(result.messages[0].content, 'You are a helpful assistant.');
|
||
assertEqual(result.messages[1].role, 'user');
|
||
});
|
||
|
||
test('多轮对话 input 数组', () => {
|
||
const result = responsesToChatCompletions({
|
||
model: 'gpt-4',
|
||
input: [
|
||
{ role: 'user', content: 'What is 2+2?' },
|
||
{ role: 'assistant', content: [{ type: 'output_text', text: '4' }] },
|
||
{ role: 'user', content: 'And 3+3?' },
|
||
],
|
||
});
|
||
assertEqual(result.messages.length, 3);
|
||
assertEqual(result.messages[0].role, 'user');
|
||
assertEqual(result.messages[1].role, 'assistant');
|
||
assertEqual(result.messages[1].content, '4');
|
||
assertEqual(result.messages[2].role, 'user');
|
||
});
|
||
|
||
test('developer 角色 → system', () => {
|
||
const result = responsesToChatCompletions({
|
||
model: 'gpt-4',
|
||
input: [
|
||
{ role: 'developer', content: 'You are a coding assistant.' },
|
||
{ role: 'user', content: 'Write hello world' },
|
||
],
|
||
});
|
||
assertEqual(result.messages[0].role, 'system');
|
||
assertEqual(result.messages[0].content, 'You are a coding assistant.');
|
||
});
|
||
|
||
test('function_call_output → tool 消息', () => {
|
||
const result = responsesToChatCompletions({
|
||
model: 'gpt-4',
|
||
input: [
|
||
{ role: 'user', content: 'List files' },
|
||
{
|
||
role: 'assistant',
|
||
content: [{
|
||
type: 'function_call',
|
||
call_id: 'call_123',
|
||
name: 'list_dir',
|
||
arguments: '{"path":"."}'
|
||
}]
|
||
},
|
||
{
|
||
type: 'function_call_output',
|
||
call_id: 'call_123',
|
||
output: 'file1.ts\nfile2.ts'
|
||
},
|
||
],
|
||
});
|
||
assertEqual(result.messages.length, 3);
|
||
assertEqual(result.messages[2].role, 'tool');
|
||
assertEqual(result.messages[2].content, 'file1.ts\nfile2.ts');
|
||
assertEqual(result.messages[2].tool_call_id, 'call_123');
|
||
});
|
||
|
||
test('function_call_output 对象 → JSON 字符串', () => {
|
||
const result = responsesToChatCompletions({
|
||
model: 'gpt-4',
|
||
input: [
|
||
{ role: 'user', content: 'Summarize tool output' },
|
||
{
|
||
type: 'function_call_output',
|
||
call_id: 'call_obj',
|
||
output: { files: ['a.ts', 'b.ts'], count: 2 }
|
||
},
|
||
],
|
||
});
|
||
assertEqual(result.messages.length, 2);
|
||
assertEqual(result.messages[1].role, 'tool');
|
||
assertEqual(result.messages[1].content, '{"files":["a.ts","b.ts"],"count":2}');
|
||
assertEqual(result.messages[1].tool_call_id, 'call_obj');
|
||
});
|
||
|
||
test('助手消息带 function_call → tool_calls', () => {
|
||
const result = responsesToChatCompletions({
|
||
model: 'gpt-4',
|
||
input: [
|
||
{ role: 'user', content: 'Read file' },
|
||
{
|
||
role: 'assistant',
|
||
content: [{
|
||
type: 'function_call',
|
||
call_id: 'call_abc',
|
||
name: 'read_file',
|
||
arguments: '{"path":"index.ts"}'
|
||
}]
|
||
},
|
||
],
|
||
});
|
||
assertEqual(result.messages[1].role, 'assistant');
|
||
assert(result.messages[1].tool_calls, 'should have tool_calls');
|
||
assertEqual(result.messages[1].tool_calls.length, 1);
|
||
assertEqual(result.messages[1].tool_calls[0].function.name, 'read_file');
|
||
assertEqual(result.messages[1].tool_calls[0].function.arguments, '{"path":"index.ts"}');
|
||
});
|
||
|
||
test('工具定义转换', () => {
|
||
const result = responsesToChatCompletions({
|
||
model: 'gpt-4',
|
||
input: 'hello',
|
||
tools: [
|
||
{
|
||
type: 'function',
|
||
name: 'read_file',
|
||
description: 'Read a file',
|
||
parameters: { type: 'object', properties: { path: { type: 'string' } } },
|
||
}
|
||
],
|
||
});
|
||
assert(result.tools, 'should have tools');
|
||
assertEqual(result.tools.length, 1);
|
||
assertEqual(result.tools[0].function.name, 'read_file');
|
||
});
|
||
|
||
test('input_text content 数组', () => {
|
||
const result = responsesToChatCompletions({
|
||
model: 'gpt-4',
|
||
input: [
|
||
{
|
||
role: 'user',
|
||
content: [
|
||
{ type: 'input_text', text: 'Part 1' },
|
||
{ type: 'input_text', text: 'Part 2' },
|
||
]
|
||
},
|
||
],
|
||
});
|
||
assertEqual(result.messages[0].content, 'Part 1\nPart 2');
|
||
});
|
||
|
||
test('Responses user input_image 不应丢失', () => {
|
||
const result = responsesToChatCompletions({
|
||
model: 'gpt-4',
|
||
input: [
|
||
{
|
||
role: 'user',
|
||
content: [
|
||
{ type: 'input_text', text: '请描述这张图' },
|
||
{ type: 'input_image', image_url: { url: 'https://example.com/image.jpg' } },
|
||
]
|
||
},
|
||
],
|
||
});
|
||
assertEqual(result.messages.length, 1);
|
||
assert(Array.isArray(result.messages[0].content), 'content should remain multimodal blocks');
|
||
assertEqual(result.messages[0].content[0], { type: 'input_text', text: '请描述这张图' });
|
||
assertEqual(result.messages[0].content[1], { type: 'input_image', image_url: { url: 'https://example.com/image.jpg' } });
|
||
});
|
||
|
||
test('stream 默认为 true', () => {
|
||
const result = responsesToChatCompletions({ model: 'gpt-4', input: 'hi' });
|
||
assertEqual(result.stream, true);
|
||
});
|
||
|
||
test('stream 显式设为 false', () => {
|
||
const result = responsesToChatCompletions({ model: 'gpt-4', input: 'hi', stream: false });
|
||
assertEqual(result.stream, false);
|
||
});
|
||
|
||
test('max_output_tokens 转换', () => {
|
||
const result = responsesToChatCompletions({ model: 'gpt-4', input: 'hi', max_output_tokens: 4096 });
|
||
assertEqual(result.max_tokens, 4096);
|
||
});
|
||
|
||
// ════════════════════════════════════════════════════════════════════
|
||
// 2. mergeConsecutiveRoles — 消息合并
|
||
// ════════════════════════════════════════════════════════════════════
|
||
console.log('\n📦 [2] mergeConsecutiveRoles — 消息合并\n');
|
||
|
||
test('交替角色不合并', () => {
|
||
const msgs = [
|
||
{ role: 'user', content: 'Hello' },
|
||
{ role: 'assistant', content: 'Hi' },
|
||
{ role: 'user', content: 'Bye' },
|
||
];
|
||
const result = mergeConsecutiveRoles(msgs);
|
||
assertEqual(result.length, 3);
|
||
});
|
||
|
||
test('连续 user 消息合并', () => {
|
||
const msgs = [
|
||
{ role: 'user', content: 'Message 1' },
|
||
{ role: 'user', content: 'Message 2' },
|
||
{ role: 'assistant', content: 'Response' },
|
||
];
|
||
const result = mergeConsecutiveRoles(msgs);
|
||
assertEqual(result.length, 2);
|
||
assertEqual(result[0].role, 'user');
|
||
// 合并后应为 block 数组
|
||
assert(Array.isArray(result[0].content), 'merged content should be array');
|
||
assertEqual(result[0].content.length, 2);
|
||
assertEqual(result[0].content[0].text, 'Message 1');
|
||
assertEqual(result[0].content[1].text, 'Message 2');
|
||
});
|
||
|
||
test('连续 assistant 消息合并', () => {
|
||
const msgs = [
|
||
{ role: 'user', content: 'Hello' },
|
||
{ role: 'assistant', content: 'Part 1' },
|
||
{ role: 'assistant', content: 'Part 2' },
|
||
];
|
||
const result = mergeConsecutiveRoles(msgs);
|
||
assertEqual(result.length, 2);
|
||
assertEqual(result[1].role, 'assistant');
|
||
assert(Array.isArray(result[1].content));
|
||
assertEqual(result[1].content.length, 2);
|
||
});
|
||
|
||
test('tool result + text user 消息合并', () => {
|
||
const msgs = [
|
||
{ role: 'user', content: [{ type: 'tool_result', tool_use_id: 'id1', content: 'output' }] },
|
||
{ role: 'user', content: 'Follow up question' },
|
||
];
|
||
const result = mergeConsecutiveRoles(msgs);
|
||
assertEqual(result.length, 1);
|
||
assert(Array.isArray(result[0].content));
|
||
assertEqual(result[0].content.length, 2); // tool_result + text
|
||
});
|
||
|
||
test('空消息列表', () => {
|
||
assertEqual(mergeConsecutiveRoles([]).length, 0);
|
||
});
|
||
|
||
test('单条消息不合并', () => {
|
||
const result = mergeConsecutiveRoles([{ role: 'user', content: 'solo' }]);
|
||
assertEqual(result.length, 1);
|
||
});
|
||
|
||
test('三条连续 user 全部合并', () => {
|
||
const msgs = [
|
||
{ role: 'user', content: 'A' },
|
||
{ role: 'user', content: 'B' },
|
||
{ role: 'user', content: 'C' },
|
||
];
|
||
const result = mergeConsecutiveRoles(msgs);
|
||
assertEqual(result.length, 1);
|
||
assert(Array.isArray(result[0].content));
|
||
assertEqual(result[0].content.length, 3);
|
||
});
|
||
|
||
// ════════════════════════════════════════════════════════════════════
|
||
// 3. Cursor 扁平格式工具兼容
|
||
// ════════════════════════════════════════════════════════════════════
|
||
console.log('\n📦 [3] Cursor 扁平格式工具兼容\n');
|
||
|
||
function convertTools(tools) {
|
||
return tools.map(t => {
|
||
if ('function' in t && t.function) {
|
||
return {
|
||
name: t.function.name,
|
||
description: t.function.description,
|
||
input_schema: t.function.parameters || { type: 'object', properties: {} },
|
||
};
|
||
}
|
||
return {
|
||
name: t.name || '',
|
||
description: t.description,
|
||
input_schema: t.input_schema || { type: 'object', properties: {} },
|
||
};
|
||
});
|
||
}
|
||
|
||
test('标准 OpenAI 格式工具', () => {
|
||
const tools = convertTools([{
|
||
type: 'function',
|
||
function: {
|
||
name: 'read_file',
|
||
description: 'Read file contents',
|
||
parameters: { type: 'object', properties: { path: { type: 'string' } } },
|
||
},
|
||
}]);
|
||
assertEqual(tools[0].name, 'read_file');
|
||
assertEqual(tools[0].description, 'Read file contents');
|
||
assert(tools[0].input_schema.properties.path);
|
||
});
|
||
|
||
test('Cursor 扁平格式工具', () => {
|
||
const tools = convertTools([{
|
||
name: 'write_file',
|
||
description: 'Write file',
|
||
input_schema: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } } },
|
||
}]);
|
||
assertEqual(tools[0].name, 'write_file');
|
||
assertEqual(tools[0].description, 'Write file');
|
||
assert(tools[0].input_schema.properties.path);
|
||
assert(tools[0].input_schema.properties.content);
|
||
});
|
||
|
||
test('混合格式工具列表', () => {
|
||
const tools = convertTools([
|
||
{
|
||
type: 'function',
|
||
function: { name: 'tool_a', description: 'A', parameters: {} },
|
||
},
|
||
{
|
||
name: 'tool_b',
|
||
description: 'B',
|
||
input_schema: {},
|
||
},
|
||
]);
|
||
assertEqual(tools.length, 2);
|
||
assertEqual(tools[0].name, 'tool_a');
|
||
assertEqual(tools[1].name, 'tool_b');
|
||
});
|
||
|
||
test('缺少 input_schema 的扁平格式', () => {
|
||
const tools = convertTools([{ name: 'simple_tool' }]);
|
||
assertEqual(tools[0].name, 'simple_tool');
|
||
assert(tools[0].input_schema, 'should have default input_schema');
|
||
assertEqual(tools[0].input_schema.type, 'object');
|
||
});
|
||
|
||
// ════════════════════════════════════════════════════════════════════
|
||
// 4. 增量流式工具调用验证
|
||
// ════════════════════════════════════════════════════════════════════
|
||
console.log('\n📦 [4] 增量流式工具调用验证\n');
|
||
|
||
test('128 字节分块:short arguments', () => {
|
||
const args = '{"path":"src/index.ts"}';
|
||
const CHUNK_SIZE = 128;
|
||
const chunks = [];
|
||
for (let j = 0; j < args.length; j += CHUNK_SIZE) {
|
||
chunks.push(args.slice(j, j + CHUNK_SIZE));
|
||
}
|
||
// 短参数应一帧发完
|
||
assertEqual(chunks.length, 1);
|
||
assertEqual(chunks[0], args);
|
||
});
|
||
|
||
test('128 字节分块:long arguments', () => {
|
||
const longContent = 'A'.repeat(400);
|
||
const args = JSON.stringify({ path: 'test.ts', content: longContent });
|
||
const CHUNK_SIZE = 128;
|
||
const chunks = [];
|
||
for (let j = 0; j < args.length; j += CHUNK_SIZE) {
|
||
chunks.push(args.slice(j, j + CHUNK_SIZE));
|
||
}
|
||
// 拼接后应等于原始数据
|
||
assertEqual(chunks.join(''), args);
|
||
// 应有多帧
|
||
assert(chunks.length > 1, `Expected multiple chunks, got ${chunks.length}`);
|
||
// 每帧最多 128 字节
|
||
for (const c of chunks) {
|
||
assert(c.length <= CHUNK_SIZE, `Chunk too long: ${c.length}`);
|
||
}
|
||
});
|
||
|
||
test('空 arguments 零帧', () => {
|
||
const args = '';
|
||
const CHUNK_SIZE = 128;
|
||
const chunks = [];
|
||
for (let j = 0; j < args.length; j += CHUNK_SIZE) {
|
||
chunks.push(args.slice(j, j + CHUNK_SIZE));
|
||
}
|
||
assertEqual(chunks.length, 0);
|
||
});
|
||
|
||
// ════════════════════════════════════════════════════════════════════
|
||
// 汇总
|
||
// ════════════════════════════════════════════════════════════════════
|
||
console.log('\n' + '═'.repeat(55));
|
||
console.log(` 结果: ${passed} 通过 / ${failed} 失败 / ${passed + failed} 总计`);
|
||
console.log('═'.repeat(55) + '\n');
|
||
|
||
if (failed > 0) process.exit(1);
|