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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +60 -0
  3. package/bin/commands/autostart.js +213 -0
  4. package/bin/commands/config.js +8 -0
  5. package/bin/commands/pm2.js +28 -0
  6. package/bin/commands/service.js +107 -0
  7. package/bin/ellm-proxy.js +61 -0
  8. package/dist/package.json +1 -0
  9. package/dist/server.js +12176 -0
  10. package/dist/web-ui/assets/DashboardView-CQdlgirS.js +1 -0
  11. package/dist/web-ui/assets/DashboardView-qrQrkgLA.css +1 -0
  12. package/dist/web-ui/assets/LayoutView-BaY4Pz9W.js +1 -0
  13. package/dist/web-ui/assets/LoginView-CPvz1Ylx.js +1 -0
  14. package/dist/web-ui/assets/LoginView-Dg0mB6ok.css +1 -0
  15. package/dist/web-ui/assets/LogsView-B-bIj36j.js +9 -0
  16. package/dist/web-ui/assets/LogsView-Wl3_adHm.css +1 -0
  17. package/dist/web-ui/assets/ProcessView-0x_ShSwV.js +1 -0
  18. package/dist/web-ui/assets/ProcessView-DzMqBJmd.css +1 -0
  19. package/dist/web-ui/assets/ProvidersView-BxAxDRFH.css +1 -0
  20. package/dist/web-ui/assets/ProvidersView-GQENcvJS.js +31 -0
  21. package/dist/web-ui/assets/SettingsView-BDkymsrS.css +1 -0
  22. package/dist/web-ui/assets/SettingsView-BrAiaskZ.js +1 -0
  23. package/dist/web-ui/assets/config-DNn5cjYY.js +1 -0
  24. package/dist/web-ui/assets/index-BecfPmag.css +1 -0
  25. package/dist/web-ui/assets/index-DFlUx18F.js +96 -0
  26. package/dist/web-ui/assets/providers-CGEmOxne.js +1 -0
  27. package/dist/web-ui/assets/useApi-BRPinb_2.js +1 -0
  28. package/dist/web-ui/assets/useManagerApi-BDWJz5o6.js +1 -0
  29. package/dist/web-ui/assets/useToast-D7xlwSg6.js +1 -0
  30. package/dist/web-ui/config.js +10 -0
  31. package/dist/web-ui/index.html +15 -0
  32. package/docs/DESIGN.md +99 -0
  33. package/ellm.config.example.json +33 -0
  34. package/manager/api/harness/backup.js +20 -0
  35. package/manager/api/harness/claude-code.js +54 -0
  36. package/manager/api/harness/codex.js +123 -0
  37. package/manager/api/harness/dsh.js +175 -0
  38. package/manager/api/harness/index.js +118 -0
  39. package/manager/api/service.js +157 -0
  40. package/manager/index.js +170 -0
  41. package/package.json +40 -0
  42. package/utils/interpreter.js +14 -0
  43. package/utils/paths.js +44 -0
  44. package/utils/pm2.js +130 -0
  45. package/utils/port.js +35 -0
  46. package/utils/service.js +189 -0
  47. package/utils/ui.js +59 -0
