/** * Mock LLM Server — E2E 冒烟测试的本地 OpenAI 兼容 Provider(v0.8.1 P2-5) * * 实现 DeepSeek 适配器实际消费的最小协议面: * - POST {baseURL}/chat/completions(stream=true):按 SSE 推送一段固定文本 + * finish_reason=stop + usage + [DONE]; * - POST /chat/completions(stream=false):非流式 JSON(压缩摘要等内部调用兜底)。 * * v0.8.2 P3-3: 新增两条可控脚本(内容标记协议,零外部依赖): * - `__E2E_TOOL_CALL__`:返回 tool_calls(think 工具);后续请求带 tool 消息时 * 返回最终文本 —— 锁定"多轮工具调用 → 观察 → 最终回答"全链路; * - `__E2E_HANG__`:推送首帧后挂起连接(不结束、不推 finish_reason)—— * 供中断链路测试:客户端 abort 时服务端感知连接关闭。 * * 端口随机分配(127.0.0.1),测试结束关闭 —— 不触外网、不落真实会话数据。 */ import { createServer, type Server } from 'http'; import type { AddressInfo } from 'net'; /** 流式回复的固定文本(断言锚点) */ export const MOCK_REPLY = 'Hello from mock LLM. E2E smoke reply.'; /** 多轮工具调用脚本的最终回复(断言锚点) */ export const TOOL_REPLY = 'Tool flow completed OK.'; /** 中断脚本的流式前缀(挂起前推送的第一帧文本) */ export const HANG_PREFIX = 'Hanging stream...'; export interface MockLLMHandle { url: string; close: () => Promise; /** 收到的 chat/completions 请求体列表(断言用) */ readonly requests: Array>; } function frame(res: import('http').ServerResponse, payload: Record): void { res.write(`data: ${JSON.stringify(payload)}\n\n`); } export function startMockLLM(): Promise { const requests: Array> = []; const server: Server = createServer((req, res) => { if (!req.url?.includes('/chat/completions')) { res.writeHead(404).end(); return; } let body = ''; req.on('data', (chunk) => { body += chunk; }); req.on('end', () => { let parsed: Record = {}; try { parsed = JSON.parse(body) as Record; } catch { /* 忽略解析失败 */ } requests.push(parsed); const isStream = parsed.stream === true; const messages = (parsed.messages as Array>) ?? []; const hasToolResult = messages.some((m) => m.role === 'tool'); // 脚本标记只看**最后一条用户消息**:恢复请求的历史里会带着早前的 // __E2E_HANG__ 消息,若按全量 JSON 匹配会把恢复请求也误判为挂起脚本。 const lastUserContent = [...messages] .reverse() .find((m) => m.role === 'user'); const lastUserText = typeof lastUserContent?.content === 'string' ? lastUserContent.content : ''; const wantsToolCall = lastUserText.includes('__E2E_TOOL_CALL__') && !hasToolResult; const wantsHang = lastUserText.includes('__E2E_HANG__'); if (!isStream) { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end( JSON.stringify({ id: 'mock-1', model: parsed.model ?? 'mock', choices: [ { index: 0, message: { role: 'assistant', content: MOCK_REPLY }, finish_reason: 'stop', }, ], usage: { prompt_tokens: 10, completion_tokens: 8, total_tokens: 18 }, }), ); return; } res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', }); // ===== 中断脚本:首帧后挂起,客户端 abort 时感知连接关闭 ===== if (wantsHang) { frame(res, { id: 'mock-hang', model: parsed.model ?? 'mock', choices: [{ index: 0, delta: { role: 'assistant', content: HANG_PREFIX } }], }); // 挂起即可:不推 finish_reason、不 [DONE]、不 end。 // 注意不要监听 req 'close' —— Node>=16 该事件在请求体接收完成时即触发 //(非连接关闭),主动 destroy 会把挂起变成 'terminated' 断流。 // 客户端 abort 时 socket 关闭由 HTTP 栈自然回收。 // 心跳:每秒发一条 SSE 注释行(':' 前缀,解析器按非 data 帧忽略)—— // 保持连接健康,防止 keep-alive/空闲机制把"挂起"变成提前断流, // 使中断测试与引擎 ERROR 收尾产生竞态。 const heartbeat = setInterval(() => { try { res.write(': keepalive\n\n'); } catch { clearInterval(heartbeat); } }, 1_000); heartbeat.unref?.(); return; } // ===== 多轮工具调用脚本 ===== if (wantsToolCall) { frame(res, { id: 'mock-tool', model: parsed.model ?? 'mock', choices: [ { index: 0, delta: { role: 'assistant', tool_calls: [ { index: 0, id: 'call_e2e_1', type: 'function', function: { name: 'think', arguments: '{"thought":"mock tool call"}' }, }, ], }, }, ], }); frame(res, { id: 'mock-tool', model: parsed.model ?? 'mock', choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }], usage: { prompt_tokens: 12, completion_tokens: 10, total_tokens: 22 }, }); res.write('data: [DONE]\n\n'); res.end(); return; } if (hasToolResult) { // 工具结果已回传 → 最终回答 frame(res, { id: 'mock-tool-2', model: parsed.model ?? 'mock', choices: [{ index: 0, delta: { role: 'assistant', content: TOOL_REPLY } }], }); frame(res, { id: 'mock-tool-2', model: parsed.model ?? 'mock', choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], usage: { prompt_tokens: 20, completion_tokens: 8, total_tokens: 28 }, }); res.write('data: [DONE]\n\n'); res.end(); return; } // ===== 默认冒烟脚本 ===== frame(res, { id: 'mock-1', model: parsed.model ?? 'mock', choices: [{ index: 0, delta: { role: 'assistant', content: MOCK_REPLY } }], }); frame(res, { id: 'mock-1', model: parsed.model ?? 'mock', choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], usage: { prompt_tokens: 10, completion_tokens: 8, total_tokens: 18 }, }); res.write('data: [DONE]\n\n'); res.end(); }); }); return new Promise((resolve) => { server.listen(0, '127.0.0.1', () => { const addr = server.address() as AddressInfo; resolve({ url: `http://127.0.0.1:${addr.port}`, close: () => new Promise((r) => server.close(() => r())), requests, }); }); }); }