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-only 上游(/responses 404,/chat/completions 返回 SSE 固定文本) * → 内置桥接 → 真实 codex app-server 跑完整 turn。 * 同时验证「Codex 无状态调用」假设:第二轮 turn 的 messages 里必须携带第一轮内容。 * 默认 npm test 不跑(.itest.ts),用 npm run smoke:codex 单独跑。 */ let upstream: Server; let upstreamPort = 0; let lastChatBody: Record | 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; } // /responses 一律 404 → 探测走 chat 分支 req.resume(); res.writeHead(404, { 'content-type': 'application/json' }); res.end('{}'); }); await new Promise((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((resolve) => upstream?.close(() => resolve())); await rm(tmpRoot, { recursive: true, force: true }).catch(() => undefined); }); describe('内置桥接:chat-only 端点接入', () => { 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`, providerType: 'VLLM', }); 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> | 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); });