import { describe, expect, it } from 'vitest'; import { chatResponseToResponses, responsesRequestToChat, ResponsesSseTranslator, type BridgeSseEvent, type ResponsesCreateParams, } from './chatBridgeTranslate'; function sseLines(events: Array>): string { return events.map((data) => `data: ${JSON.stringify(data)}\n\n`).join(''); } function types(events: BridgeSseEvent[]): string[] { return events.map((item) => item.event); } describe('responsesRequestToChat', () => { it('instructions 转 developer message,input 字符串转 user message', () => { const chat = responsesRequestToChat({ model: 'qwen', instructions: '你是助手', input: '你好' }); expect(chat.model).toBe('qwen'); expect(chat.messages).toEqual([ { role: 'developer', content: '你是助手' }, { role: 'user', content: '你好' }, ]); }); it('message item:字符串 content 直传;easy-input-message(无 type)按 message 处理', () => { const chat = responsesRequestToChat({ input: [ { type: 'message', role: 'user', content: '直接字符串' }, { role: 'assistant', content: '没有 type 字段' }, ], }); expect(chat.messages).toEqual([ { role: 'user', content: '直接字符串' }, { role: 'assistant', content: '没有 type 字段' }, ]); }); it('content parts:input_text 合并;单文本压成 string;input_file 丢弃并留诊断', () => { const dropped: string[] = []; const chat = responsesRequestToChat( { input: [ { type: 'message', role: 'user', content: [ { type: 'input_text', text: '第一段' }, { type: 'input_file', file_url: 'file:///x.pdf' }, { type: 'input_text', text: '第二段' }, ], }, { type: 'message', role: 'user', content: [{ type: 'input_text', text: '唯一文本' }], }, ], }, (line) => dropped.push(line), ); expect(chat.messages[0]).toEqual({ role: 'user', content: [ { type: 'text', text: '第一段' }, { type: 'text', text: '第二段' }, ], }); expect(chat.messages[1]).toEqual({ role: 'user', content: '唯一文本' }); expect(dropped.some((line) => line.includes('input_file'))).toBe(true); }); it('function_call / function_call_output / reasoning 的映射与丢弃', () => { const dropped: string[] = []; const chat = responsesRequestToChat( { input: [ { type: 'reasoning', summary: [] }, { type: 'function_call', call_id: 'call_1', name: 'exec', arguments: '{"cmd":"ls"}' }, { type: 'function_call_output', call_id: 'call_1', output: 'ok' }, ], }, (line) => dropped.push(line), ); expect(chat.messages).toEqual([ { role: 'assistant', content: null, tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'exec', arguments: '{"cmd":"ls"}' } }], }, { role: 'tool', tool_call_id: 'call_1', content: 'ok' }, ]); expect(dropped.some((line) => line.includes('reasoning'))).toBe(true); }); it('tools 扁平转嵌套;tool_choice 各形态;max_output_tokens 转 max_tokens', () => { const chat = responsesRequestToChat({ input: 'x', tools: [ { type: 'function', name: 'exec', description: '执行', parameters: { type: 'object' } }, { type: 'web_search' }, ], tool_choice: { type: 'function', name: 'exec' }, max_output_tokens: 1024, temperature: 0.3, top_p: 0.9, parallel_tool_calls: false, }); expect(chat.tools).toEqual([ { type: 'function', function: { name: 'exec', description: '执行', parameters: { type: 'object' } } }, ]); expect(chat.tool_choice).toEqual({ type: 'function', function: { name: 'exec' } }); expect(chat.max_tokens).toBe(1024); expect(chat.temperature).toBe(0.3); expect(chat.top_p).toBe(0.9); expect(chat.parallel_tool_calls).toBe(false); }); it('tool_choice 字符串直传;无法识别的形态丢弃', () => { expect(responsesRequestToChat({ input: 'x', tool_choice: 'required' }).tool_choice).toBe('required'); expect( responsesRequestToChat({ input: 'x', tool_choice: { type: 'allowed_tools', tools: [] } }).tool_choice, ).toBeUndefined(); }); it('stream 时补 stream_options.include_usage;responses 专有字段不外泄', () => { const chat = responsesRequestToChat({ input: 'x', stream: true, previous_response_id: 'resp_x', store: false, metadata: { a: 1 }, reasoning: { effort: 'high' }, include: ['reasoning.encrypted_content'], truncation: 'auto', }); expect(chat.stream).toBe(true); expect(chat.stream_options).toEqual({ include_usage: true }); const raw = JSON.stringify(chat); // 注意要用带冒号的键名匹配:裸 'include' 会误伤 stream_options 的 include_usage for (const key of ['previous_response_id', 'store', 'metadata', 'reasoning', 'include', 'truncation']) { expect(raw).not.toContain(`"${key}":`); } }); }); describe('chatResponseToResponses', () => { const req: ResponsesCreateParams = { model: 'qwen', input: 'x' }; it('content / tool_calls / reasoning_content / usage 全量映射', () => { const response = chatResponseToResponses( { id: 'chatcmpl-1', created: 1700000000, model: 'qwen', choices: [ { finish_reason: 'tool_calls', message: { reasoning_content: '想一想', content: '好的', tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'exec', arguments: '{}' } }], }, }, ], usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, }, req, ); expect(response).toMatchObject({ id: 'chatcmpl-1', object: 'response', created_at: 1700000000, model: 'qwen', status: 'completed', usage: { input_tokens: 10, output_tokens: 20, total_tokens: 30 }, }); const output = response.output as Array>; expect(output.map((item) => item.type)).toEqual(['reasoning', 'message', 'function_call']); expect(output[1]).toMatchObject({ role: 'assistant', status: 'completed', content: [{ type: 'output_text', text: '好的' }], }); expect(output[2]).toMatchObject({ call_id: 'call_1', name: 'exec', arguments: '{}' }); }); it('finish_reason=length 判 incomplete;无 usage 填全零', () => { const response = chatResponseToResponses( { choices: [{ finish_reason: 'length', message: { content: '截断' } }] }, req, ); expect(response.status).toBe('incomplete'); expect(response.incomplete_details).toEqual({ reason: 'max_output_tokens' }); expect(response.usage).toEqual({ input_tokens: 0, output_tokens: 0, total_tokens: 0 }); }); }); describe('ResponsesSseTranslator', () => { const req: ResponsesCreateParams = { model: 'qwen', input: 'x', stream: true }; it('纯文本流:完整 golden 事件序列', () => { const translator = new ResponsesSseTranslator(req); const events = translator.push( sseLines([ { choices: [{ delta: { role: 'assistant', content: '' }, finish_reason: null }] }, { choices: [{ delta: { content: '你好' }, finish_reason: null }] }, { choices: [{ delta: { content: ',世界' }, finish_reason: null }] }, { choices: [{ delta: {}, finish_reason: 'stop' }], usage: { prompt_tokens: 3, completion_tokens: 2, total_tokens: 5 } }, ]) + 'data: [DONE]\n\n', ); expect(types(events)).toEqual([ 'response.created', 'response.in_progress', 'response.output_item.added', 'response.content_part.added', 'response.output_text.delta', 'response.output_text.delta', 'response.output_text.done', 'response.content_part.done', 'response.output_item.done', 'response.completed', ]); const completed = events.at(-1)?.data.response as Record; expect(completed.status).toBe('completed'); expect(completed.usage).toEqual({ input_tokens: 3, output_tokens: 2, total_tokens: 5 }); const output = completed.output as Array>; expect(output).toHaveLength(1); expect((output[0].content as Array<{ text: string }>)[0].text).toBe('你好,世界'); }); it('data: 行跨分包拼接', () => { const translator = new ResponsesSseTranslator(req); const chunk = `data: ${JSON.stringify({ choices: [{ delta: { content: '整段' } }] })}`; const half = Math.floor(chunk.length / 2); const first = translator.push(chunk.slice(0, half)); const second = translator.push(chunk.slice(half) + '\n\ndata: [DONE]\n\n'); expect(types([...first, ...second])).toEqual([ 'response.created', 'response.in_progress', 'response.output_item.added', 'response.content_part.added', 'response.output_text.delta', 'response.output_text.done', 'response.content_part.done', 'response.output_item.done', 'response.completed', ]); }); it('两个并行 tool_calls 按 index 交错也能分别归拢', () => { const translator = new ResponsesSseTranslator(req); const events = translator.push( sseLines([ { choices: [ { delta: { tool_calls: [ { index: 0, id: 'call_a', function: { name: 'exec', arguments: '{"a":' } }, { index: 1, id: 'call_b', function: { name: 'read', arguments: '{"b":' } }, ], }, }, ], }, { choices: [ { delta: { tool_calls: [ { index: 1, function: { arguments: '1}' } }, { index: 0, function: { arguments: '2}' } }, ], }, finish_reason: 'tool_calls', }, ], }, ]) + 'data: [DONE]\n\n', ); expect(types(events)).toEqual([ 'response.created', 'response.in_progress', 'response.output_item.added', 'response.function_call_arguments.delta', 'response.output_item.added', 'response.function_call_arguments.delta', 'response.function_call_arguments.delta', 'response.function_call_arguments.delta', 'response.function_call_arguments.done', 'response.output_item.done', 'response.function_call_arguments.done', 'response.output_item.done', 'response.completed', ]); const completed = events.at(-1)?.data.response as Record; const output = completed.output as Array>; expect(output.map((item) => [item.call_id, item.name, item.arguments])).toEqual([ ['call_a', 'exec', '{"a":2}'], ['call_b', 'read', '{"b":1}'], ]); }); it('reasoning_content 流转 reasoning item,正文开始前闭合', () => { const translator = new ResponsesSseTranslator(req); const events = translator.push( sseLines([ { choices: [{ delta: { reasoning_content: '先想' } }] }, { choices: [{ delta: { reasoning_content: '一下' } }] }, { choices: [{ delta: { content: '结论' } }] }, { choices: [{ delta: {}, finish_reason: 'stop' }] }, ]) + 'data: [DONE]\n\n', ); expect(types(events)).toEqual([ 'response.created', 'response.in_progress', 'response.output_item.added', 'response.reasoning_summary_part.added', 'response.reasoning_summary_text.delta', 'response.reasoning_summary_text.delta', 'response.reasoning_summary_text.done', 'response.reasoning_summary_part.done', 'response.output_item.done', 'response.output_item.added', 'response.content_part.added', 'response.output_text.delta', 'response.output_text.done', 'response.content_part.done', 'response.output_item.done', 'response.completed', ]); const completed = events.at(-1)?.data.response as Record; const output = completed.output as Array>; expect(output[0]).toMatchObject({ type: 'reasoning', summary: [{ type: 'summary_text', text: '先想一下' }] }); }); it('上游没发 [DONE]:finish() 冲刷闭合;无 usage 填全零', () => { const translator = new ResponsesSseTranslator(req); translator.push(sseLines([{ choices: [{ delta: { content: '半截' } }] }])); const events = translator.finish(); expect(types(events)).toEqual([ 'response.output_text.done', 'response.content_part.done', 'response.output_item.done', 'response.completed', ]); const completed = events.at(-1)?.data.response as Record; expect(completed.usage).toEqual({ input_tokens: 0, output_tokens: 0, total_tokens: 0 }); // 再次 finish / push 不再重复发 completed expect(translator.finish()).toEqual([]); }); it('坏 JSON 行跳过不杀流;usage-only chunk 不产事件', () => { const dropped: string[] = []; const translator = new ResponsesSseTranslator(req, (line) => dropped.push(line)); const events = translator.push( 'data: {bad json\n\n' + sseLines([{ usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } }]) + sseLines([{ choices: [{ delta: { content: '好' }, finish_reason: 'stop' }] }]) + 'data: [DONE]\n\n', ); expect(dropped.some((line) => line.includes('无法解析'))).toBe(true); const completed = events.at(-1)?.data.response as Record; expect(completed.usage).toEqual({ input_tokens: 1, output_tokens: 1, total_tokens: 2 }); }); it('fail() 补 response.failed 并带错误信息', () => { const translator = new ResponsesSseTranslator(req); translator.push(sseLines([{ choices: [{ delta: { content: '写了一半' } }] }])); const events = translator.fail('上游连接中断'); expect(types(events)).toEqual(['response.failed']); const response = events[0].data.response as Record; expect(response.status).toBe('failed'); expect(response.error).toMatchObject({ code: 'upstream_error', message: '上游连接中断' }); expect(translator.finish()).toEqual([]); }); });