chatBridgeTranslate.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380
  1. import { describe, expect, it } from 'vitest';
  2. import {
  3. chatResponseToResponses,
  4. responsesRequestToChat,
  5. ResponsesSseTranslator,
  6. type BridgeSseEvent,
  7. type ResponsesCreateParams,
  8. } from './chatBridgeTranslate';
  9. function sseLines(events: Array<Record<string, unknown>>): string {
  10. return events.map((data) => `data: ${JSON.stringify(data)}\n\n`).join('');
  11. }
  12. function types(events: BridgeSseEvent[]): string[] {
  13. return events.map((item) => item.event);
  14. }
  15. describe('responsesRequestToChat', () => {
  16. it('instructions 转 developer message,input 字符串转 user message', () => {
  17. const chat = responsesRequestToChat({ model: 'qwen', instructions: '你是助手', input: '你好' });
  18. expect(chat.model).toBe('qwen');
  19. expect(chat.messages).toEqual([
  20. { role: 'developer', content: '你是助手' },
  21. { role: 'user', content: '你好' },
  22. ]);
  23. });
  24. it('message item:字符串 content 直传;easy-input-message(无 type)按 message 处理', () => {
  25. const chat = responsesRequestToChat({
  26. input: [
  27. { type: 'message', role: 'user', content: '直接字符串' },
  28. { role: 'assistant', content: '没有 type 字段' },
  29. ],
  30. });
  31. expect(chat.messages).toEqual([
  32. { role: 'user', content: '直接字符串' },
  33. { role: 'assistant', content: '没有 type 字段' },
  34. ]);
  35. });
  36. it('content parts:input_text 合并;单文本压成 string;input_file 丢弃并留诊断', () => {
  37. const dropped: string[] = [];
  38. const chat = responsesRequestToChat(
  39. {
  40. input: [
  41. {
  42. type: 'message',
  43. role: 'user',
  44. content: [
  45. { type: 'input_text', text: '第一段' },
  46. { type: 'input_file', file_url: 'file:///x.pdf' },
  47. { type: 'input_text', text: '第二段' },
  48. ],
  49. },
  50. {
  51. type: 'message',
  52. role: 'user',
  53. content: [{ type: 'input_text', text: '唯一文本' }],
  54. },
  55. ],
  56. },
  57. (line) => dropped.push(line),
  58. );
  59. expect(chat.messages[0]).toEqual({
  60. role: 'user',
  61. content: [
  62. { type: 'text', text: '第一段' },
  63. { type: 'text', text: '第二段' },
  64. ],
  65. });
  66. expect(chat.messages[1]).toEqual({ role: 'user', content: '唯一文本' });
  67. expect(dropped.some((line) => line.includes('input_file'))).toBe(true);
  68. });
  69. it('function_call / function_call_output / reasoning 的映射与丢弃', () => {
  70. const dropped: string[] = [];
  71. const chat = responsesRequestToChat(
  72. {
  73. input: [
  74. { type: 'reasoning', summary: [] },
  75. { type: 'function_call', call_id: 'call_1', name: 'exec', arguments: '{"cmd":"ls"}' },
  76. { type: 'function_call_output', call_id: 'call_1', output: 'ok' },
  77. ],
  78. },
  79. (line) => dropped.push(line),
  80. );
  81. expect(chat.messages).toEqual([
  82. {
  83. role: 'assistant',
  84. content: null,
  85. tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'exec', arguments: '{"cmd":"ls"}' } }],
  86. },
  87. { role: 'tool', tool_call_id: 'call_1', content: 'ok' },
  88. ]);
  89. expect(dropped.some((line) => line.includes('reasoning'))).toBe(true);
  90. });
  91. it('tools 扁平转嵌套;tool_choice 各形态;max_output_tokens 转 max_tokens', () => {
  92. const chat = responsesRequestToChat({
  93. input: 'x',
  94. tools: [
  95. { type: 'function', name: 'exec', description: '执行', parameters: { type: 'object' } },
  96. { type: 'web_search' },
  97. ],
  98. tool_choice: { type: 'function', name: 'exec' },
  99. max_output_tokens: 1024,
  100. temperature: 0.3,
  101. top_p: 0.9,
  102. parallel_tool_calls: false,
  103. });
  104. expect(chat.tools).toEqual([
  105. { type: 'function', function: { name: 'exec', description: '执行', parameters: { type: 'object' } } },
  106. ]);
  107. expect(chat.tool_choice).toEqual({ type: 'function', function: { name: 'exec' } });
  108. expect(chat.max_tokens).toBe(1024);
  109. expect(chat.temperature).toBe(0.3);
  110. expect(chat.top_p).toBe(0.9);
  111. expect(chat.parallel_tool_calls).toBe(false);
  112. });
  113. it('tool_choice 字符串直传;无法识别的形态丢弃', () => {
  114. expect(responsesRequestToChat({ input: 'x', tool_choice: 'required' }).tool_choice).toBe('required');
  115. expect(
  116. responsesRequestToChat({ input: 'x', tool_choice: { type: 'allowed_tools', tools: [] } }).tool_choice,
  117. ).toBeUndefined();
  118. });
  119. it('stream 时补 stream_options.include_usage;responses 专有字段不外泄', () => {
  120. const chat = responsesRequestToChat({
  121. input: 'x',
  122. stream: true,
  123. previous_response_id: 'resp_x',
  124. store: false,
  125. metadata: { a: 1 },
  126. reasoning: { effort: 'high' },
  127. include: ['reasoning.encrypted_content'],
  128. truncation: 'auto',
  129. });
  130. expect(chat.stream).toBe(true);
  131. expect(chat.stream_options).toEqual({ include_usage: true });
  132. const raw = JSON.stringify(chat);
  133. // 注意要用带冒号的键名匹配:裸 'include' 会误伤 stream_options 的 include_usage
  134. for (const key of ['previous_response_id', 'store', 'metadata', 'reasoning', 'include', 'truncation']) {
  135. expect(raw).not.toContain(`"${key}":`);
  136. }
  137. });
  138. });
  139. describe('chatResponseToResponses', () => {
  140. const req: ResponsesCreateParams = { model: 'qwen', input: 'x' };
  141. it('content / tool_calls / reasoning_content / usage 全量映射', () => {
  142. const response = chatResponseToResponses(
  143. {
  144. id: 'chatcmpl-1',
  145. created: 1700000000,
  146. model: 'qwen',
  147. choices: [
  148. {
  149. finish_reason: 'tool_calls',
  150. message: {
  151. reasoning_content: '想一想',
  152. content: '好的',
  153. tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'exec', arguments: '{}' } }],
  154. },
  155. },
  156. ],
  157. usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
  158. },
  159. req,
  160. );
  161. expect(response).toMatchObject({
  162. id: 'chatcmpl-1',
  163. object: 'response',
  164. created_at: 1700000000,
  165. model: 'qwen',
  166. status: 'completed',
  167. usage: { input_tokens: 10, output_tokens: 20, total_tokens: 30 },
  168. });
  169. const output = response.output as Array<Record<string, unknown>>;
  170. expect(output.map((item) => item.type)).toEqual(['reasoning', 'message', 'function_call']);
  171. expect(output[1]).toMatchObject({
  172. role: 'assistant',
  173. status: 'completed',
  174. content: [{ type: 'output_text', text: '好的' }],
  175. });
  176. expect(output[2]).toMatchObject({ call_id: 'call_1', name: 'exec', arguments: '{}' });
  177. });
  178. it('finish_reason=length 判 incomplete;无 usage 填全零', () => {
  179. const response = chatResponseToResponses(
  180. { choices: [{ finish_reason: 'length', message: { content: '截断' } }] },
  181. req,
  182. );
  183. expect(response.status).toBe('incomplete');
  184. expect(response.incomplete_details).toEqual({ reason: 'max_output_tokens' });
  185. expect(response.usage).toEqual({ input_tokens: 0, output_tokens: 0, total_tokens: 0 });
  186. });
  187. });
  188. describe('ResponsesSseTranslator', () => {
  189. const req: ResponsesCreateParams = { model: 'qwen', input: 'x', stream: true };
  190. it('纯文本流:完整 golden 事件序列', () => {
  191. const translator = new ResponsesSseTranslator(req);
  192. const events = translator.push(
  193. sseLines([
  194. { choices: [{ delta: { role: 'assistant', content: '' }, finish_reason: null }] },
  195. { choices: [{ delta: { content: '你好' }, finish_reason: null }] },
  196. { choices: [{ delta: { content: ',世界' }, finish_reason: null }] },
  197. { choices: [{ delta: {}, finish_reason: 'stop' }], usage: { prompt_tokens: 3, completion_tokens: 2, total_tokens: 5 } },
  198. ]) + 'data: [DONE]\n\n',
  199. );
  200. expect(types(events)).toEqual([
  201. 'response.created',
  202. 'response.in_progress',
  203. 'response.output_item.added',
  204. 'response.content_part.added',
  205. 'response.output_text.delta',
  206. 'response.output_text.delta',
  207. 'response.output_text.done',
  208. 'response.content_part.done',
  209. 'response.output_item.done',
  210. 'response.completed',
  211. ]);
  212. const completed = events.at(-1)?.data.response as Record<string, unknown>;
  213. expect(completed.status).toBe('completed');
  214. expect(completed.usage).toEqual({ input_tokens: 3, output_tokens: 2, total_tokens: 5 });
  215. const output = completed.output as Array<Record<string, unknown>>;
  216. expect(output).toHaveLength(1);
  217. expect((output[0].content as Array<{ text: string }>)[0].text).toBe('你好,世界');
  218. });
  219. it('data: 行跨分包拼接', () => {
  220. const translator = new ResponsesSseTranslator(req);
  221. const chunk = `data: ${JSON.stringify({ choices: [{ delta: { content: '整段' } }] })}`;
  222. const half = Math.floor(chunk.length / 2);
  223. const first = translator.push(chunk.slice(0, half));
  224. const second = translator.push(chunk.slice(half) + '\n\ndata: [DONE]\n\n');
  225. expect(types([...first, ...second])).toEqual([
  226. 'response.created',
  227. 'response.in_progress',
  228. 'response.output_item.added',
  229. 'response.content_part.added',
  230. 'response.output_text.delta',
  231. 'response.output_text.done',
  232. 'response.content_part.done',
  233. 'response.output_item.done',
  234. 'response.completed',
  235. ]);
  236. });
  237. it('两个并行 tool_calls 按 index 交错也能分别归拢', () => {
  238. const translator = new ResponsesSseTranslator(req);
  239. const events = translator.push(
  240. sseLines([
  241. {
  242. choices: [
  243. {
  244. delta: {
  245. tool_calls: [
  246. { index: 0, id: 'call_a', function: { name: 'exec', arguments: '{"a":' } },
  247. { index: 1, id: 'call_b', function: { name: 'read', arguments: '{"b":' } },
  248. ],
  249. },
  250. },
  251. ],
  252. },
  253. {
  254. choices: [
  255. {
  256. delta: {
  257. tool_calls: [
  258. { index: 1, function: { arguments: '1}' } },
  259. { index: 0, function: { arguments: '2}' } },
  260. ],
  261. },
  262. finish_reason: 'tool_calls',
  263. },
  264. ],
  265. },
  266. ]) + 'data: [DONE]\n\n',
  267. );
  268. expect(types(events)).toEqual([
  269. 'response.created',
  270. 'response.in_progress',
  271. 'response.output_item.added',
  272. 'response.function_call_arguments.delta',
  273. 'response.output_item.added',
  274. 'response.function_call_arguments.delta',
  275. 'response.function_call_arguments.delta',
  276. 'response.function_call_arguments.delta',
  277. 'response.function_call_arguments.done',
  278. 'response.output_item.done',
  279. 'response.function_call_arguments.done',
  280. 'response.output_item.done',
  281. 'response.completed',
  282. ]);
  283. const completed = events.at(-1)?.data.response as Record<string, unknown>;
  284. const output = completed.output as Array<Record<string, unknown>>;
  285. expect(output.map((item) => [item.call_id, item.name, item.arguments])).toEqual([
  286. ['call_a', 'exec', '{"a":2}'],
  287. ['call_b', 'read', '{"b":1}'],
  288. ]);
  289. });
  290. it('reasoning_content 流转 reasoning item,正文开始前闭合', () => {
  291. const translator = new ResponsesSseTranslator(req);
  292. const events = translator.push(
  293. sseLines([
  294. { choices: [{ delta: { reasoning_content: '先想' } }] },
  295. { choices: [{ delta: { reasoning_content: '一下' } }] },
  296. { choices: [{ delta: { content: '结论' } }] },
  297. { choices: [{ delta: {}, finish_reason: 'stop' }] },
  298. ]) + 'data: [DONE]\n\n',
  299. );
  300. expect(types(events)).toEqual([
  301. 'response.created',
  302. 'response.in_progress',
  303. 'response.output_item.added',
  304. 'response.reasoning_summary_part.added',
  305. 'response.reasoning_summary_text.delta',
  306. 'response.reasoning_summary_text.delta',
  307. 'response.reasoning_summary_text.done',
  308. 'response.reasoning_summary_part.done',
  309. 'response.output_item.done',
  310. 'response.output_item.added',
  311. 'response.content_part.added',
  312. 'response.output_text.delta',
  313. 'response.output_text.done',
  314. 'response.content_part.done',
  315. 'response.output_item.done',
  316. 'response.completed',
  317. ]);
  318. const completed = events.at(-1)?.data.response as Record<string, unknown>;
  319. const output = completed.output as Array<Record<string, unknown>>;
  320. expect(output[0]).toMatchObject({ type: 'reasoning', summary: [{ type: 'summary_text', text: '先想一下' }] });
  321. });
  322. it('上游没发 [DONE]:finish() 冲刷闭合;无 usage 填全零', () => {
  323. const translator = new ResponsesSseTranslator(req);
  324. translator.push(sseLines([{ choices: [{ delta: { content: '半截' } }] }]));
  325. const events = translator.finish();
  326. expect(types(events)).toEqual([
  327. 'response.output_text.done',
  328. 'response.content_part.done',
  329. 'response.output_item.done',
  330. 'response.completed',
  331. ]);
  332. const completed = events.at(-1)?.data.response as Record<string, unknown>;
  333. expect(completed.usage).toEqual({ input_tokens: 0, output_tokens: 0, total_tokens: 0 });
  334. // 再次 finish / push 不再重复发 completed
  335. expect(translator.finish()).toEqual([]);
  336. });
  337. it('坏 JSON 行跳过不杀流;usage-only chunk 不产事件', () => {
  338. const dropped: string[] = [];
  339. const translator = new ResponsesSseTranslator(req, (line) => dropped.push(line));
  340. const events = translator.push(
  341. 'data: {bad json\n\n' +
  342. sseLines([{ usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } }]) +
  343. sseLines([{ choices: [{ delta: { content: '好' }, finish_reason: 'stop' }] }]) +
  344. 'data: [DONE]\n\n',
  345. );
  346. expect(dropped.some((line) => line.includes('无法解析'))).toBe(true);
  347. const completed = events.at(-1)?.data.response as Record<string, unknown>;
  348. expect(completed.usage).toEqual({ input_tokens: 1, output_tokens: 1, total_tokens: 2 });
  349. });
  350. it('fail() 补 response.failed 并带错误信息', () => {
  351. const translator = new ResponsesSseTranslator(req);
  352. translator.push(sseLines([{ choices: [{ delta: { content: '写了一半' } }] }]));
  353. const events = translator.fail('上游连接中断');
  354. expect(types(events)).toEqual(['response.failed']);
  355. const response = events[0].data.response as Record<string, unknown>;
  356. expect(response.status).toBe('failed');
  357. expect(response.error).toMatchObject({ code: 'upstream_error', message: '上游连接中断' });
  358. expect(translator.finish()).toEqual([]);
  359. });
  360. });