加一套抓真实请求做预设对比的工具,并把三份预设的机制差异记进 README。
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* 假上游:实现够用的 OpenAI 兼容端点,把收到的原始请求体原样落盘。
|
||||
*
|
||||
* 用法:
|
||||
* node capture-server.mjs --label st --port 8801
|
||||
*
|
||||
* 落盘目录默认 ./captures(相对本文件)。
|
||||
* 每个请求一个 <label>-<序号>.json,内容 { label, receivedAt, method, url, headers, body }。
|
||||
* body 是原始字符串,diff 工具再解析 —— 保留原文便于核对是否有我们没建模的字段。
|
||||
*/
|
||||
import { createServer } from "node:http";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function readArgs(argv) {
|
||||
const out = { label: "unknown", port: 8800, out: path.join(HERE, "captures") };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const key = argv[i];
|
||||
const next = argv[i + 1];
|
||||
if (key === "--label") out.label = String(next);
|
||||
else if (key === "--port") out.port = Number(next);
|
||||
else if (key === "--out") out.out = path.resolve(String(next));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const args = readArgs(process.argv.slice(2));
|
||||
mkdirSync(args.out, { recursive: true });
|
||||
|
||||
const REPLY_TEXT = "[[mock-reply]] 收到。";
|
||||
const REPLY_REASONING = "[[mock-reasoning]] 想一下。";
|
||||
|
||||
function capture(req, url, raw) {
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const seq = String(globalThis.__captureSeq = (globalThis.__captureSeq ?? 0) + 1).padStart(3, "0");
|
||||
const file = path.join(args.out, `${args.label}-${seq}-${stamp}.json`);
|
||||
const payload = {
|
||||
label: args.label,
|
||||
receivedAt: new Date().toISOString(),
|
||||
method: req.method,
|
||||
url,
|
||||
headers: {
|
||||
"content-type": req.headers["content-type"] ?? null,
|
||||
authorization: req.headers.authorization ? "<redacted>" : null,
|
||||
},
|
||||
body: raw,
|
||||
};
|
||||
writeFileSync(file, JSON.stringify(payload, null, 2), "utf8");
|
||||
// 只写一行摘要,不把正文打到控制台
|
||||
let charCount = raw.length;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
charCount = Array.isArray(parsed.messages)
|
||||
? parsed.messages.reduce((n, m) => n + String(m.content ?? "").length, 0)
|
||||
: charCount;
|
||||
} catch {
|
||||
/* 非 JSON 就按原长度记 */
|
||||
}
|
||||
console.log(`[capture:${args.label}] ${req.method} ${url} -> ${path.basename(file)} (messages 正文 ${charCount} 字)`);
|
||||
return file;
|
||||
}
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
req.on("data", (c) => chunks.push(c));
|
||||
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function sseHead(res) {
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream; charset=utf-8",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
});
|
||||
}
|
||||
|
||||
function sseChunk(delta, finishReason = null) {
|
||||
return `data: ${JSON.stringify({
|
||||
id: "chatcmpl-mock",
|
||||
object: "chat.completion.chunk",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: "mock-model",
|
||||
choices: [{ index: 0, delta, finish_reason: finishReason }],
|
||||
})}\n\n`;
|
||||
}
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
const url = req.url ?? "/";
|
||||
if (req.method === "GET" && url.includes("/models")) {
|
||||
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
object: "list",
|
||||
data: [{ id: "mock-model", object: "model", owned_by: "mock" }],
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = await readBody(req);
|
||||
if (req.method !== "POST") {
|
||||
res.writeHead(404).end("not found");
|
||||
return;
|
||||
}
|
||||
|
||||
let body = {};
|
||||
try {
|
||||
body = JSON.parse(raw);
|
||||
} catch {
|
||||
/* 保留原文,下面按默认处理 */
|
||||
}
|
||||
|
||||
capture(req, url, raw);
|
||||
|
||||
if (url.includes("/embeddings")) {
|
||||
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ object: "list", data: [{ embedding: [0] }] }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.includes("/completions")) {
|
||||
if (body.stream) {
|
||||
sseHead(res);
|
||||
const chunks = [];
|
||||
if (body.reasoning_effort && body.reasoning_effort !== "none") {
|
||||
chunks.push(sseChunk({ role: "assistant", reasoning_content: REPLY_REASONING }));
|
||||
}
|
||||
chunks.push(sseChunk({ role: "assistant", content: REPLY_TEXT }));
|
||||
chunks.push(sseChunk({}, "stop"));
|
||||
chunks.push("data: [DONE]\n\n");
|
||||
res.end(chunks.join(""));
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
id: "chatcmpl-mock",
|
||||
object: "chat.completion",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: "mock-model",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: "assistant", content: REPLY_TEXT },
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 100, completion_tokens: 10, total_tokens: 110 },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
});
|
||||
|
||||
server.listen(args.port, "127.0.0.1", () => {
|
||||
console.log(`[capture:${args.label}] listening on http://127.0.0.1:${args.port}/v1 -> ${args.out}`);
|
||||
});
|
||||
Reference in New Issue
Block a user