chatBridgeService.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  1. import { createServer, type Server } from 'node:http';
  2. import { afterEach, beforeEach, describe, expect, it } from 'vitest';
  3. import { ChatBridgeService } from './chatBridgeService';
  4. /** mock 上游:记录最近一次 /chat/completions 请求,按配置返回非流式或 SSE 流式 */
  5. let upstream: Server;
  6. let upstreamPort = 0;
  7. let lastUpstream: { url: string | null; auth: string | null; extra: string | null; body: Record<string, unknown> | null };
  8. let upstreamMode: 'json' | 'sse' | 'error' = 'json';
  9. const SSE_BODY = [
  10. `data: ${JSON.stringify({ choices: [{ delta: { content: '你好' }, finish_reason: null }] })}`,
  11. '',
  12. `data: ${JSON.stringify({ choices: [{ delta: { content: '世界' }, finish_reason: null }] })}`,
  13. '',
  14. `data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: 'stop' }], usage: { prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 } })}`,
  15. '',
  16. 'data: [DONE]',
  17. '',
  18. '',
  19. ].join('\n');
  20. beforeEach(async () => {
  21. upstreamMode = 'json';
  22. lastUpstream = { url: null, auth: null, extra: null, body: null };
  23. upstream = createServer((req, res) => {
  24. const chunks: Buffer[] = [];
  25. req.on('data', (chunk: Buffer) => chunks.push(chunk));
  26. req.on('end', () => {
  27. lastUpstream = {
  28. url: req.url ?? null,
  29. auth: req.headers.authorization ?? null,
  30. extra: (req.headers['x-extra'] as string) ?? null,
  31. body: JSON.parse(Buffer.concat(chunks).toString('utf8') || 'null'),
  32. };
  33. if (upstreamMode === 'error') {
  34. res.writeHead(500, { 'content-type': 'application/json' });
  35. res.end(JSON.stringify({ error: 'boom' }));
  36. return;
  37. }
  38. if (upstreamMode === 'sse') {
  39. res.writeHead(200, { 'content-type': 'text/event-stream' });
  40. res.end(SSE_BODY);
  41. return;
  42. }
  43. res.writeHead(200, { 'content-type': 'application/json' });
  44. res.end(
  45. JSON.stringify({
  46. id: 'chatcmpl-1',
  47. created: 1700000000,
  48. model: 'qwen',
  49. choices: [{ finish_reason: 'stop', message: { role: 'assistant', content: '非流式回复' } }],
  50. usage: { prompt_tokens: 3, completion_tokens: 4, total_tokens: 7 },
  51. }),
  52. );
  53. });
  54. });
  55. await new Promise<void>((resolve) => upstream.listen(0, '127.0.0.1', resolve));
  56. const address = upstream.address();
  57. upstreamPort = typeof address === 'object' && address ? address.port : 0;
  58. });
  59. afterEach(async () => {
  60. await new Promise<void>((resolve) => upstream.close(() => resolve()));
  61. });
  62. function makeBridge(): Promise<ChatBridgeService> {
  63. return Promise.resolve(new ChatBridgeService());
  64. }
  65. describe('ChatBridgeService', () => {
  66. it('非流式:请求翻译后转发上游,响应翻译回 Responses 形状,头原样透传', async () => {
  67. const bridge = await makeBridge();
  68. const info = await bridge.start({
  69. upstreamBaseUrl: `http://127.0.0.1:${upstreamPort}/v1`,
  70. headers: { 'X-Extra': 'yes' },
  71. });
  72. try {
  73. const response = await fetch(`${info.url}/responses`, {
  74. method: 'POST',
  75. headers: { 'content-type': 'application/json', authorization: 'Bearer sk-live' },
  76. body: JSON.stringify({
  77. model: 'qwen',
  78. instructions: '你是助手',
  79. input: '你好',
  80. max_output_tokens: 64,
  81. }),
  82. });
  83. expect(response.status).toBe(200);
  84. // 上游收到的是 chat 形状
  85. expect(lastUpstream.url).toBe('/v1/chat/completions');
  86. expect(lastUpstream.auth).toBe('Bearer sk-live');
  87. expect(lastUpstream.extra).toBe('yes');
  88. expect(lastUpstream.body).toMatchObject({
  89. model: 'qwen',
  90. max_tokens: 64,
  91. messages: [
  92. { role: 'developer', content: '你是助手' },
  93. { role: 'user', content: '你好' },
  94. ],
  95. });
  96. // 下游拿到的是 responses 形状
  97. const body = (await response.json()) as Record<string, unknown>;
  98. expect(body).toMatchObject({
  99. object: 'response',
  100. status: 'completed',
  101. usage: { input_tokens: 3, output_tokens: 4, total_tokens: 7 },
  102. });
  103. const output = body.output as Array<Record<string, unknown>>;
  104. expect((output[0].content as Array<{ text: string }>)[0].text).toBe('非流式回复');
  105. } finally {
  106. await bridge.stop();
  107. }
  108. });
  109. it('流式:上游 SSE 逐事件翻译下发,completed 带 usage', async () => {
  110. upstreamMode = 'sse';
  111. const bridge = await makeBridge();
  112. const info = await bridge.start({ upstreamBaseUrl: `http://127.0.0.1:${upstreamPort}/v1` });
  113. try {
  114. const response = await fetch(`${info.url}/responses`, {
  115. method: 'POST',
  116. headers: { 'content-type': 'application/json' },
  117. body: JSON.stringify({ model: 'qwen', input: '你好', stream: true }),
  118. });
  119. expect(response.status).toBe(200);
  120. expect(response.headers.get('content-type')).toContain('text/event-stream');
  121. const text = await response.text();
  122. const events = text
  123. .split('\n\n')
  124. .filter((block) => block.trim())
  125. .map((block) => {
  126. const eventMatch = block.match(/^event: (.+)$/mu);
  127. const dataMatch = block.match(/^data: (.+)$/mu);
  128. return { event: eventMatch?.[1], data: JSON.parse(dataMatch?.[1] ?? '{}') as Record<string, unknown> };
  129. });
  130. const types = events.map((item) => item.event);
  131. expect(types[0]).toBe('response.created');
  132. expect(types.at(-1)).toBe('response.completed');
  133. // 请求侧补了 include_usage
  134. expect(lastUpstream.body?.stream_options).toEqual({ include_usage: true });
  135. const deltas = events
  136. .filter((item) => item.event === 'response.output_text.delta')
  137. .map((item) => item.data.delta);
  138. expect(deltas.join('')).toBe('你好世界');
  139. const completed = events.at(-1)?.data.response as Record<string, unknown>;
  140. expect(completed.usage).toEqual({ input_tokens: 5, output_tokens: 2, total_tokens: 7 });
  141. } finally {
  142. await bridge.stop();
  143. }
  144. });
  145. it('上游非 2xx:状态码与错误体透回', async () => {
  146. upstreamMode = 'error';
  147. const bridge = await makeBridge();
  148. const info = await bridge.start({ upstreamBaseUrl: `http://127.0.0.1:${upstreamPort}/v1` });
  149. try {
  150. const response = await fetch(`${info.url}/responses`, {
  151. method: 'POST',
  152. headers: { 'content-type': 'application/json' },
  153. body: JSON.stringify({ model: 'qwen', input: 'x' }),
  154. });
  155. expect(response.status).toBe(500);
  156. const body = (await response.json()) as { error: { message: string } };
  157. expect(body.error.message).toContain('500');
  158. } finally {
  159. await bridge.stop();
  160. }
  161. });
  162. it('只接 POST /v1/responses;stop 后端口释放', async () => {
  163. const bridge = await makeBridge();
  164. const info = await bridge.start({ upstreamBaseUrl: `http://127.0.0.1:${upstreamPort}/v1` });
  165. const wrong = await fetch(`${info.url}/models`);
  166. expect(wrong.status).toBe(404);
  167. await bridge.stop();
  168. await expect(fetch(`${info.url}/responses`, { method: 'POST' })).rejects.toThrow();
  169. });
  170. });
  171. /**
  172. * Codex 等不到就会挂断重来,而自部署的多是单槽推理:
  173. * 桥接不取消上游,被放弃的那次生成就继续占着槽,下一次请求排在它后面 —— 越重试越慢。
  174. */
  175. describe('Codex 提前挂断时取消上游', () => {
  176. it('上游请求在挂断后立即中止', async () => {
  177. let diedAt = 0;
  178. const neverEnds = createServer((req, res) => {
  179. req.resume();
  180. req.on('end', () => {
  181. res.writeHead(200, { 'content-type': 'text/event-stream' });
  182. res.write(`data: ${JSON.stringify({ choices: [{ delta: { content: '第一段' }, finish_reason: null }] })}\n\n`);
  183. const timer = setInterval(() => res.write(': tick\n\n'), 50);
  184. const stop = (): void => {
  185. clearInterval(timer);
  186. if (!diedAt) diedAt = Date.now();
  187. };
  188. res.on('close', stop);
  189. req.on('aborted', stop);
  190. });
  191. });
  192. await new Promise<void>((resolve) => neverEnds.listen(0, '127.0.0.1', resolve));
  193. const port = (neverEnds.address() as { port: number }).port;
  194. const bridge = new ChatBridgeService();
  195. try {
  196. const info = await bridge.start({ upstreamBaseUrl: `http://127.0.0.1:${port}/v1` });
  197. const controller = new AbortController();
  198. const response = await fetch(`${info.url}/responses`, {
  199. method: 'POST',
  200. headers: { 'content-type': 'application/json' },
  201. body: JSON.stringify({ model: 'm', input: '讲个长故事', stream: true }),
  202. signal: controller.signal,
  203. });
  204. // 收到第一个事件再挂断,确保桥接确实已经在上游吞吐数据
  205. await response.body?.getReader().read();
  206. const abortAt = Date.now();
  207. controller.abort();
  208. const deadline = abortAt + 2_000;
  209. while (!diedAt && Date.now() < deadline) {
  210. await new Promise((resolve) => setTimeout(resolve, 25));
  211. }
  212. expect(diedAt).toBeGreaterThan(0);
  213. expect(diedAt - abortAt).toBeLessThan(1_000);
  214. } finally {
  215. await bridge.stop();
  216. await new Promise<void>((resolve) => neverEnds.close(() => resolve()));
  217. }
  218. }, 20_000);
  219. });
  220. /**
  221. * 单槽自部署服务光 prefill 就要几分钟才吐首个字节。桥接要是等上游响应头到手才给 Codex 写东西,
  222. * Codex 面对的就是一条一个字节都没有的死连接 —— 它会掐了重发,于是越重试越慢。
  223. * 流式请求一到就先把 SSE 建起来,上游慢不影响客户端侧有字节可收。
  224. */
  225. describe('上游首字节很慢时先把流建起来', () => {
  226. it('立刻回 response.created,不等上游', async () => {
  227. const slowUpstream = createServer((req, res) => {
  228. req.resume();
  229. req.on('end', () => {
  230. setTimeout(() => {
  231. res.writeHead(200, { 'content-type': 'text/event-stream' });
  232. res.end(SSE_BODY);
  233. }, 6_000);
  234. });
  235. });
  236. await new Promise<void>((resolve) => slowUpstream.listen(0, '127.0.0.1', resolve));
  237. const port = (slowUpstream.address() as { port: number }).port;
  238. const bridge = new ChatBridgeService();
  239. const controller = new AbortController();
  240. try {
  241. const info = await bridge.start({ upstreamBaseUrl: `http://127.0.0.1:${port}/v1` });
  242. const startedAt = Date.now();
  243. const response = await fetch(`${info.url}/responses`, {
  244. method: 'POST',
  245. headers: { 'content-type': 'application/json' },
  246. body: JSON.stringify({ model: 'm', input: '讲个故事', stream: true }),
  247. signal: controller.signal,
  248. });
  249. const reader = response.body?.getReader();
  250. if (!reader) throw new Error('没有响应体');
  251. const first = await reader.read();
  252. const firstAt = Date.now() - startedAt;
  253. const firstText = new TextDecoder().decode(first.value ?? new Uint8Array());
  254. expect(firstText).toContain('event: response.created');
  255. expect(firstAt).toBeLessThan(1_500);
  256. // 流没断:上游 6 秒后才回,最终回复照样要翻给客户端
  257. let all = firstText;
  258. for (;;) {
  259. const { done, value } = await reader.read();
  260. if (done) break;
  261. all += new TextDecoder().decode(value);
  262. }
  263. expect(all).toContain('你好');
  264. expect(all).toContain('event: response.completed');
  265. } finally {
  266. controller.abort();
  267. await bridge.stop();
  268. await new Promise<void>((resolve) => slowUpstream.close(() => resolve()));
  269. }
  270. }, 30_000);
  271. });