| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311 |
- 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<string, unknown>, finishReason?: string, usage?: unknown): string {
- const choice: Record<string, unknown> = { index: 0, delta };
- if (finishReason) choice.finish_reason = finishReason;
- const chunk: Record<string, unknown> = { id: 'chatcmpl_1', choices: [choice] };
- if (usage !== undefined) chunk.usage = usage;
- return JSON.stringify(chunk);
- }
- type EventTuple = Array<{ event: string; data: Record<string, any> }>;
- 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<string, unknown> };
- });
- }
- 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<Record<string, unknown>>;
- 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<string, unknown>).total_tokens).toBe(7);
- const output = responses.output as Array<Record<string, unknown>>;
- 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<string, unknown>;
- expect(doneItem).toMatchObject({
- type: 'message',
- role: 'assistant',
- content: [{ type: 'output_text', text: '你好' }],
- });
- const completed = events[4].data.response as Record<string, unknown>;
- 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<string, unknown>).type === 'reasoning');
- const messageAdded = events.find((e) => e.event === 'response.output_item.added' && (e.data.item as Record<string, unknown>).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<string, unknown>)
- .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([]);
- });
- });
|