chore: initial import
这个提交包含在:
10
llm-proxy/Dockerfile
普通文件
10
llm-proxy/Dockerfile
普通文件
@@ -0,0 +1,10 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY server.js /app/server.js
|
||||
|
||||
ENV NODE_ENV=production
|
||||
EXPOSE 9405
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
190
llm-proxy/server.js
普通文件
190
llm-proxy/server.js
普通文件
@@ -0,0 +1,190 @@
|
||||
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}`);
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户