mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-17 09:56:06 +00:00
333 lines
9.9 KiB
JavaScript
333 lines
9.9 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { createServer } from "node:http";
|
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
import { dirname, resolve } from "node:path";
|
|
import { env, exit } from "node:process";
|
|
|
|
const args = parseArgs(process.argv.slice(2));
|
|
const host = args.host || env.LANGBOT_FAKE_PROVIDER_HOST || "127.0.0.1";
|
|
const port = integer(args.port ?? env.LANGBOT_FAKE_PROVIDER_PORT, 0);
|
|
const stateFile = args["state-file"] || env.LANGBOT_FAKE_PROVIDER_STATE_FILE || "";
|
|
const modelName = env.LANGBOT_FAKE_PROVIDER_MODEL_NAME || "gpt-4o-mini";
|
|
const responseText = env.LANGBOT_FAKE_PROVIDER_RESPONSE_TEXT || "OK";
|
|
const firstTokenDelayMs = integer(env.LANGBOT_FAKE_PROVIDER_FIRST_TOKEN_DELAY_MS, 25);
|
|
const chunkDelayMs = integer(env.LANGBOT_FAKE_PROVIDER_CHUNK_DELAY_MS, 10);
|
|
const faultStatus = integer(env.LANGBOT_FAKE_PROVIDER_FAULT_STATUS, 500);
|
|
const failFirstN = integer(env.LANGBOT_FAKE_PROVIDER_FAIL_FIRST_N, 0);
|
|
const failEveryN = integer(env.LANGBOT_FAKE_PROVIDER_FAIL_EVERY_N, 0);
|
|
const failAfterFirstChunk = bool(env.LANGBOT_FAKE_PROVIDER_FAIL_AFTER_FIRST_CHUNK, false);
|
|
const requestLogLimit = integer(env.LANGBOT_FAKE_PROVIDER_REQUEST_LOG_LIMIT, 500);
|
|
|
|
let requestCount = 0;
|
|
const recentRequests = [];
|
|
|
|
const server = createServer(async (request, response) => {
|
|
const startedAt = Date.now();
|
|
const url = new URL(request.url || "/", `http://${request.headers.host || `${host}:${port}`}`);
|
|
try {
|
|
if (request.method === "GET" && url.pathname === "/healthz") {
|
|
sendJson(response, 200, {
|
|
ok: true,
|
|
model: modelName,
|
|
request_count: requestCount,
|
|
recent_request_count: recentRequests.length,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (request.method === "GET" && ["/models", "/v1/models"].includes(url.pathname)) {
|
|
sendJson(response, 200, {
|
|
object: "list",
|
|
data: [
|
|
{
|
|
id: modelName,
|
|
object: "model",
|
|
created: 1,
|
|
owned_by: "langbot-qa",
|
|
type: "llm",
|
|
},
|
|
],
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (request.method === "POST" && ["/chat/completions", "/v1/chat/completions"].includes(url.pathname)) {
|
|
requestCount += 1;
|
|
const body = await readJson(request);
|
|
const requestId = `chatcmpl-langbot-fake-${requestCount}`;
|
|
const shouldFail = requestCount <= failFirstN || (failEveryN > 0 && requestCount % failEveryN === 0);
|
|
recordRequest({
|
|
id: requestId,
|
|
path: url.pathname,
|
|
stream: Boolean(body.stream),
|
|
model: body.model || "",
|
|
message_count: Array.isArray(body.messages) ? body.messages.length : 0,
|
|
should_fail: shouldFail,
|
|
});
|
|
|
|
if (shouldFail) {
|
|
await sleep(firstTokenDelayMs);
|
|
sendJson(response, faultStatus, {
|
|
error: {
|
|
message: `LangBot fake provider injected HTTP ${faultStatus}`,
|
|
type: "fake_provider_fault",
|
|
code: "fake_provider_fault",
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
const replyText = responseTextForBody(body);
|
|
|
|
if (body.stream) {
|
|
await streamCompletion(response, {
|
|
requestId,
|
|
model: body.model || modelName,
|
|
content: replyText,
|
|
failAfterFirstChunk,
|
|
});
|
|
} else {
|
|
await sleep(firstTokenDelayMs + chunkDelayMs);
|
|
sendJson(response, 200, completionPayload({
|
|
requestId,
|
|
model: body.model || modelName,
|
|
content: replyText,
|
|
}));
|
|
}
|
|
return;
|
|
}
|
|
|
|
sendJson(response, 404, {
|
|
error: {
|
|
message: `No fake provider route for ${request.method} ${url.pathname}`,
|
|
type: "not_found",
|
|
},
|
|
});
|
|
} catch (error) {
|
|
sendJson(response, 500, {
|
|
error: {
|
|
message: error instanceof Error ? error.message : String(error),
|
|
type: "fake_provider_error",
|
|
},
|
|
});
|
|
} finally {
|
|
const durationMs = Date.now() - startedAt;
|
|
if (url.pathname !== "/healthz") {
|
|
console.log(JSON.stringify({
|
|
at: new Date().toISOString(),
|
|
method: request.method,
|
|
path: url.pathname,
|
|
duration_ms: durationMs,
|
|
}));
|
|
}
|
|
}
|
|
});
|
|
|
|
server.listen(port, host, async () => {
|
|
const address = server.address();
|
|
const selectedPort = typeof address === "object" && address ? address.port : port;
|
|
const url = `http://${host}:${selectedPort}`;
|
|
const state = {
|
|
status: "ready",
|
|
pid: process.pid,
|
|
url,
|
|
base_url: `${url}/v1`,
|
|
model: modelName,
|
|
started_at: new Date().toISOString(),
|
|
};
|
|
if (stateFile) {
|
|
const path = resolve(stateFile);
|
|
await mkdir(dirname(path), { recursive: true });
|
|
await writeFile(path, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
|
}
|
|
console.log(JSON.stringify(state));
|
|
});
|
|
|
|
server.on("error", (error) => {
|
|
console.error(JSON.stringify({
|
|
status: "error",
|
|
reason: error instanceof Error ? error.message : String(error),
|
|
}));
|
|
exit(1);
|
|
});
|
|
|
|
process.on("SIGTERM", () => {
|
|
server.close(() => exit(0));
|
|
});
|
|
|
|
function parseArgs(argv) {
|
|
const result = {};
|
|
for (const item of argv) {
|
|
const match = item.match(/^--([^=]+)(?:=(.*))?$/);
|
|
if (!match) continue;
|
|
result[match[1]] = match[2] ?? "1";
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function integer(value, fallback) {
|
|
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
|
|
}
|
|
|
|
function bool(value, fallback) {
|
|
if (value === undefined || value === "") return fallback;
|
|
if (/^(1|true|yes|on)$/i.test(String(value))) return true;
|
|
if (/^(0|false|no|off)$/i.test(String(value))) return false;
|
|
return fallback;
|
|
}
|
|
|
|
function sleep(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
|
|
}
|
|
|
|
async function readJson(request) {
|
|
let text = "";
|
|
for await (const chunk of request) text += chunk.toString();
|
|
if (!text) return {};
|
|
return JSON.parse(text);
|
|
}
|
|
|
|
function sendJson(response, status, payload) {
|
|
const text = `${JSON.stringify(payload)}\n`;
|
|
response.writeHead(status, {
|
|
"content-type": "application/json",
|
|
"content-length": Buffer.byteLength(text),
|
|
});
|
|
response.end(text);
|
|
}
|
|
|
|
function completionPayload({ requestId, model, content }) {
|
|
const completionTokens = tokenEstimate(content);
|
|
return {
|
|
id: requestId,
|
|
object: "chat.completion",
|
|
created: Math.floor(Date.now() / 1000),
|
|
model,
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
message: {
|
|
role: "assistant",
|
|
content,
|
|
},
|
|
finish_reason: "stop",
|
|
},
|
|
],
|
|
usage: {
|
|
prompt_tokens: 8,
|
|
completion_tokens: completionTokens,
|
|
total_tokens: 8 + completionTokens,
|
|
},
|
|
};
|
|
}
|
|
|
|
async function streamCompletion(response, { requestId, model, content, failAfterFirstChunk: failMidStream }) {
|
|
response.writeHead(200, {
|
|
"content-type": "text/event-stream; charset=utf-8",
|
|
"cache-control": "no-cache",
|
|
"connection": "keep-alive",
|
|
});
|
|
|
|
await sleep(firstTokenDelayMs);
|
|
writeSse(response, {
|
|
id: requestId,
|
|
object: "chat.completion.chunk",
|
|
created: Math.floor(Date.now() / 1000),
|
|
model,
|
|
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
|
|
});
|
|
|
|
const chunks = splitContent(content);
|
|
for (let index = 0; index < chunks.length; index += 1) {
|
|
await sleep(chunkDelayMs);
|
|
writeSse(response, {
|
|
id: requestId,
|
|
object: "chat.completion.chunk",
|
|
created: Math.floor(Date.now() / 1000),
|
|
model,
|
|
choices: [{ index: 0, delta: { content: chunks[index] }, finish_reason: null }],
|
|
});
|
|
if (failMidStream && index === 0) {
|
|
response.destroy(new Error("LangBot fake provider injected mid-stream disconnect"));
|
|
return;
|
|
}
|
|
}
|
|
|
|
await sleep(chunkDelayMs);
|
|
const completionTokens = tokenEstimate(content);
|
|
writeSse(response, {
|
|
id: requestId,
|
|
object: "chat.completion.chunk",
|
|
created: Math.floor(Date.now() / 1000),
|
|
model,
|
|
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
|
usage: {
|
|
prompt_tokens: 8,
|
|
completion_tokens: completionTokens,
|
|
total_tokens: 8 + completionTokens,
|
|
},
|
|
});
|
|
response.write("data: [DONE]\n\n");
|
|
response.end();
|
|
}
|
|
|
|
function writeSse(response, payload) {
|
|
response.write(`data: ${JSON.stringify(payload)}\n\n`);
|
|
}
|
|
|
|
function splitContent(content) {
|
|
const text = String(content);
|
|
const requested = integer(env.LANGBOT_FAKE_PROVIDER_CHUNK_COUNT, 0);
|
|
if (requested <= 1 || text.length <= 1) return [text];
|
|
const chunkSize = Math.max(1, Math.ceil(text.length / requested));
|
|
const chunks = [];
|
|
for (let index = 0; index < text.length; index += chunkSize) {
|
|
chunks.push(text.slice(index, index + chunkSize));
|
|
}
|
|
return chunks;
|
|
}
|
|
|
|
function tokenEstimate(content) {
|
|
return Math.max(1, Math.ceil(String(content || "").length / 4));
|
|
}
|
|
|
|
function responseTextForBody(body) {
|
|
if (/^(0|false|no|off)$/i.test(env.LANGBOT_FAKE_PROVIDER_DYNAMIC_RESPONSE || "")) {
|
|
return responseText;
|
|
}
|
|
const messages = Array.isArray(body.messages) ? body.messages : [];
|
|
const lastUser = [...messages].reverse().find((message) => message?.role === "user");
|
|
const text = flattenContent(lastUser?.content || "");
|
|
const quoted = text.match(/["'“”](.{1,80}?)["'“”]/);
|
|
if (quoted?.[1]) return quoted[1].trim();
|
|
const exact = text.match(/(?:reply|回复|输出|return)\s+(?:exactly\s+)?([A-Za-z0-9_.:@-]{1,80})/i);
|
|
if (exact?.[1]) return exact[1].trim().replace(/[。.!?]+$/, "");
|
|
const only = text.match(/只回复\s*([A-Za-z0-9_.:@-]{1,80})/);
|
|
if (only?.[1]) return only[1].trim().replace(/[。.!?]+$/, "");
|
|
return responseText;
|
|
}
|
|
|
|
function flattenContent(content) {
|
|
if (typeof content === "string") return content;
|
|
if (Array.isArray(content)) {
|
|
return content
|
|
.map((item) => {
|
|
if (typeof item === "string") return item;
|
|
if (item && typeof item === "object") return item.text || "";
|
|
return "";
|
|
})
|
|
.join("\n");
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function recordRequest(entry) {
|
|
recentRequests.push({
|
|
...entry,
|
|
at: new Date().toISOString(),
|
|
});
|
|
while (recentRequests.length > requestLogLimit) recentRequests.shift();
|
|
}
|