| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380 |
- import { describe, expect, it } from 'vitest';
- import {
- chatResponseToResponses,
- responsesRequestToChat,
- ResponsesSseTranslator,
- type BridgeSseEvent,
- type ResponsesCreateParams,
- } from './chatBridgeTranslate';
- function sseLines(events: Array<Record<string, unknown>>): 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<Record<string, unknown>>;
- 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<string, unknown>;
- expect(completed.status).toBe('completed');
- expect(completed.usage).toEqual({ input_tokens: 3, output_tokens: 2, total_tokens: 5 });
- const output = completed.output as Array<Record<string, unknown>>;
- 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<string, unknown>;
- const output = completed.output as Array<Record<string, unknown>>;
- 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<string, unknown>;
- const output = completed.output as Array<Record<string, unknown>>;
- 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<string, unknown>;
- 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<string, unknown>;
- 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<string, unknown>;
- expect(response.status).toBe('failed');
- expect(response.error).toMatchObject({ code: 'upstream_error', message: '上游连接中断' });
- expect(translator.finish()).toEqual([]);
- });
- });
|