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}`); });