167 lines
5.1 KiB
JavaScript
167 lines
5.1 KiB
JavaScript
/**
|
|
* 假上游:实现够用的 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}`);
|
|
});
|