Files
cursor2api/test/unit-openai-compat.mjs
小海 f12ca30893 feat(v2.5.0): Cursor IDE 完整适配 + 工具参数自动修复 + 增量流式优化
🖥️ Cursor IDE 适配:
- 新增 /v1/responses 端点(Responses API → Chat Completions 自动转换)
- 兼容 Cursor 扁平工具格式 { name, input_schema }
- 扩展 /v1/models 模型列表(claude-sonnet-4-5/4/3.5)
- 连续同角色消息自动合并(mergeConsecutiveRoles)
- content 数组中 tool_use/tool_result 块直接透传

🔧 工具参数自动修复 (tool-fixer.ts):
- normalizeToolArguments: file_path → path 字段名映射
- replaceSmartQuotes: 中文/法文智能引号替换
- repairExactMatchToolArguments: 模糊匹配修复
- extractToolResultNatural: 自然语言 tool_result 转换

🚀 流式增量优化:
- input_json_delta / tool_calls 按 128 字节分块
- 拒绝重试扩展到工具模式
- 极短响应自动重试

🧪 新增 44 个单元测试 (tool-fixer + openai-compat)
2026-03-10 16:27:19 +08:00

496 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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}`);
}
// ─── 内联 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: item.output || '',
tool_call_id: item.call_id || '',
});
continue;
}
const role = item.role || 'user';
if (role === 'system' || role === 'developer') {
const text = typeof item.content === 'string'
? item.content
: Array.isArray(item.content)
? item.content.filter(b => b.type === 'input_text').map(b => b.text).join('\n')
: String(item.content || '');
messages.push({ role: 'system', content: text });
} else if (role === 'user') {
const content = typeof item.content === 'string'
? item.content
: Array.isArray(item.content)
? item.content.filter(b => b.type === 'input_text').map(b => b.text).join('\n')
: String(item.content || '');
messages.push({ role: 'user', content });
} 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 → 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('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);