| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114 |
- import { createServer, type Server } from 'node:http';
- import { mkdtemp, rm } from 'node:fs/promises';
- import { tmpdir } from 'node:os';
- import { join } from 'node:path';
- import { afterAll, beforeAll, describe, expect, it } from 'vitest';
- import { chatBridge } from './chatBridgeService';
- import { ensureCodexHome } from './codexHome';
- import { CodexRuntime } from './codexRuntime';
- import { applyProvider, readAppliedProvider } from './providerService';
- /**
- * 桥接集成冒烟:mock Chat 上游(/chat/completions 返回 SSE 固定文本,其余路由 404)
- * → 内置桥接 → 真实 codex app-server 跑完整 turn。
- * 同时验证「Codex 无状态调用」假设:第二轮 turn 的 messages 里必须携带第一轮内容。
- * 默认 npm test 不跑(.itest.ts),用 npm run smoke:codex 单独跑。
- */
- let upstream: Server;
- let upstreamPort = 0;
- let lastChatBody: Record<string, unknown> | null = null;
- let tmpRoot: string;
- const REPLY = '桥接冒烟固定回复';
- const SSE_BODY = [
- `data: ${JSON.stringify({ choices: [{ delta: { role: 'assistant', content: '' }, finish_reason: null }] })}`,
- '',
- `data: ${JSON.stringify({ choices: [{ delta: { content: REPLY }, finish_reason: null }] })}`,
- '',
- `data: ${JSON.stringify({
- choices: [{ delta: {}, finish_reason: 'stop' }],
- usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
- })}`,
- '',
- 'data: [DONE]',
- '',
- '',
- ].join('\n');
- beforeAll(async () => {
- upstream = createServer((req, res) => {
- if (req.url?.endsWith('/chat/completions')) {
- const chunks: Buffer[] = [];
- req.on('data', (chunk: Buffer) => chunks.push(chunk));
- req.on('end', () => {
- lastChatBody = JSON.parse(Buffer.concat(chunks).toString('utf8') || 'null');
- res.writeHead(200, { 'content-type': 'text/event-stream' });
- res.end(SSE_BODY);
- });
- return;
- }
- // 除 Chat 外的路由一律 404(探活的 /models 也算活着,不影响判定)
- req.resume();
- res.writeHead(404, { 'content-type': 'application/json' });
- res.end('{}');
- });
- await new Promise<void>((resolve) => upstream.listen(0, '127.0.0.1', resolve));
- const address = upstream.address();
- upstreamPort = typeof address === 'object' && address ? address.port : 0;
- tmpRoot = await mkdtemp(join(tmpdir(), 'zsjz-codex-bridge-'));
- });
- afterAll(async () => {
- await chatBridge.stop();
- await new Promise<void>((resolve) => upstream?.close(() => resolve()));
- await rm(tmpRoot, { recursive: true, force: true }).catch(() => undefined);
- });
- describe('内置桥接:Chat 端点接入', () => {
- it('桥接应用成功,真实 Codex 经桥接跑通两轮 turn(验证无状态全量历史)', async () => {
- const codexHome = await ensureCodexHome();
- const runtime = new CodexRuntime({ codexHome });
- try {
- const applied = await applyProvider(runtime, {
- modelId: 'bridge-smoke-model',
- name: '桥接冒烟',
- baseUrl: `http://127.0.0.1:${upstreamPort}/v1`,
- });
- expect(applied.bridged).toBe(true);
- // 落盘存上游真实地址;桥接的本地 URL 不落盘
- expect(applied.baseUrl).toBe(`http://127.0.0.1:${upstreamPort}/v1`);
- expect(chatBridge.info).not.toBeNull();
- expect(JSON.stringify(await readAppliedProvider())).not.toContain(chatBridge.info!.url);
- // 生效配置:必须是自定义 zsjz provider 指向桥接地址(不能残留内置 provider 绕过桥接)
- const config = await runtime.readConfig();
- expect(config.model_provider).toBe('zsjz');
- const entry = (config.model_providers as Record<string, Record<string, unknown>> | undefined)?.zsjz;
- expect(entry?.base_url).toBe(chatBridge.info!.url);
- expect(entry?.wire_api).toBe('responses');
- // 第一轮:Codex → 桥接 → mock 上游 SSE → 翻译回 Responses 流
- const threadId = await runtime.startThread({ cwd: tmpRoot });
- const first = await runtime.runTurn({ threadId, prompt: '第一句话', timeoutMs: 120_000 });
- expect(first.status).toBe('completed');
- expect(first.text).toContain(REPLY);
- expect(lastChatBody?.model).toBe('bridge-smoke-model');
- expect(lastChatBody?.stream).toBe(true);
- expect(JSON.stringify(lastChatBody?.messages)).toContain('第一句话');
- // 第二轮:上游没有状态(没有 previous_response_id 可用),
- // Codex 必须把第一轮内容一并放进 messages —— 这是 Ollama 非状态化兼容的根基
- const second = await runtime.runTurn({ threadId, prompt: '第二句话', timeoutMs: 120_000 });
- expect(second.status).toBe('completed');
- const history = JSON.stringify(lastChatBody?.messages);
- expect(history).toContain('第一句话');
- expect(history).toContain(REPLY); // 第一轮 assistant 回复也被回放
- expect(history).toContain('第二句话');
- } finally {
- await runtime.dispose();
- await chatBridge.stop();
- }
- }, 300_000);
- });
|