import { describe, expect, it } from 'vitest'; import { ChatSseTranslator, chatResponseToResponses, responsesToChatRequest } from './translate'; function chatSse(payload: string): string { return `data: ${payload}\n\n`; } function chatDelta(delta: Record, finishReason?: string, usage?: unknown): string { const choice: Record = { index: 0, delta }; if (finishReason) choice.finish_reason = finishReason; const chunk: Record = { id: 'chatcmpl_1', choices: [choice] }; if (usage !== undefined) chunk.usage = usage; return JSON.stringify(chunk); } type EventTuple = Array<{ event: string; data: Record }>; function parseFrames(frames: string[]): EventTuple { return frames.join('').split('\n\n').filter(Boolean).map((raw) => { const event = /^event: (.*)$/mu.exec(raw)?.[1] ?? ''; const data = /^data: (.*)$/mu.exec(raw)?.[1] ?? '{}'; return { event, data: JSON.parse(data) as Record }; }); } describe('responsesToChatRequest 请求方向', () => { it('instructions → developer 消息;input 字符串 → user 消息', () => { const chat = responsesToChatRequest({ model: 'qwen', instructions: '你是助手', input: '你好' }); expect(chat.model).toBe('qwen'); expect(chat.messages).toEqual([ { role: 'developer', content: '你是助手' }, { role: 'user', content: '你好' }, ]); expect(chat.stream).toBe(false); }); it('message 数组 content:文本合并、单文本压字符串、图片转 image_url、未知 part 丢弃留诊断', () => { const dropped: string[] = []; const chat = responsesToChatRequest({ model: 'qwen', input: [{ type: 'message', role: 'user', content: [ { type: 'input_text', text: '看图' }, { type: 'input_image', image_url: 'http://x/y.png' }, { type: 'input_image', image_url: { url: 'http://x/z.png' } }, { type: 'input_file', file_id: 'f1' }, { type: 'input_text', text: '回答我' }, ], }], }, (m) => dropped.push(m)); expect(chat.messages).toHaveLength(1); const content = chat.messages[0].content as Array>; expect(content).toEqual([ { type: 'text', text: '看图' }, { type: 'image_url', image_url: { url: 'http://x/y.png' } }, { type: 'image_url', image_url: { url: 'http://x/z.png' } }, { type: 'text', text: '回答我' }, ]); expect(dropped.join(' ')).toContain('input_file×1'); }); it('function_call / function_call_output → assistant tool_calls 与 role:tool,call_id 原样对应', () => { const chat = responsesToChatRequest({ model: 'qwen', input: [ { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'ls' }] }, { type: 'function_call', id: 'fc_1', call_id: 'call_9', name: 'shell', arguments: '{"cmd":["ls"]}' }, { type: 'function_call_output', call_id: 'call_9', output: 'file-a\nfile-b' }, ], }); expect(chat.messages[1]).toEqual({ role: 'assistant', content: null, tool_calls: [{ id: 'call_9', type: 'function', function: { name: 'shell', arguments: '{"cmd":["ls"]}' } }], }); expect(chat.messages[2]).toEqual({ role: 'tool', tool_call_id: 'call_9', content: 'file-a\nfile-b' }); }); it('custom_tool_call 用 input 当 arguments;结构化 output 折叠成文本', () => { const chat = responsesToChatRequest({ model: 'qwen', input: [ { type: 'custom_tool_call', call_id: 'c1', name: 'patch', input: '*** Begin Patch' }, { type: 'custom_tool_call_output', call_id: 'c1', output: [{ type: 'input_text', text: 'ok' }] }, ], }); expect(chat.messages[0].tool_calls?.[0].function.arguments).toBe('*** Begin Patch'); expect(chat.messages[1].content).toBe('ok'); }); it('reasoning / web_search_call 丢弃并留诊断', () => { const dropped: string[] = []; const chat = responsesToChatRequest({ model: 'qwen', input: [ { type: 'reasoning', id: 'rs_1', summary: [{ type: 'summary_text', text: '想' }] }, { type: 'message', role: 'user', content: 'hi' }, { type: 'web_search_call', id: 'ws_1' }, ], }, (m) => dropped.push(m)); expect(chat.messages).toEqual([{ role: 'user', content: 'hi' }]); expect(dropped.join(' ')).toContain('reasoning×1'); expect(dropped.join(' ')).toContain('web_search_call×1'); }); it('tools 扁平转嵌套,非 function 工具丢弃;tool_choice 各形态映射', () => { const dropped: string[] = []; const chat = responsesToChatRequest({ model: 'qwen', input: 'hi', tools: [ { type: 'function', name: 'shell', description: '跑命令', parameters: { type: 'object' }, strict: true }, { type: 'web_search' }, ], tool_choice: { type: 'function', name: 'shell' }, }, (m) => dropped.push(m)); expect(chat.tools).toEqual([{ type: 'function', function: { name: 'shell', description: '跑命令', parameters: { type: 'object' } } }]); expect(chat.tool_choice).toEqual({ type: 'function', function: { name: 'shell' } }); expect(dropped.join(' ')).toContain('tools×1'); }); it('Responses 专有字段绝不外泄(白名单式构建)', () => { const chat = responsesToChatRequest({ model: 'qwen', input: 'hi', stream: true, store: false, include: ['reasoning.encrypted_content'], reasoning: { effort: 'low', summary: 'auto' }, prompt_cache_key: 'thread-1', client_metadata: { session_id: 's' }, metadata: { a: 1 }, text: { verbosity: 'low' }, service_tier: 'default', previous_response_id: 'resp_old', max_output_tokens: 123, temperature: 0.5, top_p: 0.9, parallel_tool_calls: false, }); const serialized = JSON.stringify(chat); for (const banned of ['store', 'include', 'reasoning', 'prompt_cache_key', 'client_metadata', 'metadata', 'text', 'service_tier', 'previous_response_id']) { expect(serialized).not.toContain(`"${banned}"`); } expect(chat.max_tokens).toBe(123); expect(chat.temperature).toBe(0.5); expect(chat.top_p).toBe(0.9); expect(chat.parallel_tool_calls).toBe(false); expect(chat.stream).toBe(true); expect(chat.stream_options).toEqual({ include_usage: true }); }); }); describe('chatResponseToResponses 非流式响应方向', () => { it('content/tool_calls/reasoning_content/usage 全量映射,length → incomplete', () => { const responses = chatResponseToResponses({ choices: [{ message: { content: 'hello', reasoning_content: 'thinking…', tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'shell', arguments: '{}' } }], }, finish_reason: 'stop', }], usage: { prompt_tokens: 3, completion_tokens: 4, total_tokens: 7 }, }, 'resp_x'); expect(responses.status).toBe('completed'); expect((responses.usage as Record).total_tokens).toBe(7); const output = responses.output as Array>; expect(output.map((item) => item.type)).toEqual(['reasoning', 'message', 'function_call']); expect(output[2]).toMatchObject({ call_id: 'call_1', name: 'shell', arguments: '{}' }); }); it('finish_reason=length → incomplete + max_output_tokens', () => { const responses = chatResponseToResponses({ choices: [{ message: { content: ' truncated' }, finish_reason: 'length' }], }, 'resp_y'); expect(responses.status).toBe('incomplete'); expect(responses.incomplete_details).toEqual({ reason: 'max_output_tokens' }); }); }); describe('ChatSseTranslator 流式状态机', () => { it('begin() 先发 created + in_progress,response.id 一致且必填', () => { const translator = new ChatSseTranslator(); const events = parseFrames(translator.begin()); expect(events.map((e) => e.event)).toEqual(['response.created', 'response.in_progress']); for (const e of events) expect(e.data.response.id).toMatch(/^resp_/); expect(translator.responseId).toBe(events[0].data.response.id); }); it('纯文本流 golden:added → delta* → done(完整文本) → completed(usage)', () => { const translator = new ChatSseTranslator(); translator.begin(); const frames = [ ...translator.push( chatSse(chatDelta({ role: 'assistant', content: '你' })) + chatSse(chatDelta({ content: '好' })) + chatSse(chatDelta({}, 'stop', { prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 })) + chatSse('[DONE]'), ), ...translator.finish(), ]; const events = parseFrames(frames); expect(events.map((e) => e.event)).toEqual([ 'response.output_item.added', 'response.output_text.delta', 'response.output_text.delta', 'response.output_item.done', 'response.completed', ]); const doneItem = events[3].data.item as Record; expect(doneItem).toMatchObject({ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: '你好' }], }); const completed = events[4].data.response as Record; expect(completed.id).toBe(translator.responseId); expect(completed.usage).toMatchObject({ input_tokens: 5, output_tokens: 2, total_tokens: 7 }); }); it('data 行跨分包也能正确解析', () => { const translator = new ChatSseTranslator(); const payload = chatDelta({ content: '分' }); const whole = chatSse(payload); const mid = Math.floor(whole.indexOf('choices') + 3); const frames = [...translator.push(whole.slice(0, mid)), ...translator.push(whole.slice(mid))]; const events = parseFrames(frames); expect(events.some((e) => e.event === 'response.output_text.delta' && e.data.delta === '分')).toBe(true); }); it('reasoning_content 先开先闭:reasoning item 先 done,再开 message item', () => { const translator = new ChatSseTranslator(); const frames = translator.push( chatSse(chatDelta({ reasoning_content: '想一步' })) + chatSse(chatDelta({ reasoning_content: '想两步' })) + chatSse(chatDelta({ content: '答' })) + chatSse('[DONE]'), ); translator.finish(); const events = parseFrames(frames); const kinds = events.map((e) => e.event); expect(kinds.filter((e) => e === 'response.reasoning_summary_text.delta')).toHaveLength(2); expect(kinds.indexOf('response.output_item.done')).toBeGreaterThan(-1); const reasoningDone = events.find((e) => e.event === 'response.output_item.done' && (e.data.item as Record).type === 'reasoning'); const messageAdded = events.find((e) => e.event === 'response.output_item.added' && (e.data.item as Record).type === 'message'); const reasoningDoneIndex = events.indexOf(reasoningDone!); const messageAddedIndex = events.indexOf(messageAdded!); expect(reasoningDoneIndex).toBeLessThan(messageAddedIndex); expect(reasoningDone?.data.item).toMatchObject({ summary: [{ type: 'summary_text', text: '想一步想两步' }] }); }); it('并行 tool_calls 按 index 归拢:参数跨 chunk 拼接,出现新 index 先收口前一个', () => { const translator = new ChatSseTranslator(); const frames = [ ...translator.push( chatSse(chatDelta({ tool_calls: [{ index: 0, id: 'call_a', function: { name: 'shell', arguments: '{"cm' } }] })) + chatSse(chatDelta({ tool_calls: [{ index: 0, function: { arguments: 'd":["ls"]}' } }, { index: 1, id: 'call_b', function: { name: 'read' } }] })) + chatSse(chatDelta({}, 'tool_calls')) + chatSse('[DONE]'), ), ...translator.finish(), ]; const events = parseFrames(frames); const doneCalls = events.filter((e) => e.event === 'response.output_item.done') .map((e) => e.data.item as Record) .filter((item) => item.type === 'function_call'); expect(doneCalls).toHaveLength(2); expect(doneCalls[0]).toMatchObject({ call_id: 'call_a', name: 'shell', arguments: '{"cmd":["ls"]}' }); expect(doneCalls[1]).toMatchObject({ call_id: 'call_b', name: 'read', arguments: '' }); }); it('finish_reason=length → completed 带 incomplete_details;无 usage 时省略 usage', () => { const translator = new ChatSseTranslator(); const frames = [ ...translator.push(chatSse(chatDelta({ content: 'x' }))), ...translator.push(chatSse(chatDelta({}, 'length'))), ...translator.finish(), ]; const events = parseFrames(frames); const completed = events.find((e) => e.event === 'response.completed')!; expect(completed.data.response.status).toBe('incomplete'); expect(completed.data.response.incomplete_details).toEqual({ reason: 'max_output_tokens' }); expect(completed.data.response.usage).toBeUndefined(); }); it('上游 EOF(无 [DONE])时 finish() 冲刷且幂等;坏 JSON 跳过;usage-only chunk 不产事件', () => { const translator = new ChatSseTranslator(); const bad = translator.push('data: {not-json\n\n'); expect(bad).toEqual([]); const usageOnly = translator.push(chatSse(JSON.stringify({ choices: [], usage: { prompt_tokens: 1, completion_tokens: 1 } }))); expect(usageOnly).toEqual([]); const first = translator.finish(); const second = translator.finish(); expect(second).toEqual([]); expect(first.join('')).toContain('response.completed'); }); it('fail() 产出 response.failed 且幂等;finish 后 fail 不再产帧', () => { const translator = new ChatSseTranslator(); const frames = translator.fail('上游炸了'); expect(parseFrames(frames)[0].data.response.error).toEqual({ code: 'upstream_error', message: '上游炸了' }); expect(translator.fail('again')).toEqual([]); const fresh = new ChatSseTranslator(); fresh.finish(); expect(fresh.fail('late')).toEqual([]); }); });