ellm-proxy 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +60 -0
- package/bin/commands/autostart.js +213 -0
- package/bin/commands/config.js +8 -0
- package/bin/commands/pm2.js +28 -0
- package/bin/commands/service.js +107 -0
- package/bin/ellm-proxy.js +61 -0
- package/dist/package.json +1 -0
- package/dist/server.js +12176 -0
- package/dist/web-ui/assets/DashboardView-CQdlgirS.js +1 -0
- package/dist/web-ui/assets/DashboardView-qrQrkgLA.css +1 -0
- package/dist/web-ui/assets/LayoutView-BaY4Pz9W.js +1 -0
- package/dist/web-ui/assets/LoginView-CPvz1Ylx.js +1 -0
- package/dist/web-ui/assets/LoginView-Dg0mB6ok.css +1 -0
- package/dist/web-ui/assets/LogsView-B-bIj36j.js +9 -0
- package/dist/web-ui/assets/LogsView-Wl3_adHm.css +1 -0
- package/dist/web-ui/assets/ProcessView-0x_ShSwV.js +1 -0
- package/dist/web-ui/assets/ProcessView-DzMqBJmd.css +1 -0
- package/dist/web-ui/assets/ProvidersView-BxAxDRFH.css +1 -0
- package/dist/web-ui/assets/ProvidersView-GQENcvJS.js +31 -0
- package/dist/web-ui/assets/SettingsView-BDkymsrS.css +1 -0
- package/dist/web-ui/assets/SettingsView-BrAiaskZ.js +1 -0
- package/dist/web-ui/assets/config-DNn5cjYY.js +1 -0
- package/dist/web-ui/assets/index-BecfPmag.css +1 -0
- package/dist/web-ui/assets/index-DFlUx18F.js +96 -0
- package/dist/web-ui/assets/providers-CGEmOxne.js +1 -0
- package/dist/web-ui/assets/useApi-BRPinb_2.js +1 -0
- package/dist/web-ui/assets/useManagerApi-BDWJz5o6.js +1 -0
- package/dist/web-ui/assets/useToast-D7xlwSg6.js +1 -0
- package/dist/web-ui/config.js +10 -0
- package/dist/web-ui/index.html +15 -0
- package/docs/DESIGN.md +99 -0
- package/ellm.config.example.json +33 -0
- package/manager/api/harness/backup.js +20 -0
- package/manager/api/harness/claude-code.js +54 -0
- package/manager/api/harness/codex.js +123 -0
- package/manager/api/harness/dsh.js +175 -0
- package/manager/api/harness/index.js +118 -0
- package/manager/api/service.js +157 -0
- package/manager/index.js +170 -0
- package/package.json +40 -0
- package/utils/interpreter.js +14 -0
- package/utils/paths.js +44 -0
- package/utils/pm2.js +130 -0
- package/utils/port.js +35 -0
- package/utils/service.js +189 -0
- package/utils/ui.js +59 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// harness 代理开关聚合:Claude Code / Codex CLI / DeepSeek Harness 流量指向本机 ellm-proxy 网关。
|
|
2
|
+
// 纯开关实现:claude-code.js(~/.claude/settings.json)/ codex.js(~/.codex/config.toml)/
|
|
3
|
+
// dsh.js(~/.dsh/settings.yaml + ~/.dsh/.credentials.yaml)/ backup.js(共用备份还原)
|
|
4
|
+
// createHarnessApp = HTTP 路由子应用(挂载于 /harness):状态查询 + 开关切换(含 masterKey 校验与提示文案)
|
|
5
|
+
import { Hono } from "hono";
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { resolveGatewayBase } from "../../../utils/service.js";
|
|
8
|
+
import { claudeCodeStatus, claudeCodeEnable, claudeCodeDisable } from "./claude-code.js";
|
|
9
|
+
import { codexStatus, codexEnable, codexDisable } from "./codex.js";
|
|
10
|
+
import { dshStatus, dshEnable, dshDisable } from "./dsh.js";
|
|
11
|
+
|
|
12
|
+
/** 配置文件顶层 masterKey(Claude Code 代理的 AUTH_TOKEN 取值;网关侧用它做鉴权) */
|
|
13
|
+
function readMasterKey(configFile) {
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(readFileSync(configFile, "utf-8")).masterKey || "";
|
|
16
|
+
} catch {
|
|
17
|
+
return "";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** harness 开关子应用(挂载于 /harness):GET / · POST /claude-code · POST /codex · POST /dsh(body {enabled}) */
|
|
22
|
+
export function createHarnessApp({ startOpts }) {
|
|
23
|
+
const app = new Hono();
|
|
24
|
+
|
|
25
|
+
// GET /harness:三个开关状态
|
|
26
|
+
app.get("/", (c) => {
|
|
27
|
+
const { base } = resolveGatewayBase(startOpts.config);
|
|
28
|
+
return c.json({ ok: true, claudeCode: claudeCodeStatus(base), codex: codexStatus(), dsh: dshStatus(base), gatewayBase: base });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/** 读 body 并校验 enabled;合法返回 boolean,非法返回 400 Response(调用方直接 return 该响应) */
|
|
32
|
+
const requireEnabled = async (c) => {
|
|
33
|
+
let enabled;
|
|
34
|
+
try {
|
|
35
|
+
enabled = (await c.req.json())?.enabled;
|
|
36
|
+
} catch {
|
|
37
|
+
return c.json({ ok: false, error: "请求体不是合法 JSON" }, 400);
|
|
38
|
+
}
|
|
39
|
+
if (typeof enabled !== "boolean") {
|
|
40
|
+
return c.json({ ok: false, error: 'body 需为 {"enabled": true|false}' }, 400);
|
|
41
|
+
}
|
|
42
|
+
return enabled;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
app.post("/claude-code", async (c) => {
|
|
46
|
+
const enabled = await requireEnabled(c);
|
|
47
|
+
if (enabled instanceof Response) return enabled;
|
|
48
|
+
const { base, configFile } = resolveGatewayBase(startOpts.config);
|
|
49
|
+
if (enabled) {
|
|
50
|
+
const masterKey = readMasterKey(configFile);
|
|
51
|
+
if (!masterKey) {
|
|
52
|
+
return c.json(
|
|
53
|
+
{
|
|
54
|
+
ok: false,
|
|
55
|
+
error: `配置未设置 masterKey,Claude Code 代理无法鉴权。请先在配置文件中设置 masterKey: ${configFile}`,
|
|
56
|
+
},
|
|
57
|
+
400,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
claudeCodeEnable({ gatewayBase: base, token: masterKey });
|
|
61
|
+
return c.json({
|
|
62
|
+
ok: true,
|
|
63
|
+
claudeCode: true,
|
|
64
|
+
file: "~/.claude/settings.json",
|
|
65
|
+
note: "已备份原文件为 settings.json.ellm-bak,重启 Claude Code 会话后生效",
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
const changed = claudeCodeDisable(base);
|
|
69
|
+
return c.json({ ok: true, claudeCode: false, changed, note: changed ? "已从备份还原" : "原本未开启,无操作" });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
app.post("/codex", async (c) => {
|
|
73
|
+
const enabled = await requireEnabled(c);
|
|
74
|
+
if (enabled instanceof Response) return enabled;
|
|
75
|
+
const { base, configFile } = resolveGatewayBase(startOpts.config);
|
|
76
|
+
if (enabled) {
|
|
77
|
+
codexEnable({ gatewayBase: base });
|
|
78
|
+
const masterKey = readMasterKey(configFile);
|
|
79
|
+
return c.json({
|
|
80
|
+
ok: true,
|
|
81
|
+
codex: true,
|
|
82
|
+
file: "~/.codex/config.toml",
|
|
83
|
+
note: masterKey
|
|
84
|
+
? `已备份原文件为 config.toml.ellm-bak;请设置环境变量 ELLM_PROXY_API_KEY=${masterKey}(Codex 以 env_key 读取鉴权)`
|
|
85
|
+
: "已备份原文件为 config.toml.ellm-bak;请设置环境变量 ELLM_PROXY_API_KEY(网关未配 masterKey 时取值任意)",
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
const changed = codexDisable();
|
|
89
|
+
return c.json({ ok: true, codex: false, changed, note: changed ? "已从备份还原" : "原本未开启,无操作" });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
app.post("/dsh", async (c) => {
|
|
93
|
+
const enabled = await requireEnabled(c);
|
|
94
|
+
if (enabled instanceof Response) return enabled;
|
|
95
|
+
const { base, configFile } = resolveGatewayBase(startOpts.config);
|
|
96
|
+
const masterKey = readMasterKey(configFile);
|
|
97
|
+
if (enabled) {
|
|
98
|
+
dshEnable({ gatewayBase: base, masterKey });
|
|
99
|
+
return c.json({
|
|
100
|
+
ok: true,
|
|
101
|
+
dsh: true,
|
|
102
|
+
file: "~/.dsh/settings.yaml",
|
|
103
|
+
note: masterKey
|
|
104
|
+
? "已写入 settings.yaml(llm-deepseek.baseURL)与 .credentials.yaml(refs.DEEPSEEK_API_KEY),原文件备份为 *.ellm-bak。注意:进程环境里已设 DEEPSEEK_API_KEY 时其优先级更高,需自行改用网关 masterKey;模型请在 dsh 内选择与网关部署名一致的模型"
|
|
105
|
+
: "已写入 settings.yaml 与 .credentials.yaml(网关未配 masterKey,密钥为占位值即可放行),原文件备份为 *.ellm-bak。模型请在 dsh 内选择与网关部署名一致的模型",
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
const changed = dshDisable(base, masterKey);
|
|
109
|
+
return c.json({ ok: true, dsh: false, changed, note: changed ? "已从备份还原(或移除本开关写入的字段)" : "原本未开启,无操作" });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
return app;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 纯开关函数同时聚合导出(供不经过 HTTP 的调用方直接使用)
|
|
116
|
+
export * from "./claude-code.js";
|
|
117
|
+
export * from "./codex.js";
|
|
118
|
+
export * from "./dsh.js";
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// 主服务进程控制 api 子应用:状态查询与启停。
|
|
2
|
+
// 注意 stop 语义 = 只停主服务(与 CLI ellm-proxy stop 的全停语义不同)——
|
|
3
|
+
// 管理服务若自杀,再无人能远程拉起主服务。
|
|
4
|
+
import { readFileSync, realpathSync, statSync } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { dirname, join, resolve } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { createRequire } from "node:module";
|
|
9
|
+
import { Hono } from "hono";
|
|
10
|
+
import { APP_NAME, MANAGER_APP_NAME } from "../../utils/paths.js";
|
|
11
|
+
import { withPm2, findApp, pm2cb, safeDump } from "../../utils/pm2.js";
|
|
12
|
+
import { doStart, resolveGatewayBase, resolvePort } from "../../utils/service.js";
|
|
13
|
+
|
|
14
|
+
// 本文件在 manager/api/ 下,包根为上两级
|
|
15
|
+
const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
16
|
+
|
|
17
|
+
/** ellm-proxy 进程状态(pm2 视角) */
|
|
18
|
+
async function statusPayload(startOpts) {
|
|
19
|
+
const proc = await withPm2(() => findApp());
|
|
20
|
+
const { base } = resolveGatewayBase(startOpts.config);
|
|
21
|
+
return {
|
|
22
|
+
running: !!proc,
|
|
23
|
+
pid: proc?.pid ?? null,
|
|
24
|
+
pm2Status: proc?.pm2_env?.status ?? null,
|
|
25
|
+
uptime: proc?.pm2_env?.pm_uptime ?? null,
|
|
26
|
+
gatewayBase: base,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** pm2 依赖树内的 pidusage(realpath 穿透 pnpm 符号链接):pm2 守护进程自身的内存/CPU 采样 */
|
|
31
|
+
function loadPidusage() {
|
|
32
|
+
const pm2PkgJson = realpathSync(join(pkgRoot, "node_modules", "pm2", "package.json"));
|
|
33
|
+
return createRequire(pm2PkgJson)("pidusage");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 统一行形状:role = daemon | manager | main(前端按 main 判定可操作行) */
|
|
37
|
+
function row(role, { pid = null, status = "stopped", port = null, startedAt = null, memory = null, cpu = null, restarts = null }) {
|
|
38
|
+
return {
|
|
39
|
+
role,
|
|
40
|
+
pid,
|
|
41
|
+
status,
|
|
42
|
+
port,
|
|
43
|
+
address: port ? `http://127.0.0.1:${port}` : null,
|
|
44
|
+
startedAt,
|
|
45
|
+
memory,
|
|
46
|
+
cpu,
|
|
47
|
+
restarts,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** pm2 守护进程行:PID 读 PM2_HOME/pm2.pid(文件只在 daemon 启动时写,mtime 即启动时刻);
|
|
52
|
+
* 内存/CPU 用 pidusage 采样(pm2 jlist 不含 daemon 自身) */
|
|
53
|
+
async function daemonRow() {
|
|
54
|
+
const pm2Home = process.env.PM2_HOME || join(homedir(), ".pm2");
|
|
55
|
+
const pidFile = join(pm2Home, "pm2.pid");
|
|
56
|
+
let pid = null;
|
|
57
|
+
let startedAt = null;
|
|
58
|
+
try {
|
|
59
|
+
pid = Number(readFileSync(pidFile, "utf8").trim()) || null;
|
|
60
|
+
startedAt = statSync(pidFile).mtimeMs;
|
|
61
|
+
} catch {
|
|
62
|
+
/* 无 pm2.pid = daemon 未运行 */
|
|
63
|
+
}
|
|
64
|
+
if (!pid) return row("daemon");
|
|
65
|
+
let memory = null;
|
|
66
|
+
let cpu = null;
|
|
67
|
+
try {
|
|
68
|
+
const stat = await loadPidusage()(pid);
|
|
69
|
+
memory = stat.memory ?? null;
|
|
70
|
+
cpu = stat.cpu != null ? Math.round(stat.cpu * 10) / 10 : null;
|
|
71
|
+
} catch {
|
|
72
|
+
/* pid 文件存在但进程已死(采样 reject):按未运行处理 */
|
|
73
|
+
pid = null;
|
|
74
|
+
}
|
|
75
|
+
return row("daemon", { pid, status: pid ? "online" : "stopped", port: null, startedAt, memory, cpu });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** pm2 托管进程行:monit(内存/CPU,pm2 周期采样)+ pm_uptime(启动时刻)+ restart_time */
|
|
79
|
+
function appRow(proc, role, { port }) {
|
|
80
|
+
if (!proc) return row(role);
|
|
81
|
+
const env = proc.pm2_env ?? {};
|
|
82
|
+
const monit = proc.monit ?? {};
|
|
83
|
+
return row(role, {
|
|
84
|
+
pid: proc.pid ?? null,
|
|
85
|
+
status: env.status ?? "unknown",
|
|
86
|
+
port: port ?? null,
|
|
87
|
+
startedAt: env.pm_uptime ?? null,
|
|
88
|
+
memory: monit.memory ?? null,
|
|
89
|
+
cpu: monit.cpu != null ? Math.round(monit.cpu * 10) / 10 : null,
|
|
90
|
+
restarts: env.restart_time ?? null,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 全进程列表:pm2 守护进程 / 管理服务 / 主服务。
|
|
95
|
+
* 端口口径:主服务 = pm2 注入的 ELLM_PORT(含占用顺延后的实际值)> env > 配置文件;
|
|
96
|
+
* 管理服务 = ELLM_MANAGER_PORT env > 主端口 + 1(与 manager/index.js 的预算口径一致)。 */
|
|
97
|
+
async function processesPayload(startOpts) {
|
|
98
|
+
const list = (await withPm2(() => pm2cb("list"))) || [];
|
|
99
|
+
const main = list.find((p) => p.name === APP_NAME);
|
|
100
|
+
const manager = list.find((p) => p.name === MANAGER_APP_NAME);
|
|
101
|
+
const mainPort =
|
|
102
|
+
Number(main?.pm2_env?.env?.ELLM_PORT) || resolvePort(process.env.ELLM_PORT, startOpts.config) || null;
|
|
103
|
+
const managerPort = Number(process.env.ELLM_MANAGER_PORT) || (mainPort ? mainPort + 1 : null);
|
|
104
|
+
return [
|
|
105
|
+
await daemonRow(),
|
|
106
|
+
appRow(manager, "manager", { port: managerPort }),
|
|
107
|
+
appRow(main, "main", { port: mainPort }),
|
|
108
|
+
];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 仅停止主服务(管理服务保持存活);管理服务仍在 dump 列表中,resurrect 后它会先起来继续提供远程启停 */
|
|
112
|
+
async function stopMainOnly() {
|
|
113
|
+
return withPm2(async () => {
|
|
114
|
+
const proc = await findApp();
|
|
115
|
+
if (!proc) return "already-stopped";
|
|
116
|
+
await pm2cb("delete", APP_NAME);
|
|
117
|
+
await safeDump();
|
|
118
|
+
return "stopped";
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** pm2 启停:start / stop / restart(restart = 停主服务 + start,
|
|
123
|
+
* start 时管理服务已在线会被 doStart 的幂等检查跳过) */
|
|
124
|
+
async function runStartStop(action, startOpts) {
|
|
125
|
+
if (action === "stop") return stopMainOnly();
|
|
126
|
+
if (action === "start") return doStart(startOpts);
|
|
127
|
+
await stopMainOnly();
|
|
128
|
+
await doStart(startOpts);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 进程控制子应用(挂载于根路径):GET /status · GET /processes · POST /start|stop|restart */
|
|
132
|
+
export function createServiceApp({ startOpts }) {
|
|
133
|
+
const app = new Hono();
|
|
134
|
+
|
|
135
|
+
app.get("/status", async (c) => c.json({ ok: true, ...(await statusPayload(startOpts)) }));
|
|
136
|
+
|
|
137
|
+
// 全进程列表(三行固定:pm2 守护进程 / 管理服务 / 主服务);启停操作仍只走
|
|
138
|
+
// /start|stop|restart 的主服务语义——daemon 与管理服务不允许被远程操控(自杀失联风险)
|
|
139
|
+
app.get("/processes", async (c) =>
|
|
140
|
+
c.json({ ok: true, processes: await processesPayload(startOpts) }),
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
app.post("/start", async (c) => {
|
|
144
|
+
await runStartStop("start", startOpts);
|
|
145
|
+
return c.json({ ok: true, action: "start" });
|
|
146
|
+
});
|
|
147
|
+
app.post("/stop", async (c) => {
|
|
148
|
+
await runStartStop("stop", startOpts);
|
|
149
|
+
return c.json({ ok: true, action: "stop" });
|
|
150
|
+
});
|
|
151
|
+
app.post("/restart", async (c) => {
|
|
152
|
+
await runStartStop("restart", startOpts);
|
|
153
|
+
return c.json({ ok: true, action: "restart" });
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
return app;
|
|
157
|
+
}
|
package/manager/index.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// ellm-proxy 管理服务入口(独立常驻脚本):随 `ellm-proxy start` 一并由 pm2 托管(name=ellm-proxy-manager);
|
|
2
|
+
// 也可手动 `node manager/index.js` 运行。本文件 = 创建 app(中间件 + api 聚合挂载)+ 启动 HTTP 服务;
|
|
3
|
+
// 各 api 域实现在 api/ 下:service.js(进程控制,命名对齐 backend/src/api/service.ts)、harness/(代理开关域)。
|
|
4
|
+
//
|
|
5
|
+
// 为什么独立于 backend:ellm-proxy 与 backend 同进程,进程自杀后管理台与转发层同时失联,
|
|
6
|
+
// 无法返回操作结果——启停必须由活在外面的本服务执行。
|
|
7
|
+
//
|
|
8
|
+
// 管理界面托管:本服务同进程托管 web-ui 构建产物(根路径 /,hash 路由),并反代 /api/* 到主服务——
|
|
9
|
+
// web-ui 全部接口(/api/* 管理台 + /status /restart /harness 等进程/开关域)同源可达,config.js 留空零配置。
|
|
10
|
+
//
|
|
11
|
+
// 接口(CORS 全放开,均返回 {ok, ...} 或 {ok:false, error}):
|
|
12
|
+
// GET / web-ui 管理界面(静态托管,产物缺失时仅告警跳过)
|
|
13
|
+
// GET /api/* 反代主服务管理台 API(端口 = ELLM_PORT env > 配置文件 port,每请求解析)
|
|
14
|
+
// GET /status ellm-proxy 运行状态(running/pid/status/uptime/网关端口)
|
|
15
|
+
// GET /processes 全进程列表(pm2 守护进程/管理服务/主服务:状态/PID/端口/内存/CPU/运行时长)
|
|
16
|
+
// POST /start | /stop | /restart pm2 启动/停止/重启 ellm-proxy(复用 utils/service.js,含探活)
|
|
17
|
+
// GET /harness Claude Code / Codex / DeepSeek Harness 代理开关状态
|
|
18
|
+
// POST /harness/claude-code body {enabled};开 = 写 ~/.claude/settings.json(备份),关 = 还原备份
|
|
19
|
+
// POST /harness/codex body {enabled};开 = 写 ~/.codex/config.toml(备份),关 = 还原备份
|
|
20
|
+
// POST /harness/dsh body {enabled};开 = 写 ~/.dsh/settings.yaml + .credentials.yaml(备份),关 = 还原
|
|
21
|
+
//
|
|
22
|
+
// 配置来源(env 优先,argv 可覆盖,缺省兜底):
|
|
23
|
+
// 监听端口 ELLM_MANAGER_PORT(doStart 统一预算 = 主服务端口+1)> argv --port > 29771
|
|
24
|
+
// web-ui 产物 ELLM_WEBUI_DIR(可选覆盖)> 按本文件位置推导:包模式 <pkgRoot>/dist/web-ui → 仓库模式 <repoRoot>/web-ui/dist
|
|
25
|
+
// 主服务参数 与 ellm-proxy start 注入的 env 同源:ELLM_CONFIG / ELLM_DATA_DIR / ELLM_APP_DIR / ELLM_PORT /
|
|
26
|
+
// ELLM_SERVER_JS(主服务 dist/server.js 路径,restart 重启主服务时使用,保证参数不漂移)
|
|
27
|
+
import { existsSync } from "node:fs";
|
|
28
|
+
import { readFile } from "node:fs/promises";
|
|
29
|
+
import { Hono } from "hono";
|
|
30
|
+
import { cors } from "hono/cors";
|
|
31
|
+
import { proxy } from "hono/proxy";
|
|
32
|
+
import { serve } from "@hono/node-server";
|
|
33
|
+
import { serveStatic } from "@hono/node-server/serve-static";
|
|
34
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
35
|
+
import { fileURLToPath } from "node:url";
|
|
36
|
+
import mri from "mri";
|
|
37
|
+
import { absolutize } from "../utils/paths.js";
|
|
38
|
+
import { resolveGatewayBase } from "../utils/service.js";
|
|
39
|
+
import { ICONS, say } from "../utils/ui.js";
|
|
40
|
+
import { createServiceApp } from "./api/service.js";
|
|
41
|
+
import { createHarnessApp } from "./api/harness/index.js";
|
|
42
|
+
|
|
43
|
+
const DEFAULT_SERVER_PORT = 29771; // 手动运行且无 env 时的兜底(默认网关 29770 + 1)
|
|
44
|
+
// 本文件在 manager/ 下,包根为上一级
|
|
45
|
+
const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
46
|
+
|
|
47
|
+
// web-ui 构建产物目录:ELLM_WEBUI_DIR 优先;缺省按本文件位置推导(包模式 dist/web-ui 与 server.js 同级,
|
|
48
|
+
// 仓库模式落到 monorepo 的 web-ui/dist)。启动时判定一次,缺失则跳过静态托管(接口不受影响)。
|
|
49
|
+
const WEBUI_DIST_DIR = process.env.ELLM_WEBUI_DIR
|
|
50
|
+
? resolve(process.cwd(), process.env.ELLM_WEBUI_DIR)
|
|
51
|
+
: ([join(pkgRoot, "dist", "web-ui"), resolve(pkgRoot, "..", "web-ui", "dist")].find((p) =>
|
|
52
|
+
existsSync(join(p, "index.html")),
|
|
53
|
+
) ?? join(pkgRoot, "dist", "web-ui"));
|
|
54
|
+
const HAS_WEBUI = existsSync(join(WEBUI_DIST_DIR, "index.html"));
|
|
55
|
+
|
|
56
|
+
/** web-ui 管理界面静态托管:/ 出 index.html(hash 路由唯一服务端入口),assets/ 补长缓存头。
|
|
57
|
+
* 须在 api 聚合与 /api 反代之后注册——serve-static 未命中才放行,不会遮蔽接口。 */
|
|
58
|
+
function registerWebUi(app) {
|
|
59
|
+
if (!HAS_WEBUI) return;
|
|
60
|
+
// serve-static 的 root 只接受相对 cwd 的路径:把绝对定位换算成当前 cwd 表达(仓库/发布包/任意 cwd 均成立)
|
|
61
|
+
const root = relative(process.cwd(), WEBUI_DIST_DIR);
|
|
62
|
+
app.get("/", async (c) => {
|
|
63
|
+
try {
|
|
64
|
+
const buf = await readFile(join(WEBUI_DIST_DIR, "index.html"));
|
|
65
|
+
return c.body(new Uint8Array(buf), 200, {
|
|
66
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
67
|
+
"Cache-Control": "no-cache",
|
|
68
|
+
});
|
|
69
|
+
} catch {
|
|
70
|
+
return c.text("Not Found", 404);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
app.use("/*", (c, next) => {
|
|
74
|
+
if (c.req.path === "/") return next(); // 根路径由上面的 serveIndex 处理
|
|
75
|
+
return serveStatic({
|
|
76
|
+
root,
|
|
77
|
+
onFound: (_p, ctx) => {
|
|
78
|
+
if (ctx.req.path.startsWith("/assets/")) ctx.header("Cache-Control", "public, max-age=31536000, immutable");
|
|
79
|
+
},
|
|
80
|
+
})(c, next);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 创建管理服务 app:CORS 全放开 + api 聚合挂载 + /api 反代主服务 + web-ui 静态托管 + 统一 404/500 */
|
|
85
|
+
function createApp({ startOpts }) {
|
|
86
|
+
const app = new Hono();
|
|
87
|
+
|
|
88
|
+
// CORS 全放开(OPTIONS 预检由中间件自动处理)
|
|
89
|
+
app.use("*", cors());
|
|
90
|
+
|
|
91
|
+
// api 聚合:进程控制(GET /status · POST /start|stop|restart)与 harness 开关(GET /harness · POST /harness/claude-code|codex|dsh)
|
|
92
|
+
app.route("/", createServiceApp({ startOpts }));
|
|
93
|
+
app.route("/harness", createHarnessApp({ startOpts }));
|
|
94
|
+
|
|
95
|
+
// 反代主服务管理台 API(web-ui 同源访问的 /api/*):端口与 start 口径一致(ELLM_PORT env > 配置文件),
|
|
96
|
+
// 每请求解析——主服务端口顺延漂移无感知
|
|
97
|
+
app.all("/api/*", (c) => {
|
|
98
|
+
const { base } = resolveGatewayBase();
|
|
99
|
+
const port = Number(new URL(base).port);
|
|
100
|
+
if (!port) {
|
|
101
|
+
return c.json(
|
|
102
|
+
{ ok: false, error: "主服务端口无法解析(ELLM_PORT env 与配置文件 port 均缺失),无法转发 /api/*" },
|
|
103
|
+
502,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
// 目标 URL 必须带上原始查询串:c.req.path 只有 pathname,漏拼 search 会把
|
|
107
|
+
// ?id=... / ?page=... 等查询参数全部丢掉(主服务按缺参返回 400 或默认分页)
|
|
108
|
+
const incoming = new URL(c.req.raw.url);
|
|
109
|
+
return proxy(new URL(incoming.pathname + incoming.search, base), {
|
|
110
|
+
method: c.req.method,
|
|
111
|
+
headers: c.req.raw.headers,
|
|
112
|
+
body: c.req.method === "GET" || c.req.method === "HEAD" ? undefined : c.req.raw.body,
|
|
113
|
+
duplex: "half", // body 为流时 undici 必需
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
registerWebUi(app);
|
|
118
|
+
|
|
119
|
+
app.notFound((c) =>
|
|
120
|
+
c.json(
|
|
121
|
+
{
|
|
122
|
+
ok: false,
|
|
123
|
+
error: `未知接口: ${c.req.method} ${c.req.path}(可用: GET /(管理界面), GET /api/*(反代主服务), GET /status, GET /processes, POST /start|stop|restart, GET /harness, POST /harness/claude-code|codex|dsh)`,
|
|
124
|
+
},
|
|
125
|
+
404,
|
|
126
|
+
),
|
|
127
|
+
);
|
|
128
|
+
app.onError((err, c) => {
|
|
129
|
+
console.error(`[ellm-proxy-manager] ${c.req.method} ${c.req.path} 失败:`, err?.message ?? err);
|
|
130
|
+
return c.json({ ok: false, error: err?.message ?? String(err) }, 500);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
return app;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function main() {
|
|
137
|
+
// argv 解析(mri):仅 --port(其余主服务参数一律走 env,与 start 注入链同源)
|
|
138
|
+
const argv = mri(process.argv.slice(2));
|
|
139
|
+
const listenPort = Number(argv.port) || Number(process.env.ELLM_MANAGER_PORT) || DEFAULT_SERVER_PORT;
|
|
140
|
+
// 主服务启动参数(start/restart 时透传给 doStart):ELLM_* env 与 CLI start 注入链同源
|
|
141
|
+
const startOpts = absolutize({
|
|
142
|
+
config: process.env.ELLM_CONFIG,
|
|
143
|
+
dataDir: process.env.ELLM_DATA_DIR,
|
|
144
|
+
workingDir: process.env.ELLM_APP_DIR,
|
|
145
|
+
serverJs: process.env.ELLM_SERVER_JS || join(pkgRoot, "dist", "server.js"),
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const app = createApp({ startOpts });
|
|
149
|
+
|
|
150
|
+
const server = serve({ fetch: app.fetch, port: listenPort, hostname: "127.0.0.1" });
|
|
151
|
+
server.on("error", (err) => {
|
|
152
|
+
console.error(
|
|
153
|
+
`错误:管理服务监听 ${listenPort} 失败(${err.code ?? err.message})${err.code === "EADDRINUSE" ? ",端口被占用" : ""}`,
|
|
154
|
+
);
|
|
155
|
+
process.exit(1);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
say(`ellm-proxy 管理服务已启动: http://127.0.0.1:${listenPort}`, ICONS.rocket);
|
|
159
|
+
say(
|
|
160
|
+
HAS_WEBUI
|
|
161
|
+
? ` 管理界面: http://127.0.0.1:${listenPort}/(web-ui,/api/* 反代主服务)`
|
|
162
|
+
: ` 管理界面未托管:web-ui 构建产物缺失(${WEBUI_DIST_DIR}),仓库根执行 pnpm build:web 后重启本服务`,
|
|
163
|
+
);
|
|
164
|
+
say(
|
|
165
|
+
" 接口: GET /status /processes · POST /start /stop /restart · GET /harness · POST /harness/claude-code /harness/codex /harness/dsh",
|
|
166
|
+
);
|
|
167
|
+
say(" CORS 已全放开(仅监听 127.0.0.1,不对外)");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
main();
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ellm-proxy",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Self-hosted LLM gateway with admin web UI (pm2-managed)",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=22.13.0"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"ellm-proxy": "bin/ellm-proxy.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"bin",
|
|
15
|
+
"manager",
|
|
16
|
+
"utils",
|
|
17
|
+
"dist",
|
|
18
|
+
"docs",
|
|
19
|
+
"ellm.config.example.json",
|
|
20
|
+
"README.md",
|
|
21
|
+
"LICENSE"
|
|
22
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "node scripts/build-package.mjs",
|
|
25
|
+
"prepack": "node scripts/build-package.mjs"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/node": "^22.12.1"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@hono/node-server": "^1.19.17",
|
|
32
|
+
"cac": "^6.7.14",
|
|
33
|
+
"get-node": "^15.0.4",
|
|
34
|
+
"hono": "^4.13.5",
|
|
35
|
+
"mri": "^1.2.0",
|
|
36
|
+
"pm2": "^7.0.4",
|
|
37
|
+
"smol-toml": "^1.8.0",
|
|
38
|
+
"yaml": "^2.9.0"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// 服务解释器解析:保证 dist/server.js(node:sqlite)运行在 Node >= MIN_NODE 上。
|
|
2
|
+
// CLI 自身 node 满足 → process.execPath;否则 get-node 解析(缺失时下载一次并缓存),
|
|
3
|
+
// pm2 daemon 的 node 版本无关紧要(interpreter 由本模块保证)。
|
|
4
|
+
import getNode from "get-node";
|
|
5
|
+
import { MIN_NODE } from "./paths.js";
|
|
6
|
+
|
|
7
|
+
export async function resolveInterpreter() {
|
|
8
|
+
const [maj, min] = process.version.replace(/^v/, "").split(".").map(Number);
|
|
9
|
+
if (maj > 22 || (maj === 22 && min >= 13)) return process.execPath;
|
|
10
|
+
console.log(`当前 node ${process.version} 不满足 ${MIN_NODE},正在解析可用版本(缺失时自动下载一次并缓存)...`);
|
|
11
|
+
const { path, version } = await getNode(MIN_NODE, { progress: true });
|
|
12
|
+
console.log(`服务将使用 node ${version}: ${path}`);
|
|
13
|
+
return path;
|
|
14
|
+
}
|
package/utils/paths.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// 共享常量与路径工具:包根/应用目录常量、配置查找链、首次配置自愈、选项绝对化。
|
|
2
|
+
// 供 CLI(bin/)与管理服务(manager/)两侧复用;工具一律返回数据,人读文案由调用方输出。
|
|
3
|
+
import { readFileSync, mkdirSync, existsSync, copyFileSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { dirname, join, resolve } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
/** 本文件在 utils/ 下,包根为上一级 */
|
|
9
|
+
export const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
10
|
+
export const defaultAppDir = join(homedir(), ".ellm-proxy");
|
|
11
|
+
export const pkg = JSON.parse(readFileSync(join(pkgRoot, "package.json"), "utf-8"));
|
|
12
|
+
export const APP_NAME = "ellm-proxy"; // 主服务(dist/server.js)的 pm2 进程名
|
|
13
|
+
export const MANAGER_APP_NAME = "ellm-proxy-manager"; // 管理服务(manager/index.js)的 pm2 进程名
|
|
14
|
+
export const MIN_NODE = ">=22.13.0"; // node:sqlite 最低版(与包 engines 一致)
|
|
15
|
+
|
|
16
|
+
/** 配置文件路径:--config > ELLM_CONFIG 环境变量 > ~/.ellm-proxy/ellm.config.json(与 server 端查找链一致) */
|
|
17
|
+
export function configPath(override) {
|
|
18
|
+
if (override) return resolve(override);
|
|
19
|
+
return process.env["ELLM_CONFIG"] ? resolve(process.env["ELLM_CONFIG"]) : join(defaultAppDir, "ellm.config.json");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 首次配置自愈:不存在则从包内示例复制创建,并提示两种修改方式 */
|
|
23
|
+
export function ensureConfig(override) {
|
|
24
|
+
const config = configPath(override);
|
|
25
|
+
if (existsSync(config)) return config;
|
|
26
|
+
const example = join(pkgRoot, "ellm.config.example.json");
|
|
27
|
+
if (!existsSync(example)) {
|
|
28
|
+
throw new Error(`缺少示例配置 ${example},无法自动创建配置文件`);
|
|
29
|
+
}
|
|
30
|
+
mkdirSync(dirname(config), { recursive: true });
|
|
31
|
+
copyFileSync(example, config);
|
|
32
|
+
console.log(`已创建配置文件: ${config}`);
|
|
33
|
+
console.log("可直接编辑该文件(填入供应商 apiKey),或经管理台在线修改(实时热更新)。");
|
|
34
|
+
return config;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 路径类选项统一转绝对路径(相对按调用方当前目录展开;CLI cwd 与 node 进程工作目录基准不同,透传相对串会错位) */
|
|
38
|
+
export function absolutize(opts) {
|
|
39
|
+
const out = { ...opts };
|
|
40
|
+
for (const key of ["config", "dataDir", "workingDir", "serverJs"]) {
|
|
41
|
+
if (out[key]) out[key] = resolve(out[key]);
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
package/utils/pm2.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// pm2 JS API 封装:连接包裹/回调转 promise/应用查询/探活轮询/安全 dump/包内 pm2 CLI 定位/env 白名单。
|
|
2
|
+
import { rmSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
import pm2 from "pm2";
|
|
7
|
+
import { APP_NAME, MANAGER_APP_NAME } from "./paths.js";
|
|
8
|
+
import { dot, endOk, endFail } from "./ui.js";
|
|
9
|
+
|
|
10
|
+
/** pm2.connect 包裹:fn 内可调 pm2.xxx(回调式),结束后自动 disconnect */
|
|
11
|
+
export function withPm2(fn) {
|
|
12
|
+
return new Promise((resolvePromise, reject) => {
|
|
13
|
+
pm2.connect((connectErr) => {
|
|
14
|
+
if (connectErr) return reject(connectErr);
|
|
15
|
+
Promise.resolve(fn(pm2)).then(
|
|
16
|
+
(value) => pm2.disconnect(() => resolvePromise(value)),
|
|
17
|
+
(err) => {
|
|
18
|
+
try {
|
|
19
|
+
pm2.disconnect();
|
|
20
|
+
} catch {
|
|
21
|
+
/* 已断开 */
|
|
22
|
+
}
|
|
23
|
+
reject(err);
|
|
24
|
+
},
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** pm2 回调式方法转 promise */
|
|
31
|
+
export function pm2cb(method, ...args) {
|
|
32
|
+
return new Promise((resolvePromise, reject) => {
|
|
33
|
+
pm2[method](...args, (err, ...out) => (err ? reject(err) : resolvePromise(...out)));
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 查询应用当前进程记录(list 中按 name 匹配;不存在返回 undefined)。
|
|
38
|
+
* name 缺省 = 主服务 ellm-proxy;管理服务传 MANAGER_APP_NAME */
|
|
39
|
+
export async function findApp(name = APP_NAME) {
|
|
40
|
+
const list = (await pm2cb("list")) || [];
|
|
41
|
+
return list.find((p) => p.name === name);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 启动探活:轮询 status 至 online(每 pollMs 一次,全程打进度点)——比固定等待更快成功
|
|
45
|
+
* (online 即返回),也能容忍慢启动(默认 15s 上限,固定 1.5s 在慢机器上会误报);errored/stopped
|
|
46
|
+
* 提前失败(脚本秒崩不必等满超时)。failHint 是失败文案的线索部分(文案是契约的一部分)。
|
|
47
|
+
* quiet = 静默轮询不打点(多服务并行等待时输出由调用方合并,避免多组进度点交错)。 */
|
|
48
|
+
export async function waitOnline(name, failHint, { timeoutMs = 15000, pollMs = 300, quiet = false } = {}) {
|
|
49
|
+
const deadline = Date.now() + timeoutMs;
|
|
50
|
+
let last = null;
|
|
51
|
+
for (;;) {
|
|
52
|
+
last = await findApp(name);
|
|
53
|
+
const status = last?.pm2_env?.status;
|
|
54
|
+
if (status === "online") {
|
|
55
|
+
if (!quiet) endOk();
|
|
56
|
+
return last;
|
|
57
|
+
}
|
|
58
|
+
if (status === "errored" || status === "stopped" || Date.now() >= deadline) {
|
|
59
|
+
if (!quiet) endFail();
|
|
60
|
+
throw new Error(`${name} 启动后状态异常(${status ?? "已退出"})。${failHint}`);
|
|
61
|
+
}
|
|
62
|
+
if (!quiet) dot();
|
|
63
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 安全 dump(start/stop 成功后调用):pm2 不允许 dump 空列表会报错——此时说明全部进程已删,
|
|
68
|
+
* 清掉旧 dump.pm2 与滚动备份 dump.pm2.bak(resurrect 无 dump.pm2 时会回落 .bak),防止开机
|
|
69
|
+
* resurrect 把已停止的服务拉起来 */
|
|
70
|
+
export async function safeDump() {
|
|
71
|
+
try {
|
|
72
|
+
await pm2cb("dump");
|
|
73
|
+
} catch {
|
|
74
|
+
try {
|
|
75
|
+
rmSync(join(homedir(), ".pm2", "dump.pm2"), { force: true });
|
|
76
|
+
rmSync(join(homedir(), ".pm2", "dump.pm2.bak"), { force: true });
|
|
77
|
+
} catch {
|
|
78
|
+
/* 清理失败不影响 stop 语义 */
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 当前包内 pm2 CLI 入口绝对路径(autostart cmd 用;resolve 兼容 npm 全局平铺与 pnpm 布局) */
|
|
84
|
+
export function pm2BinJs() {
|
|
85
|
+
const pm2PkgRoot = dirname(createRequire(import.meta.url).resolve("pm2/package.json"));
|
|
86
|
+
return join(pm2PkgRoot, "bin", "pm2");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 清洗 pm2 子进程透传的 env:pm2 托管的进程自身 env 带 pm_id/pm2_env/unique_id 等
|
|
90
|
+
* 内部变量与 bash 注入的 `_`,原样传回 pm2.start 会污染新进程注册——实测主服务
|
|
91
|
+
* (name=ellm-proxy)被注册成管理服务的名字,导致探活误判「已退出」。
|
|
92
|
+
* 黑名单清洗实测仍有漏网(多键组合触发),故改用白名单:
|
|
93
|
+
* 只透传主服务必需的业务键(ELLM_* 前缀,含 ELLM_PORT / ELLM_MANAGER_PORT)与 Windows node 运行所需的系统键。
|
|
94
|
+
* PM2_HOME 保留(pm2 API 连接 daemon 需要,主服务自身不用)。 */
|
|
95
|
+
const PM2_ENV_WHITELIST = new Set([
|
|
96
|
+
"PATH",
|
|
97
|
+
"SYSTEMROOT",
|
|
98
|
+
"WINDIR",
|
|
99
|
+
"SYSTEMDRIVE",
|
|
100
|
+
"COMSPEC",
|
|
101
|
+
"PATHEXT",
|
|
102
|
+
"OS",
|
|
103
|
+
"TEMP",
|
|
104
|
+
"TMP",
|
|
105
|
+
"USERPROFILE",
|
|
106
|
+
"HOMEDRIVE",
|
|
107
|
+
"HOMEPATH",
|
|
108
|
+
"APPDATA",
|
|
109
|
+
"LOCALAPPDATA",
|
|
110
|
+
"PROGRAMDATA",
|
|
111
|
+
"PROGRAMFILES",
|
|
112
|
+
"ALLUSERSPROFILE",
|
|
113
|
+
"PUBLIC",
|
|
114
|
+
"USERNAME",
|
|
115
|
+
"USERDOMAIN",
|
|
116
|
+
"COMPUTERNAME",
|
|
117
|
+
"NUMBER_OF_PROCESSORS",
|
|
118
|
+
"HTTP_PROXY",
|
|
119
|
+
"HTTPS_PROXY",
|
|
120
|
+
"NO_PROXY",
|
|
121
|
+
]);
|
|
122
|
+
|
|
123
|
+
export function sanitizePm2Env(env) {
|
|
124
|
+
const out = {};
|
|
125
|
+
for (const [key, value] of Object.entries(env)) {
|
|
126
|
+
if (key === "PM2_HOME" || key.startsWith("ELLM_")) out[key] = value;
|
|
127
|
+
else if (PM2_ENV_WHITELIST.has(key.toUpperCase())) out[key] = value;
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|