文件
apkReverseknowledge/llm-proxy/server.js
2026-03-06 08:55:47 +08:00

191 行
5.0 KiB
JavaScript

const http = require("node:http");
const { Readable } = require("node:stream");
const PORT = Number(process.env.PORT || 9405);
const LLM_BASE_URL = (process.env.LLM_BASE_URL || "").replace(/\/+$/, "");
const LLM_API_KEY = process.env.LLM_API_KEY || "";
const LLM_MODEL = process.env.LLM_MODEL || "qwen3.5-plus";
const LLM_TIMEOUT_MS = Number(process.env.LLM_TIMEOUT_MS || 60000);
const LLM_FORCE_CHINESE =
String(process.env.LLM_FORCE_CHINESE || "true").toLowerCase() !== "false";
const CHINESE_SYSTEM_PROMPT =
"你是专业助手。除非用户明确要求使用其他语言,否则始终使用简体中文回答。";
const MAX_BODY_BYTES = 2 * 1024 * 1024;
function failFast() {
const missing = [];
if (!LLM_BASE_URL) missing.push("LLM_BASE_URL");
if (!LLM_API_KEY) missing.push("LLM_API_KEY");
if (missing.length) {
console.error(`Missing required env vars: ${missing.join(", ")}`);
process.exit(1);
}
}
function sendJson(res, statusCode, data) {
const body = JSON.stringify(data);
res.writeHead(statusCode, {
"Content-Type": "application/json; charset=utf-8",
"Content-Length": Buffer.byteLength(body),
});
res.end(body);
}
function readJsonBody(req) {
return new Promise((resolve, reject) => {
let total = 0;
const chunks = [];
req.on("data", (chunk) => {
total += chunk.length;
if (total > MAX_BODY_BYTES) {
reject(new Error("Request body too large"));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on("end", () => {
try {
const text = Buffer.concat(chunks).toString("utf8");
const parsed = text ? JSON.parse(text) : {};
resolve(parsed);
} catch (error) {
reject(new Error("Invalid JSON body"));
}
});
req.on("error", reject);
});
}
function buildPayload(body) {
const payload = { ...body };
if (!payload.model || typeof payload.model !== "string") {
payload.model = LLM_MODEL;
}
if (LLM_FORCE_CHINESE && Array.isArray(payload.messages)) {
const hasSystem = payload.messages.some((m) => m && m.role === "system");
if (!hasSystem) {
payload.messages = [{ role: "system", content: CHINESE_SYSTEM_PROMPT }, ...payload.messages];
}
}
return payload;
}
function isChatPath(pathname) {
return pathname === "/api/llm/chat" || pathname === "/v1/chat/completions";
}
function parseTimeoutMs(value) {
if (!Number.isFinite(value) || value <= 0) {
return 60000;
}
return value;
}
async function proxyChatCompletion(req, res) {
let body;
try {
body = await readJsonBody(req);
} catch (error) {
const status = error.message === "Request body too large" ? 413 : 400;
sendJson(res, status, { error: error.message });
return;
}
if (!Array.isArray(body.messages)) {
sendJson(res, 400, { error: "Field `messages` must be an array." });
return;
}
const payload = buildPayload(body);
const timeoutMs = parseTimeoutMs(LLM_TIMEOUT_MS);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
let upstream;
try {
upstream = await fetch(`${LLM_BASE_URL}/chat/completions`, {
method: "POST",
headers: {
Authorization: `Bearer ${LLM_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
signal: controller.signal,
});
} catch (error) {
clearTimeout(timeout);
const status = error.name === "AbortError" ? 504 : 502;
sendJson(res, status, { error: "LLM upstream request failed" });
return;
} finally {
clearTimeout(timeout);
}
const contentType = upstream.headers.get("content-type") || "";
const isSSE = contentType.includes("text/event-stream") || payload.stream === true;
if (isSSE) {
res.writeHead(upstream.status, {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
});
if (!upstream.body) {
res.end();
return;
}
const stream = Readable.fromWeb(upstream.body);
stream.on("error", () => {
if (!res.writableEnded) {
res.end();
}
});
stream.pipe(res);
return;
}
const text = await upstream.text();
res.writeHead(upstream.status, {
"Content-Type": contentType || "application/json; charset=utf-8",
});
res.end(text);
}
function requestHandler(req, res) {
const host = req.headers.host || "localhost";
const url = new URL(req.url, `http://${host}`);
if (req.method === "GET" && url.pathname === "/health") {
sendJson(res, 200, { ok: true });
return;
}
if (req.method === "POST" && isChatPath(url.pathname)) {
proxyChatCompletion(req, res).catch((error) => {
console.error("proxyChatCompletion failed:", error);
sendJson(res, 500, { error: "Internal server error" });
});
return;
}
sendJson(res, 404, { error: "Not found" });
}
failFast();
const server = http.createServer(requestHandler);
server.listen(PORT, "0.0.0.0", () => {
console.log(`llm-proxy listening on :${PORT}`);
});