router.itest.ts 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129
  1. import { createServer, type Server } from 'node:http';
  2. import { mkdtemp, mkdir, 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 { CodexRuntime } from '../codexRuntime';
  7. import { chatRouter } from './routerService';
  8. import { applyProvider, clearProvider } from '../providerService';
  9. /**
  10. * 集成冒烟(不进 npm test,跑法:npm run smoke:codex):
  11. * 真 codex.exe + 内置路由层 + 只说 Chat Completions 的本地 mock。
  12. * 钉住三件事:
  13. * 1. applyProvider 对 chat-only 端点自动起路由,codex 的 base_url 指向本地路由;
  14. * 2. codex 发出的 Responses 请求经路由翻译后,上游收到合法 Chat 请求(含鉴权透传);
  15. * 3. 两轮对话第二轮的全量历史里带着第一轮提问与回复(store=false 的无状态语义经桥不丢)。
  16. */
  17. const API_KEY = 'sk-router-smoke-secret-1234567890';
  18. let upstream: Server | null = null;
  19. let upstreamUrl = '';
  20. let upstreamPosts: string[] = [];
  21. let root = '';
  22. let homes = 0;
  23. beforeAll(async () => {
  24. upstream = createServer((req, res) => {
  25. const chunks: Buffer[] = [];
  26. req.on('data', (c: Buffer) => chunks.push(c));
  27. req.on('end', () => {
  28. const route = req.url?.split('?')[0] ?? '';
  29. if (req.method !== 'POST' || (route !== '/v1/chat/completions' && route !== '/chat/completions')) {
  30. res.writeHead(404, { 'content-type': 'application/json' });
  31. res.end(JSON.stringify({ error: { message: 'no route' } }));
  32. return;
  33. }
  34. upstreamPosts.push(Buffer.concat(chunks).toString('utf8'));
  35. res.writeHead(200, { 'content-type': 'text/event-stream' });
  36. res.write(`data: ${JSON.stringify({ choices: [{ delta: { content: '路由回复' } }] })}\n\n`);
  37. res.write(`data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: 'stop' }] })}\n\n`);
  38. res.write(`data: ${JSON.stringify({ choices: [], usage: { prompt_tokens: 11, completion_tokens: 4, total_tokens: 15 } })}\n\n`);
  39. res.write('data: [DONE]\n\n');
  40. res.end();
  41. });
  42. });
  43. await new Promise<void>((resolve) => upstream?.listen(0, '127.0.0.1', resolve));
  44. const address = upstream.address();
  45. const port = typeof address === 'object' && address ? address.port : 0;
  46. upstreamUrl = `http://127.0.0.1:${port}/v1`;
  47. root = await mkdtemp(join(tmpdir(), 'zsjz-router-'));
  48. }, 120_000);
  49. afterAll(async () => {
  50. await chatRouter.stop();
  51. await new Promise<void>((resolve) => (upstream ? upstream.close(() => resolve()) : resolve()));
  52. await rm(root, { recursive: true, force: true }).catch(() => undefined);
  53. });
  54. describe('内置路由层:codex → 路由 → Chat 上游', () => {
  55. it(
  56. 'chat-only 端点自动桥接,两轮对话全量历史经路由不丢',
  57. async () => {
  58. const home = join(root, `home-${++homes}`);
  59. await mkdir(home, { recursive: true });
  60. const runtime = new CodexRuntime({ codexHome: home });
  61. try {
  62. // 走应用真实链路:探测判 chat-only → 起路由 → spec.baseUrl=路由地址
  63. const applied = await applyProvider(runtime, {
  64. modelId: 'chat-model',
  65. name: '桥接模型',
  66. baseUrl: upstreamUrl,
  67. apiKey: API_KEY,
  68. modelRecordId: 7,
  69. });
  70. expect(applied.bridged).toBe(true);
  71. // 落盘的是真实上游地址,路由地址不落盘
  72. expect(applied.baseUrl).toBe(upstreamUrl);
  73. expect(runtime.status.state).toBe('ready');
  74. expect(runtime.provider?.baseUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/v1$/u);
  75. expect(runtime.provider?.baseUrl).not.toBe(upstreamUrl);
  76. const config = await runtime.readConfig();
  77. const entry = (config.model_providers as Record<string, Record<string, unknown>> | undefined)?.zsjz;
  78. expect(entry?.base_url).toBe(runtime.provider?.baseUrl);
  79. expect(entry?.wire_api).toBe('responses');
  80. expect(JSON.stringify(config)).not.toContain(API_KEY);
  81. const threadId = await runtime.startThread({ cwd: root, approvalPolicy: 'never', sandbox: 'read-only' });
  82. const first = await runtime.runTurn({ threadId, prompt: '第一句', approvalPolicy: 'never' });
  83. expect(first.status).toBe('completed');
  84. expect(first.text).toContain('路由回复');
  85. const second = await runtime.runTurn({ threadId, prompt: '第二句', approvalPolicy: 'never' });
  86. expect(second.status).toBe('completed');
  87. // 应用时的探测会先直连上游打一发 /chat/completions(无 messages);
  88. // 桥接转发的请求必须恰好两轮,且都是合法 Chat 请求
  89. const bridgePosts = upstreamPosts.filter((body) => body.includes('"messages"'));
  90. expect(bridgePosts.length).toBe(2);
  91. const firstRequest = JSON.parse(bridgePosts[0] ?? '{}') as {
  92. model?: string;
  93. stream?: boolean;
  94. messages?: Array<{ role: string; content: unknown }>;
  95. };
  96. expect(firstRequest.model).toBe('chat-model');
  97. expect(firstRequest.stream).toBe(true);
  98. expect(firstRequest.messages?.some((m) => m.role === 'developer')).toBe(true);
  99. expect(JSON.stringify(firstRequest.messages)).toContain('第一句');
  100. const secondRequest = JSON.parse(bridgePosts[1] ?? '{}') as {
  101. messages?: Array<{ role: string; content: unknown }>;
  102. };
  103. const serialized = JSON.stringify(secondRequest.messages);
  104. // store=false 的无状态语义:第二轮请求带全量历史(第一轮问、答 + 第二轮问)
  105. expect(serialized).toContain('第一句');
  106. expect(serialized).toContain('路由回复');
  107. expect(serialized).toContain('第二句');
  108. // Responses 专有字段没有被透传给 Chat 上游
  109. expect(serialized).not.toContain('prompt_cache_key');
  110. expect(upstreamPosts.every((body) => !body.includes('ZSJZ_CODEX_API_KEY'))).toBe(true);
  111. } finally {
  112. await clearProvider(runtime);
  113. await runtime.dispose();
  114. }
  115. },
  116. 240_000,
  117. );
  118. });