Files
thzxx 4cd6e997b5
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m45s
CI / 全量测试 (Electron ABI) (push) Failing after 6m28s
CI / 产物编译验证 (push) Successful in 11m18s
feat: v0.8.2 安全纵深补全 · 协议保真 · 断链修复 — 图片SSRF/根MEMORY.md保护根治 · Anthropic thinking回传+pause_turn续传 · 2523 用例全量回归 + E2E 扩充
2026-09-08 14:30:27 +08:00

202 lines
7.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Mock LLM Server — E2E 冒烟测试的本地 OpenAI 兼容 Providerv0.8.1 P2-5
*
* 实现 DeepSeek 适配器实际消费的最小协议面:
* - POST {baseURL}/chat/completionsstream=true):按 SSE 推送一段固定文本 +
* finish_reason=stop + usage + [DONE]
* - POST /chat/completionsstream=false):非流式 JSON(压缩摘要等内部调用兜底)。
*
* v0.8.2 P3-3: 新增两条可控脚本(内容标记协议,零外部依赖):
* - `__E2E_TOOL_CALL__`:返回 tool_callsthink 工具);后续请求带 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<void>;
/** 收到的 chat/completions 请求体列表(断言用) */
readonly requests: Array<Record<string, unknown>>;
}
function frame(res: import('http').ServerResponse, payload: Record<string, unknown>): void {
res.write(`data: ${JSON.stringify(payload)}\n\n`);
}
export function startMockLLM(): Promise<MockLLMHandle> {
const requests: Array<Record<string, unknown>> = [];
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<string, unknown> = {};
try {
parsed = JSON.parse(body) as Record<string, unknown>;
} catch {
/* 忽略解析失败 */
}
requests.push(parsed);
const isStream = parsed.stream === true;
const messages = (parsed.messages as Array<Record<string, unknown>>) ?? [];
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<void>((r) => server.close(() => r())),
requests,
});
});
});
}