chatBridge.itest.ts 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114
  1. import { createServer, type Server } from 'node:http';
  2. import { mkdtemp, rm } from 'node:fs/promises';
  3. import { tmpdir } from 'node:os';
  4. import { join } from 'node:path';
  5. import { afterAll, beforeAll, describe, expect, it } from 'vitest';
  6. import { chatBridge } from './chatBridgeService';
  7. import { ensureCodexHome } from './codexHome';
  8. import { CodexRuntime } from './codexRuntime';
  9. import { applyProvider, readAppliedProvider } from './providerService';
  10. /**
  11. * 桥接集成冒烟:mock Chat 上游(/chat/completions 返回 SSE 固定文本,其余路由 404)
  12. * → 内置桥接 → 真实 codex app-server 跑完整 turn。
  13. * 同时验证「Codex 无状态调用」假设:第二轮 turn 的 messages 里必须携带第一轮内容。
  14. * 默认 npm test 不跑(.itest.ts),用 npm run smoke:codex 单独跑。
  15. */
  16. let upstream: Server;
  17. let upstreamPort = 0;
  18. let lastChatBody: Record<string, unknown> | null = null;
  19. let tmpRoot: string;
  20. const REPLY = '桥接冒烟固定回复';
  21. const SSE_BODY = [
  22. `data: ${JSON.stringify({ choices: [{ delta: { role: 'assistant', content: '' }, finish_reason: null }] })}`,
  23. '',
  24. `data: ${JSON.stringify({ choices: [{ delta: { content: REPLY }, finish_reason: null }] })}`,
  25. '',
  26. `data: ${JSON.stringify({
  27. choices: [{ delta: {}, finish_reason: 'stop' }],
  28. usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
  29. })}`,
  30. '',
  31. 'data: [DONE]',
  32. '',
  33. '',
  34. ].join('\n');
  35. beforeAll(async () => {
  36. upstream = createServer((req, res) => {
  37. if (req.url?.endsWith('/chat/completions')) {
  38. const chunks: Buffer[] = [];
  39. req.on('data', (chunk: Buffer) => chunks.push(chunk));
  40. req.on('end', () => {
  41. lastChatBody = JSON.parse(Buffer.concat(chunks).toString('utf8') || 'null');
  42. res.writeHead(200, { 'content-type': 'text/event-stream' });
  43. res.end(SSE_BODY);
  44. });
  45. return;
  46. }
  47. // 除 Chat 外的路由一律 404(探活的 /models 也算活着,不影响判定)
  48. req.resume();
  49. res.writeHead(404, { 'content-type': 'application/json' });
  50. res.end('{}');
  51. });
  52. await new Promise<void>((resolve) => upstream.listen(0, '127.0.0.1', resolve));
  53. const address = upstream.address();
  54. upstreamPort = typeof address === 'object' && address ? address.port : 0;
  55. tmpRoot = await mkdtemp(join(tmpdir(), 'zsjz-codex-bridge-'));
  56. });
  57. afterAll(async () => {
  58. await chatBridge.stop();
  59. await new Promise<void>((resolve) => upstream?.close(() => resolve()));
  60. await rm(tmpRoot, { recursive: true, force: true }).catch(() => undefined);
  61. });
  62. describe('内置桥接:Chat 端点接入', () => {
  63. it('桥接应用成功,真实 Codex 经桥接跑通两轮 turn(验证无状态全量历史)', async () => {
  64. const codexHome = await ensureCodexHome();
  65. const runtime = new CodexRuntime({ codexHome });
  66. try {
  67. const applied = await applyProvider(runtime, {
  68. modelId: 'bridge-smoke-model',
  69. name: '桥接冒烟',
  70. baseUrl: `http://127.0.0.1:${upstreamPort}/v1`,
  71. });
  72. expect(applied.bridged).toBe(true);
  73. // 落盘存上游真实地址;桥接的本地 URL 不落盘
  74. expect(applied.baseUrl).toBe(`http://127.0.0.1:${upstreamPort}/v1`);
  75. expect(chatBridge.info).not.toBeNull();
  76. expect(JSON.stringify(await readAppliedProvider())).not.toContain(chatBridge.info!.url);
  77. // 生效配置:必须是自定义 zsjz provider 指向桥接地址(不能残留内置 provider 绕过桥接)
  78. const config = await runtime.readConfig();
  79. expect(config.model_provider).toBe('zsjz');
  80. const entry = (config.model_providers as Record<string, Record<string, unknown>> | undefined)?.zsjz;
  81. expect(entry?.base_url).toBe(chatBridge.info!.url);
  82. expect(entry?.wire_api).toBe('responses');
  83. // 第一轮:Codex → 桥接 → mock 上游 SSE → 翻译回 Responses 流
  84. const threadId = await runtime.startThread({ cwd: tmpRoot });
  85. const first = await runtime.runTurn({ threadId, prompt: '第一句话', timeoutMs: 120_000 });
  86. expect(first.status).toBe('completed');
  87. expect(first.text).toContain(REPLY);
  88. expect(lastChatBody?.model).toBe('bridge-smoke-model');
  89. expect(lastChatBody?.stream).toBe(true);
  90. expect(JSON.stringify(lastChatBody?.messages)).toContain('第一句话');
  91. // 第二轮:上游没有状态(没有 previous_response_id 可用),
  92. // Codex 必须把第一轮内容一并放进 messages —— 这是 Ollama 非状态化兼容的根基
  93. const second = await runtime.runTurn({ threadId, prompt: '第二句话', timeoutMs: 120_000 });
  94. expect(second.status).toBe('completed');
  95. const history = JSON.stringify(lastChatBody?.messages);
  96. expect(history).toContain('第一句话');
  97. expect(history).toContain(REPLY); // 第一轮 assistant 回复也被回放
  98. expect(history).toContain('第二句话');
  99. } finally {
  100. await runtime.dispose();
  101. await chatBridge.stop();
  102. }
  103. }, 300_000);
  104. });