translate.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. import { describe, expect, it } from 'vitest';
  2. import { ChatSseTranslator, chatResponseToResponses, responsesToChatRequest } from './translate';
  3. function chatSse(payload: string): string {
  4. return `data: ${payload}\n\n`;
  5. }
  6. function chatDelta(delta: Record<string, unknown>, finishReason?: string, usage?: unknown): string {
  7. const choice: Record<string, unknown> = { index: 0, delta };
  8. if (finishReason) choice.finish_reason = finishReason;
  9. const chunk: Record<string, unknown> = { id: 'chatcmpl_1', choices: [choice] };
  10. if (usage !== undefined) chunk.usage = usage;
  11. return JSON.stringify(chunk);
  12. }
  13. type EventTuple = Array<{ event: string; data: Record<string, any> }>;
  14. function parseFrames(frames: string[]): EventTuple {
  15. return frames.join('').split('\n\n').filter(Boolean).map((raw) => {
  16. const event = /^event: (.*)$/mu.exec(raw)?.[1] ?? '';
  17. const data = /^data: (.*)$/mu.exec(raw)?.[1] ?? '{}';
  18. return { event, data: JSON.parse(data) as Record<string, unknown> };
  19. });
  20. }
  21. describe('responsesToChatRequest 请求方向', () => {
  22. it('instructions → developer 消息;input 字符串 → user 消息', () => {
  23. const chat = responsesToChatRequest({ model: 'qwen', instructions: '你是助手', input: '你好' });
  24. expect(chat.model).toBe('qwen');
  25. expect(chat.messages).toEqual([
  26. { role: 'developer', content: '你是助手' },
  27. { role: 'user', content: '你好' },
  28. ]);
  29. expect(chat.stream).toBe(false);
  30. });
  31. it('message 数组 content:文本合并、单文本压字符串、图片转 image_url、未知 part 丢弃留诊断', () => {
  32. const dropped: string[] = [];
  33. const chat = responsesToChatRequest({
  34. model: 'qwen',
  35. input: [{
  36. type: 'message',
  37. role: 'user',
  38. content: [
  39. { type: 'input_text', text: '看图' },
  40. { type: 'input_image', image_url: 'http://x/y.png' },
  41. { type: 'input_image', image_url: { url: 'http://x/z.png' } },
  42. { type: 'input_file', file_id: 'f1' },
  43. { type: 'input_text', text: '回答我' },
  44. ],
  45. }],
  46. }, (m) => dropped.push(m));
  47. expect(chat.messages).toHaveLength(1);
  48. const content = chat.messages[0].content as Array<Record<string, unknown>>;
  49. expect(content).toEqual([
  50. { type: 'text', text: '看图' },
  51. { type: 'image_url', image_url: { url: 'http://x/y.png' } },
  52. { type: 'image_url', image_url: { url: 'http://x/z.png' } },
  53. { type: 'text', text: '回答我' },
  54. ]);
  55. expect(dropped.join(' ')).toContain('input_file×1');
  56. });
  57. it('function_call / function_call_output → assistant tool_calls 与 role:tool,call_id 原样对应', () => {
  58. const chat = responsesToChatRequest({
  59. model: 'qwen',
  60. input: [
  61. { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'ls' }] },
  62. { type: 'function_call', id: 'fc_1', call_id: 'call_9', name: 'shell', arguments: '{"cmd":["ls"]}' },
  63. { type: 'function_call_output', call_id: 'call_9', output: 'file-a\nfile-b' },
  64. ],
  65. });
  66. expect(chat.messages[1]).toEqual({
  67. role: 'assistant',
  68. content: null,
  69. tool_calls: [{ id: 'call_9', type: 'function', function: { name: 'shell', arguments: '{"cmd":["ls"]}' } }],
  70. });
  71. expect(chat.messages[2]).toEqual({ role: 'tool', tool_call_id: 'call_9', content: 'file-a\nfile-b' });
  72. });
  73. it('custom_tool_call 用 input 当 arguments;结构化 output 折叠成文本', () => {
  74. const chat = responsesToChatRequest({
  75. model: 'qwen',
  76. input: [
  77. { type: 'custom_tool_call', call_id: 'c1', name: 'patch', input: '*** Begin Patch' },
  78. { type: 'custom_tool_call_output', call_id: 'c1', output: [{ type: 'input_text', text: 'ok' }] },
  79. ],
  80. });
  81. expect(chat.messages[0].tool_calls?.[0].function.arguments).toBe('*** Begin Patch');
  82. expect(chat.messages[1].content).toBe('ok');
  83. });
  84. it('reasoning / web_search_call 丢弃并留诊断', () => {
  85. const dropped: string[] = [];
  86. const chat = responsesToChatRequest({
  87. model: 'qwen',
  88. input: [
  89. { type: 'reasoning', id: 'rs_1', summary: [{ type: 'summary_text', text: '想' }] },
  90. { type: 'message', role: 'user', content: 'hi' },
  91. { type: 'web_search_call', id: 'ws_1' },
  92. ],
  93. }, (m) => dropped.push(m));
  94. expect(chat.messages).toEqual([{ role: 'user', content: 'hi' }]);
  95. expect(dropped.join(' ')).toContain('reasoning×1');
  96. expect(dropped.join(' ')).toContain('web_search_call×1');
  97. });
  98. it('tools 扁平转嵌套,非 function 工具丢弃;tool_choice 各形态映射', () => {
  99. const dropped: string[] = [];
  100. const chat = responsesToChatRequest({
  101. model: 'qwen',
  102. input: 'hi',
  103. tools: [
  104. { type: 'function', name: 'shell', description: '跑命令', parameters: { type: 'object' }, strict: true },
  105. { type: 'web_search' },
  106. ],
  107. tool_choice: { type: 'function', name: 'shell' },
  108. }, (m) => dropped.push(m));
  109. expect(chat.tools).toEqual([{ type: 'function', function: { name: 'shell', description: '跑命令', parameters: { type: 'object' } } }]);
  110. expect(chat.tool_choice).toEqual({ type: 'function', function: { name: 'shell' } });
  111. expect(dropped.join(' ')).toContain('tools×1');
  112. });
  113. it('Responses 专有字段绝不外泄(白名单式构建)', () => {
  114. const chat = responsesToChatRequest({
  115. model: 'qwen',
  116. input: 'hi',
  117. stream: true,
  118. store: false,
  119. include: ['reasoning.encrypted_content'],
  120. reasoning: { effort: 'low', summary: 'auto' },
  121. prompt_cache_key: 'thread-1',
  122. client_metadata: { session_id: 's' },
  123. metadata: { a: 1 },
  124. text: { verbosity: 'low' },
  125. service_tier: 'default',
  126. previous_response_id: 'resp_old',
  127. max_output_tokens: 123,
  128. temperature: 0.5,
  129. top_p: 0.9,
  130. parallel_tool_calls: false,
  131. });
  132. const serialized = JSON.stringify(chat);
  133. for (const banned of ['store', 'include', 'reasoning', 'prompt_cache_key', 'client_metadata', 'metadata', 'text', 'service_tier', 'previous_response_id']) {
  134. expect(serialized).not.toContain(`"${banned}"`);
  135. }
  136. expect(chat.max_tokens).toBe(123);
  137. expect(chat.temperature).toBe(0.5);
  138. expect(chat.top_p).toBe(0.9);
  139. expect(chat.parallel_tool_calls).toBe(false);
  140. expect(chat.stream).toBe(true);
  141. expect(chat.stream_options).toEqual({ include_usage: true });
  142. });
  143. });
  144. describe('chatResponseToResponses 非流式响应方向', () => {
  145. it('content/tool_calls/reasoning_content/usage 全量映射,length → incomplete', () => {
  146. const responses = chatResponseToResponses({
  147. choices: [{
  148. message: {
  149. content: 'hello',
  150. reasoning_content: 'thinking…',
  151. tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'shell', arguments: '{}' } }],
  152. },
  153. finish_reason: 'stop',
  154. }],
  155. usage: { prompt_tokens: 3, completion_tokens: 4, total_tokens: 7 },
  156. }, 'resp_x');
  157. expect(responses.status).toBe('completed');
  158. expect((responses.usage as Record<string, unknown>).total_tokens).toBe(7);
  159. const output = responses.output as Array<Record<string, unknown>>;
  160. expect(output.map((item) => item.type)).toEqual(['reasoning', 'message', 'function_call']);
  161. expect(output[2]).toMatchObject({ call_id: 'call_1', name: 'shell', arguments: '{}' });
  162. });
  163. it('finish_reason=length → incomplete + max_output_tokens', () => {
  164. const responses = chatResponseToResponses({
  165. choices: [{ message: { content: ' truncated' }, finish_reason: 'length' }],
  166. }, 'resp_y');
  167. expect(responses.status).toBe('incomplete');
  168. expect(responses.incomplete_details).toEqual({ reason: 'max_output_tokens' });
  169. });
  170. });
  171. describe('ChatSseTranslator 流式状态机', () => {
  172. it('begin() 先发 created + in_progress,response.id 一致且必填', () => {
  173. const translator = new ChatSseTranslator();
  174. const events = parseFrames(translator.begin());
  175. expect(events.map((e) => e.event)).toEqual(['response.created', 'response.in_progress']);
  176. for (const e of events) expect(e.data.response.id).toMatch(/^resp_/);
  177. expect(translator.responseId).toBe(events[0].data.response.id);
  178. });
  179. it('纯文本流 golden:added → delta* → done(完整文本) → completed(usage)', () => {
  180. const translator = new ChatSseTranslator();
  181. translator.begin();
  182. const frames = [
  183. ...translator.push(
  184. chatSse(chatDelta({ role: 'assistant', content: '你' })) +
  185. chatSse(chatDelta({ content: '好' })) +
  186. chatSse(chatDelta({}, 'stop', { prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 })) +
  187. chatSse('[DONE]'),
  188. ),
  189. ...translator.finish(),
  190. ];
  191. const events = parseFrames(frames);
  192. expect(events.map((e) => e.event)).toEqual([
  193. 'response.output_item.added',
  194. 'response.output_text.delta',
  195. 'response.output_text.delta',
  196. 'response.output_item.done',
  197. 'response.completed',
  198. ]);
  199. const doneItem = events[3].data.item as Record<string, unknown>;
  200. expect(doneItem).toMatchObject({
  201. type: 'message',
  202. role: 'assistant',
  203. content: [{ type: 'output_text', text: '你好' }],
  204. });
  205. const completed = events[4].data.response as Record<string, unknown>;
  206. expect(completed.id).toBe(translator.responseId);
  207. expect(completed.usage).toMatchObject({ input_tokens: 5, output_tokens: 2, total_tokens: 7 });
  208. });
  209. it('data 行跨分包也能正确解析', () => {
  210. const translator = new ChatSseTranslator();
  211. const payload = chatDelta({ content: '分' });
  212. const whole = chatSse(payload);
  213. const mid = Math.floor(whole.indexOf('choices') + 3);
  214. const frames = [...translator.push(whole.slice(0, mid)), ...translator.push(whole.slice(mid))];
  215. const events = parseFrames(frames);
  216. expect(events.some((e) => e.event === 'response.output_text.delta' && e.data.delta === '分')).toBe(true);
  217. });
  218. it('reasoning_content 先开先闭:reasoning item 先 done,再开 message item', () => {
  219. const translator = new ChatSseTranslator();
  220. const frames = translator.push(
  221. chatSse(chatDelta({ reasoning_content: '想一步' })) +
  222. chatSse(chatDelta({ reasoning_content: '想两步' })) +
  223. chatSse(chatDelta({ content: '答' })) +
  224. chatSse('[DONE]'),
  225. );
  226. translator.finish();
  227. const events = parseFrames(frames);
  228. const kinds = events.map((e) => e.event);
  229. expect(kinds.filter((e) => e === 'response.reasoning_summary_text.delta')).toHaveLength(2);
  230. expect(kinds.indexOf('response.output_item.done')).toBeGreaterThan(-1);
  231. const reasoningDone = events.find((e) => e.event === 'response.output_item.done' && (e.data.item as Record<string, unknown>).type === 'reasoning');
  232. const messageAdded = events.find((e) => e.event === 'response.output_item.added' && (e.data.item as Record<string, unknown>).type === 'message');
  233. const reasoningDoneIndex = events.indexOf(reasoningDone!);
  234. const messageAddedIndex = events.indexOf(messageAdded!);
  235. expect(reasoningDoneIndex).toBeLessThan(messageAddedIndex);
  236. expect(reasoningDone?.data.item).toMatchObject({ summary: [{ type: 'summary_text', text: '想一步想两步' }] });
  237. });
  238. it('并行 tool_calls 按 index 归拢:参数跨 chunk 拼接,出现新 index 先收口前一个', () => {
  239. const translator = new ChatSseTranslator();
  240. const frames = [
  241. ...translator.push(
  242. chatSse(chatDelta({ tool_calls: [{ index: 0, id: 'call_a', function: { name: 'shell', arguments: '{"cm' } }] })) +
  243. chatSse(chatDelta({ tool_calls: [{ index: 0, function: { arguments: 'd":["ls"]}' } }, { index: 1, id: 'call_b', function: { name: 'read' } }] })) +
  244. chatSse(chatDelta({}, 'tool_calls')) +
  245. chatSse('[DONE]'),
  246. ),
  247. ...translator.finish(),
  248. ];
  249. const events = parseFrames(frames);
  250. const doneCalls = events.filter((e) => e.event === 'response.output_item.done')
  251. .map((e) => e.data.item as Record<string, unknown>)
  252. .filter((item) => item.type === 'function_call');
  253. expect(doneCalls).toHaveLength(2);
  254. expect(doneCalls[0]).toMatchObject({ call_id: 'call_a', name: 'shell', arguments: '{"cmd":["ls"]}' });
  255. expect(doneCalls[1]).toMatchObject({ call_id: 'call_b', name: 'read', arguments: '' });
  256. });
  257. it('finish_reason=length → completed 带 incomplete_details;无 usage 时省略 usage', () => {
  258. const translator = new ChatSseTranslator();
  259. const frames = [
  260. ...translator.push(chatSse(chatDelta({ content: 'x' }))),
  261. ...translator.push(chatSse(chatDelta({}, 'length'))),
  262. ...translator.finish(),
  263. ];
  264. const events = parseFrames(frames);
  265. const completed = events.find((e) => e.event === 'response.completed')!;
  266. expect(completed.data.response.status).toBe('incomplete');
  267. expect(completed.data.response.incomplete_details).toEqual({ reason: 'max_output_tokens' });
  268. expect(completed.data.response.usage).toBeUndefined();
  269. });
  270. it('上游 EOF(无 [DONE])时 finish() 冲刷且幂等;坏 JSON 跳过;usage-only chunk 不产事件', () => {
  271. const translator = new ChatSseTranslator();
  272. const bad = translator.push('data: {not-json\n\n');
  273. expect(bad).toEqual([]);
  274. const usageOnly = translator.push(chatSse(JSON.stringify({ choices: [], usage: { prompt_tokens: 1, completion_tokens: 1 } })));
  275. expect(usageOnly).toEqual([]);
  276. const first = translator.finish();
  277. const second = translator.finish();
  278. expect(second).toEqual([]);
  279. expect(first.join('')).toContain('response.completed');
  280. });
  281. it('fail() 产出 response.failed 且幂等;finish 后 fail 不再产帧', () => {
  282. const translator = new ChatSseTranslator();
  283. const frames = translator.fail('上游炸了');
  284. expect(parseFrames(frames)[0].data.response.error).toEqual({ code: 'upstream_error', message: '上游炸了' });
  285. expect(translator.fail('again')).toEqual([]);
  286. const fresh = new ChatSseTranslator();
  287. fresh.finish();
  288. expect(fresh.fail('late')).toEqual([]);
  289. });
  290. });