aps-agent/poc/pi-aps/e2e/mock-llm.mjs

185 lines
6.0 KiB
JavaScript
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.mjs - scripted OpenAI-compatible LLM endpoint for real Pi E2E.
*
* This server only scripts the model layer. It does NOT mock APS data. The APS
* request is made by the real Pi extension against the real APS HTTP server.
*
* Behaviour:
* 1. First chat completion request (no tool result yet): ask the model to call
* the chosen APS tool (aps_order_pool if present, otherwise aps_health).
* 2. Second request (tool result present): return a Chinese final message.
*/
import http from "node:http";
import { appendFileSync } from "node:fs";
import path from "node:path";
const RESERVED = new Set(["aps_health", "aps_summary", "aps_invoke"]);
function args() {
const out = { port: 0, portFile: "" };
const argv = process.argv.slice(2);
for (let i = 0; i < argv.length; i += 1) {
if (argv[i] === "--port") out.port = Number(argv[i + 1]);
if (argv[i] === "--port-file") out.portFile = String(argv[i + 1] || "");
}
return out;
}
function chunk(delta, finishReason = null) {
const payload = {
id: "chatcmpl-aps-e2e-mock",
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model: "mock-local",
choices: [{ index: 0, delta, finish_reason: finishReason }],
};
return `data: ${JSON.stringify(payload)}\n\n`;
}
function toolCallSse(toolName, callIndex) {
const id = `call_aps_e2e_${Date.now()}_${callIndex}`;
const parts = [
chunk({ role: "assistant", content: null }),
chunk({
tool_calls: [{
index: callIndex,
id,
type: "function",
function: { name: toolName, arguments: "" },
}],
}),
chunk({
tool_calls: [{
index: callIndex,
function: { arguments: "{}" },
}],
}),
chunk({}, "tool_calls"),
chunk({}, null),
"data: [DONE]\n\n",
];
return parts.join("");
}
function finalTextSse(text) {
const parts = [chunk({ role: "assistant", content: "" })];
for (let i = 0; i < text.length; i += 40) {
parts.push(chunk({ content: text.slice(i, i + 40) }));
}
parts.push(chunk({}, "stop"));
parts.push("data: [DONE]\n\n");
return parts.join("");
}
function pickTool(body) {
const tools = (body.tools || [])
.map((t) => t?.function?.name)
.filter((name) => typeof name === "string" && name.startsWith("aps_"));
const forced = process.env.APS_E2E_TOOL;
if (forced && tools.includes(forced)) return forced;
const dynamic = tools.find((name) => name !== "aps_health" && name !== "aps_summary" && name !== "aps_invoke");
return dynamic || tools.find((name) => name === "aps_health") || tools[0];
}
function logRequest(summary) {
const logFile = process.env.APS_MOCK_REQUEST_LOG;
if (!logFile) return;
try {
appendFileSync(logFile, `${JSON.stringify({ ...summary, at: new Date().toISOString() })}\n`, "utf8");
} catch {
// Logging must never break the E2E run.
}
}
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => {
try {
resolve(JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}"));
} catch (err) {
reject(err);
}
});
req.on("error", reject);
});
}
function sendSse(res, payload, status = 200) {
res.writeHead(status, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "close",
});
res.end(payload);
}
export async function startMockLlmServer(options = {}) {
const port = options.port ?? 0;
const portFile = options.portFile ?? "";
const { writeFileSync } = await import("node:fs");
const server = http.createServer(async (req, res) => {
if (req.method === "POST" && req.url.replace(/\/+$/, "").endsWith("/chat/completions")) {
logRequest({
route: "chat/completions/request_seen",
url: req.url,
contentLength: Number(req.headers["content-length"] || 0),
});
let body;
try {
body = await readBody(req);
} catch {
sendSse(res, finalTextSse("剧本 LLM 收到无法解析的请求,E2E 失败。"), 400);
return;
}
const roles = (body.messages || []).map((m) => m.role);
const hasToolResult = roles.includes("tool");
logRequest({ route: "chat/completions", roles, hasToolResult });
if (hasToolResult) {
const toolMessage = (body.messages || []).find((m) => m.role === "tool");
const toolName = toolMessage?.toolName || toolMessage?.tool_name || toolMessage?.name || "APS 工具";
sendSse(
res,
finalTextSse(
`${toolName} 已由真实 Pi 扩展调用并返回结果。我核对了工具事件,` +
"未发现 HTTP 失败;以下是面向用户的中文总结:APS 服务请求已完成,结果已按工具返回内容如实呈现。",
),
);
return;
}
const toolName = pickTool(body);
if (!toolName) {
sendSse(
res,
finalTextSse("剧本 LLM 未发现 pi-aps 注册的 APS 工具,扩展加载或目录注册失败。"),
);
return;
}
sendSse(res, toolCallSse(toolName, 0));
return;
}
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "not found" }));
});
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(port, "127.0.0.1", resolve);
});
const actualPort = server.address().port;
if (portFile) {
writeFileSync(portFile, `MOCK_LLM_PORT=${actualPort}\n`, "utf8");
}
process.stdout.write(`[mock-llm] listening on http://127.0.0.1:${actualPort}/v1\n`);
return { server, port: actualPort, close: () => new Promise((resolve) => server.close(resolve)) };
}
const isMain = typeof process.argv[1] === "string" && path.basename(process.argv[1]) === "mock-llm.mjs";
if (isMain) {
const { server } = await startMockLlmServer(args());
const shutdown = () => server.close(() => process.exit(0));
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
}