package/utils/port.js ADDED
@@ -0,0 +1,35 @@
1
+ // 端口可用性探测:试绑 listen(全部成功=空闲,任一 EADDRINUSE 等错误=占用),供 service.js 启动前预算端口。
2
+ // Windows 的 wildcard/specific 共存语义(bind 0.0.0.0 与 bind 127.0.0.1 可同端口并存,实测)会让单维度
3
+ // 探测漏报"bind 成功但 IPv4 流量被占用者抢走"的假活,故主服务探测 0.0.0.0 + 127.0.0.1 双维度;
4
+ // IPv6-only 占用极罕见(漏报由 pm2 探活失败兜底)。管理服务固定监听 127.0.0.1 → 探测 ["127.0.0.1"]。
5
+ // 探测与实际 bind 之间存在毫秒级竞态窗口(TOCTOU),极端时被抢占 → 仍由 pm2 探活失败兜底。
6
+ import { createServer } from "node:net";
7
+
8
+ /** 单 host 试绑:成功=空闲(等 close 回调 socket 释放后才返回 true,避免误占探测端口) */
9
+ function isHostFree(port, host) {
10
+ return new Promise((resolve) => {
11
+ const srv = createServer();
12
+ srv.once("error", () => resolve(false)); // EADDRINUSE / EACCES 等一律视为不可用
13
+ srv.once("listening", () => srv.close(() => resolve(true)));
14
+ srv.listen(port, host);
15
+ });
16
+ }
17
+
18
+ /** 端口是否空闲:逐个 host 试绑,全部成功才空闲(hosts 传数组多维探测,传字符串退化为单 host) */
19
+ export function isPortFree(port, hosts = ["0.0.0.0", "127.0.0.1"]) {
20
+ const list = Array.isArray(hosts) ? hosts : [hosts];
21
+ return (async () => {
22
+ for (const host of list) {
23
+ if (!(await isHostFree(port, host))) return false;
24
+ }
25
+ return true;
26
+ })();
27
+ }
28
+
29
+ /** 从 start 起向上找第一个空闲端口(含 start;maxTry 个内全占返回 0,由调用方决定兜底行为) */
30
+ export async function findFreePort(start, hosts = ["0.0.0.0", "127.0.0.1"], maxTry = 100) {
31
+ for (let p = start; p < start + maxTry; p++) {
32
+ if (await isPortFree(p, hosts)) return p;
33
+ }
34
+ return 0;
35
+ }
@@ -0,0 +1,189 @@
1
+ // 服务进程管理核心:start/stop(restart = stop + start 由调用方组合,保证最新参数生效)。
2
+ // 被 CLI(bin/commands/service.js)与管理服务(manager/index.js)复用。
3
+ // 本模块只做进程编排并返回数据,人读文案/摘要由调用方输出;错误以 throw 抛出(文案是契约的一部分)。
4
+ // start 流程:配置自愈 → env 注入(含端口预算:主服务 ELLM_PORT / 管理服务 ELLM_MANAGER_PORT = 主端口+1,
5
+ // 占用时自动顺延空闲端口)→ 两个服务一并提交 pm2(数组形式)→ 并行轮询探活 → dump;
6
+ // 管理服务已在线(部分恢复场景)则只补启主服务。
7
+ // 全程分步输出进度(「→ 启动 ...」+ 探活进度点),等待期间不留静默空窗。
8
+ import { readFileSync, mkdirSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { APP_NAME, MANAGER_APP_NAME, pkgRoot, defaultAppDir, ensureConfig, configPath } from "./paths.js";
11
+ import { withPm2, pm2cb, findApp, waitOnline, safeDump, sanitizePm2Env } from "./pm2.js";
12
+ import { resolveInterpreter } from "./interpreter.js";
13
+ import { isPortFree, findFreePort } from "./port.js";
14
+ import { ICONS, say, warn, begin, endOk, endFail } from "./ui.js";
15
+
16
+ /** 管理服务脚本(随包分发,start 时随主服务一并 pm2 托管) */
17
+ const MANAGER_INDEX_JS = join(pkgRoot, "manager", "index.js");
18
+ const DEFAULT_MANAGER_PORT = 29771; // 主端口无法解析时 manager 的最终兜底(默认网关 29770 + 1,与 manager/index.js 缺省一致)
19
+
20
+ /** 端口解析口径:portEnv(ELLM_PORT env)> 配置文件 port 字段 */
21
+ export function resolvePort(portEnv, configFile) {
22
+ let port = Number(portEnv) || 0;
23
+ if (!port) {
24
+ try {
25
+ port = JSON.parse(readFileSync(configFile, "utf-8")).port || 0;
26
+ } catch {
27
+ port = 0;
28
+ }
29
+ }
30
+ return port;
31
+ }
32
+
33
+ /** 网关地址与配置文件定位(harness 代理指向目标):ELLM_PORT env / 配置文件 port,与 start 命令端口口径一致 */
34
+ export function resolveGatewayBase(configOverride) {
35
+ const configFile = configPath(configOverride);
36
+ const port = resolvePort(process.env.ELLM_PORT, configFile);
37
+ return { base: `http://127.0.0.1:${port}`, configFile };
38
+ }
39
+
40
+ /** 启动服务(CLI restart 与管理服务 restart 复用):
41
+ * 配置自愈 → env 注入 → 两服务一并提交 pm2(数组形式)→ 并行轮询探活 → dump,
42
+ * 管理服务已在线则只补启主服务。全程分步进度输出。返回 { proc, managerProc, appDir, config } 供调用方输出摘要。 */
43
+ export async function doStart(opts) {
44
+ say(`正在启动 ${APP_NAME}(pm2 后台守护)...`, ICONS.rocket);
45
+ const config = ensureConfig(opts.config);
46
+ const appDir = opts.workingDir || defaultAppDir;
47
+ mkdirSync(join(appDir, "logs"), { recursive: true });
48
+
49
+ // server 端读取的路径基变量:显式值优先(CLI 参数 > 会话已有 > 默认推导)
50
+ process.env.ELLM_APP_DIR = process.env.ELLM_APP_DIR ?? appDir;
51
+ process.env.ELLM_CONFIG = config;
52
+ if (opts.dataDir) process.env.ELLM_DATA_DIR = opts.dataDir;
53
+
54
+ // 端口基准预算:主服务 = --port > ELLM_PORT env > 配置文件 port 字段(与 server 端读取口径一致)。
55
+ // 占用探测与注入在 withPm2 内做(管理服务是否在线影响其端口预算,findApp 需要 pm2 连接)。
56
+ const port = resolvePort(opts.port ?? process.env.ELLM_PORT, config);
57
+
58
+ const interpreter = await resolveInterpreter();
59
+ const script = opts.serverJs || join(pkgRoot, "dist", "server.js");
60
+ // ELLM_SERVER_JS:管理服务 restart 主服务时按同一路径重启,保证 --server-js 等参数不漂移
61
+ process.env.ELLM_SERVER_JS = script;
62
+
63
+ return withPm2(async () => {
64
+ if (await findApp()) {
65
+ throw new Error(`${APP_NAME} 已在运行。请先停止: ellm-proxy stop`);
66
+ }
67
+
68
+ // 主服务端口:基准被占(其他应用 / 孤儿进程)则自动顺延到空闲端口(默认 0.0.0.0 + 127.0.0.1 双维度,
69
+ // 见 utils/port.js),避免把注定冲突的端口交给 pm2 后陷入崩溃循环;顺延仅本次运行生效,不改配置文件。
70
+ let mainPort = port;
71
+ if (mainPort && !(await isPortFree(mainPort))) {
72
+ const wanted = mainPort;
73
+ mainPort = (await findFreePort(mainPort + 1)) || mainPort;
74
+ warn(
75
+ mainPort === wanted
76
+ ? `端口 ${wanted} 已被占用且未找到空闲端口,仍按原端口启动(可能失败,请释放端口或修改配置)`
77
+ : `端口 ${wanted} 已被占用,主服务自动改用空闲端口 ${mainPort}(仅本次运行生效,不改配置文件)`,
78
+ );
79
+ }
80
+ if (mainPort) process.env.ELLM_PORT = String(mainPort);
81
+
82
+ // 管理服务随主服务一并托管(活在外面:主服务被 stop/重启后由它执行拉起;resurrect 时一并恢复)。
83
+ // 已在线则跳过启动(部分恢复场景幂等),并保持其现有端口(重新预算换端口会造成访问地址漂移)。
84
+ const existingManager = await findApp(MANAGER_APP_NAME);
85
+ let managerPort = 0;
86
+ if (existingManager) {
87
+ const runningPort = Number(existingManager.pm2_env?.env?.ELLM_MANAGER_PORT);
88
+ if (runningPort) process.env.ELLM_MANAGER_PORT = String(runningPort);
89
+ managerPort = Number(process.env.ELLM_MANAGER_PORT) || 0;
90
+ say(`管理服务 ${MANAGER_APP_NAME} 已在线(端口 ${managerPort || "?"}),跳过启动`, ICONS.skip);
91
+ } else {
92
+ // 管理服务端口 = 主服务端口 + 1(用户显式设置过 ELLM_MANAGER_PORT 则尊重);
93
+ // 被占则自动顺延(固定监听 127.0.0.1,单维度探测)。探测注入必须在 pm2 start 前,env 随之透传。
94
+ managerPort = Number(process.env.ELLM_MANAGER_PORT) || (mainPort ? mainPort + 1 : 0);
95
+ if (managerPort && !(await isPortFree(managerPort, ["127.0.0.1"]))) {
96
+ const wanted = managerPort;
97
+ managerPort = (await findFreePort(managerPort + 1, ["127.0.0.1"])) || managerPort;
98
+ warn(
99
+ managerPort === wanted
100
+ ? `管理服务端口 ${wanted} 已被占用且未找到空闲端口,仍按原端口启动(可能失败,可用 ELLM_MANAGER_PORT 调整)`
101
+ : `管理服务端口 ${wanted} 已被占用,自动改用空闲端口 ${managerPort}(仅本次运行生效)`,
102
+ );
103
+ }
104
+ if (managerPort) process.env.ELLM_MANAGER_PORT = String(managerPort);
105
+ }
106
+
107
+ // 两份 pm2 start 配置(单启主服务 / 双启一并提交两条路径共用);env 在端口注入后快照
108
+ const mainOpts = {
109
+ name: APP_NAME,
110
+ script,
111
+ interpreter,
112
+ cwd: appDir,
113
+ // 崩溃自动重启但封顶,防端口占用/配置损坏陷入无限重启
114
+ autorestart: true,
115
+ max_restarts: 10,
116
+ restart_delay: 3000,
117
+ merge_logs: true,
118
+ out_file: join(appDir, "logs", "ellm-out.log"),
119
+ error_file: join(appDir, "logs", "ellm-err.log"),
120
+ env: sanitizePm2Env(process.env),
121
+ };
122
+ const managerOpts = {
123
+ name: MANAGER_APP_NAME,
124
+ script: MANAGER_INDEX_JS,
125
+ interpreter,
126
+ cwd: appDir,
127
+ autorestart: true,
128
+ max_restarts: 10,
129
+ restart_delay: 3000,
130
+ merge_logs: true,
131
+ out_file: join(appDir, "logs", "manager-out.log"),
132
+ error_file: join(appDir, "logs", "manager-err.log"),
133
+ env: sanitizePm2Env(process.env),
134
+ };
135
+
136
+ if (existingManager) {
137
+ // 仅补启主服务(单服务,保持打点进度样式)
138
+ begin(`启动主服务 ${APP_NAME}${mainPort ? ` (端口 ${mainPort})` : ""} `);
139
+ await pm2cb("start", mainOpts);
140
+ const proc = await waitOnline(
141
+ APP_NAME,
142
+ `最可能是端口被占用或配置无效,请查看日志: ${join(appDir, "logs", "ellm-err.log")}`,
143
+ );
144
+ await safeDump();
145
+ return { proc, managerProc: existingManager, appDir, config };
146
+ }
147
+
148
+ // 双启:两个服务一并提交 pm2(数组形式,daemon 逐个拉起),等待期并行轮询、输出合并为一行。
149
+ // 主服务失败时管理服务往往已在线——其职责正是远程拉起主服务,故失败后不回收进程,文案给出重试路径。
150
+ begin(
151
+ `启动 ${APP_NAME}${mainPort ? ` (端口 ${mainPort})` : ""} + ${MANAGER_APP_NAME}${managerPort ? ` (端口 ${managerPort})` : ""} ...`,
152
+ );
153
+ await pm2cb("start", [mainOpts, managerOpts]);
154
+ const [mainRes, managerRes] = await Promise.allSettled([
155
+ waitOnline(APP_NAME, `最可能是端口被占用或配置无效,请查看日志: ${join(appDir, "logs", "ellm-err.log")}`, {
156
+ quiet: true,
157
+ }),
158
+ waitOnline(
159
+ MANAGER_APP_NAME,
160
+ `最可能是管理服务端口被占用(端口 ${process.env.ELLM_MANAGER_PORT || DEFAULT_MANAGER_PORT},可用 ELLM_MANAGER_PORT 调整),请查看日志: ${join(appDir, "logs", "manager-err.log")}`,
161
+ { quiet: true },
162
+ ),
163
+ ]);
164
+ const failures = [mainRes, managerRes].filter((r) => r.status === "rejected");
165
+ if (failures.length) {
166
+ endFail();
167
+ throw new Error(failures.map((f) => f.reason?.message ?? String(f.reason)).join(";"));
168
+ }
169
+ endOk();
170
+
171
+ // dump 进程列表(~/.pm2/dump.pm2),供开机自启 pm2 resurrect 恢复(主服务 + 管理服务一并恢复)
172
+ await safeDump();
173
+ return { proc: mainRes.value, managerProc: managerRes.value, appDir, config };
174
+ });
175
+ }
176
+
177
+ /** 停止服务:主服务与管理服务一并删除(幂等)。返回 { main, manager } 表示各自此前是否在运行。 */
178
+ export async function doStop() {
179
+ return withPm2(async () => {
180
+ const proc = await findApp();
181
+ const managerProc = await findApp(MANAGER_APP_NAME);
182
+ if (!proc && !managerProc) return { main: false, manager: false };
183
+ // 先删管理服务(避免它在主服务停止后仍对外提供启停接口造成状态误判),再删主服务
184
+ if (managerProc) await pm2cb("delete", MANAGER_APP_NAME);
185
+ if (proc) await pm2cb("delete", APP_NAME);
186
+ await safeDump();
187
+ return { main: !!proc, manager: !!managerProc };
188
+ });
189
+ }
package/utils/ui.js ADDED
@@ -0,0 +1,59 @@
1
+ // 统一输出工具:emoji 图标 + 进度点 + 摘要字段行,CLI(bin/)与管理服务(manager/)共用。
2
+ // 图标约定:一律选无 VS16(变体选择符)的码点——🖥️/🛠️ 等带 VS16 的在不同终端宽度不稳,实测弃用。
3
+ // 摘要字段行按「两空格缩进 + 图标 + 四字标签 + 三空格 + 内容」对齐。
4
+ export const ICONS = {
5
+ rocket: "🚀", // 开始
6
+ pending: "⏳", // 进行中(探活等待等)
7
+ success: "✅", // 成功完成
8
+ warn: "❗", // 警告(端口顺延等降级提示)
9
+ skip: "⏭️", // 已在线跳过
10
+ running: "🟢", // 服务运行中
11
+ stopped: "🛑", // 已停止
12
+ idle: "💤", // 未在运行
13
+ error: "❌", // 失败
14
+ gateway: "🌐", // 网关端点
15
+ webui: "💻", // 管理界面
16
+ api: "🔧", // 管理服务接口
17
+ folder: "📂", // 应用目录
18
+ status: "📋", // 运行状态
19
+ question: "❓", // 未知(端口未知等兜底展示)
20
+ };
21
+
22
+ /** 一行信息(icon 省略 = 纯文本行) */
23
+ export function say(text, icon) {
24
+ console.log(icon ? `${icon} ${text}` : text);
25
+ }
26
+
27
+ /** 警告行 */
28
+ export function warn(text) {
29
+ say(text, ICONS.warn);
30
+ }
31
+
32
+ /** 错误行(stderr,供 CLI 统一错误出口) */
33
+ export function error(text) {
34
+ console.error(`${ICONS.error} ${text}`);
35
+ }
36
+
37
+ /** 行内片段(不换行):⏳ 前缀 + 文本,供后续追加进度点 / 行尾状态;text 需自带尾部空格或省略号 */
38
+ export function begin(text) {
39
+ process.stdout.write(`${ICONS.pending} ${text}`);
40
+ }
41
+
42
+ /** 进度点(探活轮询期间追加) */
43
+ export function dot() {
44
+ process.stdout.write(".");
45
+ }
46
+
47
+ /** 行尾成功 / 失败标记并换行(与 begin/dot 配套) */
48
+ export function endOk() {
49
+ process.stdout.write(" ✓\n");
50
+ }
51
+
52
+ export function endFail() {
53
+ process.stdout.write(" ✗\n");
54
+ }
55
+
56
+ /** 摘要字段行:缩进 + 图标 + 标签 + 内容 */
57
+ export function field(icon, label, value) {
58
+ console.log(` ${icon} ${label} ${value}`);
59
+ }