191 行
5.0 KiB
JavaScript
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}`);
|
|
});
|