codex-slot 0.1.25 → 0.1.27
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/dist/app/service-lifecycle-service.js +422 -18
- package/dist/proxy-retry-service.js +160 -109
- package/dist/serve.js +54 -0
- package/dist/server.js +69 -20
- package/dist/service-control.js +12 -0
- package/dist/upstream-client.js +33 -3
- package/package.json +1 -1
|
@@ -4,6 +4,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.getRunningPid = getRunningPid;
|
|
7
|
+
exports.buildLaunchAgentPlist = buildLaunchAgentPlist;
|
|
8
|
+
exports.resolveServiceManagerKind = resolveServiceManagerKind;
|
|
9
|
+
exports.buildSystemdUserUnit = buildSystemdUserUnit;
|
|
7
10
|
exports.startManagedService = startManagedService;
|
|
8
11
|
exports.stopManagedService = stopManagedService;
|
|
9
12
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
@@ -20,6 +23,7 @@ const cli_helpers_1 = require("../cli-helpers");
|
|
|
20
23
|
const config_1 = require("../config");
|
|
21
24
|
const STARTUP_POLL_INTERVAL_MS = 100;
|
|
22
25
|
const STARTUP_TIMEOUT_MS = 5000;
|
|
26
|
+
const LAUNCH_AGENT_LABEL_PREFIX = "com.openxiaobu.cslot";
|
|
23
27
|
/**
|
|
24
28
|
* 休眠指定毫秒数,供启动轮询流程复用。
|
|
25
29
|
*
|
|
@@ -75,6 +79,361 @@ function resolveServeEntrypoint() {
|
|
|
75
79
|
args: [`${serveBasePath}.js`]
|
|
76
80
|
};
|
|
77
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* 基于当前 HOME 目录生成稳定的 launchd label,避免测试隔离 HOME 与真实 HOME 互相冲突。
|
|
84
|
+
*
|
|
85
|
+
* @returns 当前 HOME 对应的 launchd label。
|
|
86
|
+
* @throws 无显式抛出。
|
|
87
|
+
*/
|
|
88
|
+
function getLaunchAgentLabel() {
|
|
89
|
+
const home = (0, config_1.getUserHomeDir)();
|
|
90
|
+
let hash = 2166136261;
|
|
91
|
+
for (const character of home) {
|
|
92
|
+
hash ^= character.charCodeAt(0);
|
|
93
|
+
hash = Math.imul(hash, 16777619);
|
|
94
|
+
}
|
|
95
|
+
return `${LAUNCH_AGENT_LABEL_PREFIX}.${(hash >>> 0).toString(16)}`;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* 返回当前 HOME 对应的 LaunchAgents plist 路径,并确保父目录存在。
|
|
99
|
+
*
|
|
100
|
+
* @returns launchd plist 绝对路径。
|
|
101
|
+
* @throws 当目录创建失败时抛出文件系统错误。
|
|
102
|
+
*/
|
|
103
|
+
function getLaunchAgentPlistPath() {
|
|
104
|
+
const launchAgentsDir = node_path_1.default.join((0, config_1.getUserHomeDir)(), "Library", "LaunchAgents");
|
|
105
|
+
node_fs_1.default.mkdirSync(launchAgentsDir, { recursive: true });
|
|
106
|
+
return node_path_1.default.join(launchAgentsDir, `${getLaunchAgentLabel()}.plist`);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* 对 XML 文本做最小转义,避免命令参数与日志路径写入 plist 时破坏结构。
|
|
110
|
+
*
|
|
111
|
+
* @param value 待写入 plist 的原始文本。
|
|
112
|
+
* @returns 完成 XML 转义后的文本。
|
|
113
|
+
* @throws 无显式抛出。
|
|
114
|
+
*/
|
|
115
|
+
function escapeXml(value) {
|
|
116
|
+
return value
|
|
117
|
+
.replaceAll("&", "&")
|
|
118
|
+
.replaceAll("<", "<")
|
|
119
|
+
.replaceAll(">", ">")
|
|
120
|
+
.replaceAll("\"", """)
|
|
121
|
+
.replaceAll("'", "'");
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* 生成 launchd plist 内容,使 cslot 服务具备开机自启与异常退出自动拉起能力。
|
|
125
|
+
*
|
|
126
|
+
* @param command 启动服务使用的绝对命令路径。
|
|
127
|
+
* @param args 命令参数列表。
|
|
128
|
+
* @param logPath 标准输出与错误输出写入的日志路径。
|
|
129
|
+
* @returns 可直接写入磁盘的 plist 文本。
|
|
130
|
+
* @throws 无显式抛出。
|
|
131
|
+
*/
|
|
132
|
+
function buildLaunchAgentPlist(command, args, logPath) {
|
|
133
|
+
const programArguments = [command, ...args].map((item) => ` <string>${escapeXml(item)}</string>`).join("\n");
|
|
134
|
+
const home = escapeXml((0, config_1.getUserHomeDir)());
|
|
135
|
+
const label = escapeXml(getLaunchAgentLabel());
|
|
136
|
+
const escapedLogPath = escapeXml(logPath);
|
|
137
|
+
return [
|
|
138
|
+
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
|
|
139
|
+
"<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">",
|
|
140
|
+
"<plist version=\"1.0\">",
|
|
141
|
+
"<dict>",
|
|
142
|
+
" <key>Label</key>",
|
|
143
|
+
` <string>${label}</string>`,
|
|
144
|
+
" <key>ProgramArguments</key>",
|
|
145
|
+
" <array>",
|
|
146
|
+
programArguments,
|
|
147
|
+
" </array>",
|
|
148
|
+
" <key>RunAtLoad</key>",
|
|
149
|
+
" <true/>",
|
|
150
|
+
" <key>KeepAlive</key>",
|
|
151
|
+
" <true/>",
|
|
152
|
+
" <key>EnvironmentVariables</key>",
|
|
153
|
+
" <dict>",
|
|
154
|
+
" <key>HOME</key>",
|
|
155
|
+
` <string>${home}</string>`,
|
|
156
|
+
" </dict>",
|
|
157
|
+
" <key>StandardOutPath</key>",
|
|
158
|
+
` <string>${escapedLogPath}</string>`,
|
|
159
|
+
" <key>StandardErrorPath</key>",
|
|
160
|
+
` <string>${escapedLogPath}</string>`,
|
|
161
|
+
"</dict>",
|
|
162
|
+
"</plist>",
|
|
163
|
+
""
|
|
164
|
+
].join("\n");
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* 判断当前环境是否应启用 launchd 托管。
|
|
168
|
+
*
|
|
169
|
+
* @returns macOS 且未显式禁用 launchd 时返回 `true`,否则返回 `false`。
|
|
170
|
+
* @throws 无显式抛出。
|
|
171
|
+
*/
|
|
172
|
+
function shouldUseLaunchd() {
|
|
173
|
+
return process.platform === "darwin" && process.env.CSLOT_DISABLE_LAUNCHD !== "1";
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* 判断当前 Linux 环境是否应使用 systemd user service 托管。
|
|
177
|
+
*
|
|
178
|
+
* @returns Linux 且未显式禁用 systemd 托管时返回 `true`,否则返回 `false`。
|
|
179
|
+
* @throws 无显式抛出。
|
|
180
|
+
*/
|
|
181
|
+
function shouldUseSystemdUser() {
|
|
182
|
+
return process.platform === "linux" && process.env.CSLOT_DISABLE_SYSTEMD !== "1";
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* 解析当前平台应使用的后台服务托管方式。
|
|
186
|
+
*
|
|
187
|
+
* @returns 当前平台对应的服务管理器类型。
|
|
188
|
+
* @throws 当 Linux 期望使用 systemd 但本机缺少 `systemctl` 时抛出异常。
|
|
189
|
+
*/
|
|
190
|
+
function resolveServiceManagerKind() {
|
|
191
|
+
if (shouldUseLaunchd()) {
|
|
192
|
+
return "launchd";
|
|
193
|
+
}
|
|
194
|
+
if (shouldUseSystemdUser()) {
|
|
195
|
+
try {
|
|
196
|
+
(0, node_child_process_1.execFileSync)("systemctl", ["--user", "--version"], {
|
|
197
|
+
stdio: ["ignore", "ignore", "ignore"]
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
throw new Error("当前 Linux 环境缺少 systemd --user,无法提供自动拉起与开机自启。请安装 systemd user service,或显式设置 CSLOT_DISABLE_SYSTEMD=1 回退到 detached 模式。");
|
|
202
|
+
}
|
|
203
|
+
return "systemd-user";
|
|
204
|
+
}
|
|
205
|
+
return "detached";
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* 返回当前用户的 launchd domain,供 `launchctl bootstrap/bootout` 复用。
|
|
209
|
+
*
|
|
210
|
+
* @returns 类似 `gui/501` 的 domain 字符串。
|
|
211
|
+
* @throws 当当前进程无法获取 uid 时抛出异常。
|
|
212
|
+
*/
|
|
213
|
+
function getLaunchctlDomain() {
|
|
214
|
+
if (typeof process.getuid !== "function") {
|
|
215
|
+
throw new Error("当前平台不支持 launchctl user domain");
|
|
216
|
+
}
|
|
217
|
+
return `gui/${process.getuid()}`;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* 返回 systemd user service 的 unit 名称,按 HOME 做稳定区分,避免测试与真实环境互串。
|
|
221
|
+
*
|
|
222
|
+
* @returns 当前 HOME 对应的 systemd unit 文件名。
|
|
223
|
+
* @throws 无显式抛出。
|
|
224
|
+
*/
|
|
225
|
+
function getSystemdUserUnitName() {
|
|
226
|
+
return `${getLaunchAgentLabel().replaceAll(".", "-")}.service`;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* 返回 systemd user service unit 文件路径,并确保目录存在。
|
|
230
|
+
*
|
|
231
|
+
* @returns systemd user unit 绝对路径。
|
|
232
|
+
* @throws 当目录创建失败时抛出文件系统错误。
|
|
233
|
+
*/
|
|
234
|
+
function getSystemdUserUnitPath() {
|
|
235
|
+
const unitDir = node_path_1.default.join((0, config_1.getUserHomeDir)(), ".config", "systemd", "user");
|
|
236
|
+
node_fs_1.default.mkdirSync(unitDir, { recursive: true });
|
|
237
|
+
return node_path_1.default.join(unitDir, getSystemdUserUnitName());
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* 对 systemd Environment 值做最小转义,避免空格或双引号破坏 unit 文件语义。
|
|
241
|
+
*
|
|
242
|
+
* @param value Environment 原始值。
|
|
243
|
+
* @returns 已转义的值文本。
|
|
244
|
+
* @throws 无显式抛出。
|
|
245
|
+
*/
|
|
246
|
+
function escapeSystemdEnvironmentValue(value) {
|
|
247
|
+
return value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"");
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* 对 systemd ExecStart 参数做 shell 风格转义,确保路径和参数可被 systemd 正确拆分。
|
|
251
|
+
*
|
|
252
|
+
* @param value ExecStart 单个参数文本。
|
|
253
|
+
* @returns 安全可写入 unit 的参数文本。
|
|
254
|
+
* @throws 无显式抛出。
|
|
255
|
+
*/
|
|
256
|
+
function quoteSystemdExecArgument(value) {
|
|
257
|
+
return `"${value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"")}"`;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* 生成 systemd --user service unit,使 cslot 在 Linux 下具备用户级开机自启与异常退出自动重启能力。
|
|
261
|
+
*
|
|
262
|
+
* @param command 启动服务使用的绝对命令路径。
|
|
263
|
+
* @param args 命令参数列表。
|
|
264
|
+
* @param logPath 服务日志路径。
|
|
265
|
+
* @returns 可直接写入 systemd user unit 的文本。
|
|
266
|
+
* @throws 无显式抛出。
|
|
267
|
+
*/
|
|
268
|
+
function buildSystemdUserUnit(command, args, logPath) {
|
|
269
|
+
const execStart = [command, ...args].map(quoteSystemdExecArgument).join(" ");
|
|
270
|
+
const home = escapeSystemdEnvironmentValue((0, config_1.getUserHomeDir)());
|
|
271
|
+
const safeLogPath = logPath.replaceAll("\\", "\\\\").replaceAll("%", "%%");
|
|
272
|
+
return [
|
|
273
|
+
"[Unit]",
|
|
274
|
+
"Description=cslot managed local proxy",
|
|
275
|
+
"After=network-online.target",
|
|
276
|
+
"Wants=network-online.target",
|
|
277
|
+
"",
|
|
278
|
+
"[Service]",
|
|
279
|
+
"Type=simple",
|
|
280
|
+
`Environment=HOME="${home}"`,
|
|
281
|
+
`ExecStart=${execStart}`,
|
|
282
|
+
"Restart=always",
|
|
283
|
+
"RestartSec=1",
|
|
284
|
+
`StandardOutput=append:${safeLogPath}`,
|
|
285
|
+
`StandardError=append:${safeLogPath}`,
|
|
286
|
+
"",
|
|
287
|
+
"[Install]",
|
|
288
|
+
"WantedBy=default.target",
|
|
289
|
+
""
|
|
290
|
+
].join("\n");
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* 执行一次 systemctl --user 命令,并在允许的退出码范围内按幂等成功处理。
|
|
294
|
+
*
|
|
295
|
+
* @param args systemctl 参数列表。
|
|
296
|
+
* @param allowedStatuses 允许按成功处理的退出码集合。
|
|
297
|
+
* @returns 标准输出文本。
|
|
298
|
+
* @throws 当命令失败且退出码不在允许列表中时抛出异常。
|
|
299
|
+
*/
|
|
300
|
+
function runSystemctlUser(args, allowedStatuses = []) {
|
|
301
|
+
try {
|
|
302
|
+
return (0, node_child_process_1.execFileSync)("systemctl", ["--user", ...args], {
|
|
303
|
+
encoding: "utf8",
|
|
304
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
const status = typeof error === "object" && error && "status" in error ? Number(error.status) : null;
|
|
309
|
+
if (status !== null && allowedStatuses.includes(status)) {
|
|
310
|
+
return "";
|
|
311
|
+
}
|
|
312
|
+
const stderr = typeof error === "object" && error && "stderr" in error && typeof error.stderr === "string"
|
|
313
|
+
? error.stderr.trim()
|
|
314
|
+
: "";
|
|
315
|
+
const message = stderr || (error instanceof Error ? error.message : String(error));
|
|
316
|
+
throw new Error(`systemctl --user ${args.join(" ")} 失败: ${message}`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* 启动或重载 systemd --user 托管的 cslot 服务。
|
|
321
|
+
*
|
|
322
|
+
* @param port 最终启动端口。
|
|
323
|
+
* @param logPath 服务日志路径。
|
|
324
|
+
* @returns 当前 systemd 托管实例的 PID。
|
|
325
|
+
* @throws 当 unit 写入、systemctl 调用或服务就绪检查失败时抛出异常。
|
|
326
|
+
*/
|
|
327
|
+
async function startManagedServiceWithSystemdUser(port, logPath) {
|
|
328
|
+
const serveEntrypoint = resolveServeEntrypoint();
|
|
329
|
+
const unitPath = getSystemdUserUnitPath();
|
|
330
|
+
const unitName = getSystemdUserUnitName();
|
|
331
|
+
const unit = buildSystemdUserUnit(serveEntrypoint.command, [...serveEntrypoint.args, "--port", String(port)], logPath);
|
|
332
|
+
node_fs_1.default.writeFileSync(unitPath, unit, "utf8");
|
|
333
|
+
runSystemctlUser(["daemon-reload"]);
|
|
334
|
+
runSystemctlUser(["enable", "--now", unitName]);
|
|
335
|
+
return await waitForManagedServicePid();
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* 停止并卸载 systemd --user 托管的 cslot 服务,同时清理本地 unit 工件。
|
|
339
|
+
*
|
|
340
|
+
* @returns 停止前记录到的 PID;若当时没有可见运行 PID 则返回 `null`。
|
|
341
|
+
* @throws 当 systemctl 卸载或清理失败时抛出异常。
|
|
342
|
+
*/
|
|
343
|
+
function stopManagedServiceWithSystemdUser() {
|
|
344
|
+
const pid = getRunningPid();
|
|
345
|
+
const unitName = getSystemdUserUnitName();
|
|
346
|
+
const unitPath = getSystemdUserUnitPath();
|
|
347
|
+
runSystemctlUser(["disable", "--now", unitName], [1, 5]);
|
|
348
|
+
node_fs_1.default.rmSync(unitPath, { force: true });
|
|
349
|
+
runSystemctlUser(["daemon-reload"]);
|
|
350
|
+
runSystemctlUser(["reset-failed", unitName], [1, 5]);
|
|
351
|
+
node_fs_1.default.rmSync((0, config_1.getPidPath)(), { force: true });
|
|
352
|
+
return pid;
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* 执行一次 launchctl 命令,并在允许的失败码内按幂等处理。
|
|
356
|
+
*
|
|
357
|
+
* @param args launchctl 参数列表。
|
|
358
|
+
* @param allowedStatuses 允许视为成功的退出码集合。
|
|
359
|
+
* @returns 标准输出文本。
|
|
360
|
+
* @throws 当命令执行失败且退出码不在允许集合中时抛出异常。
|
|
361
|
+
*/
|
|
362
|
+
function runLaunchctl(args, allowedStatuses = []) {
|
|
363
|
+
try {
|
|
364
|
+
return (0, node_child_process_1.execFileSync)("launchctl", args, {
|
|
365
|
+
encoding: "utf8",
|
|
366
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
catch (error) {
|
|
370
|
+
const status = typeof error === "object" && error && "status" in error ? Number(error.status) : null;
|
|
371
|
+
if (status !== null && allowedStatuses.includes(status)) {
|
|
372
|
+
return "";
|
|
373
|
+
}
|
|
374
|
+
const stderr = typeof error === "object" && error && "stderr" in error && typeof error.stderr === "string"
|
|
375
|
+
? error.stderr.trim()
|
|
376
|
+
: "";
|
|
377
|
+
const message = stderr || (error instanceof Error ? error.message : String(error));
|
|
378
|
+
throw new Error(`launchctl ${args.join(" ")} 失败: ${message}`);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* 等待服务进程把自己的 PID 写入本地状态文件,兼容 launchd 拉起的非子进程模型。
|
|
383
|
+
*
|
|
384
|
+
* @param timeoutMs 等待超时时间,单位毫秒。
|
|
385
|
+
* @returns Promise,成功时返回当前运行中的 PID。
|
|
386
|
+
* @throws 当超时后仍未拿到有效 PID 时抛出异常。
|
|
387
|
+
*/
|
|
388
|
+
async function waitForManagedServicePid(timeoutMs = STARTUP_TIMEOUT_MS) {
|
|
389
|
+
const startedAt = Date.now();
|
|
390
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
391
|
+
const pid = getRunningPid();
|
|
392
|
+
if (pid) {
|
|
393
|
+
return pid;
|
|
394
|
+
}
|
|
395
|
+
await sleep(STARTUP_POLL_INTERVAL_MS);
|
|
396
|
+
}
|
|
397
|
+
throw new Error(`后台服务启动超时,未在 ${timeoutMs}ms 内写入有效 PID`);
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* 启动或重载 launchd 托管的 cslot 服务。
|
|
401
|
+
*
|
|
402
|
+
* @param port 最终启动端口。
|
|
403
|
+
* @param logPath 服务日志路径。
|
|
404
|
+
* @returns 当前 launchd 托管实例的 PID。
|
|
405
|
+
* @throws 当 plist 写入、launchctl 调用或服务就绪检查失败时抛出异常。
|
|
406
|
+
*/
|
|
407
|
+
async function startManagedServiceWithLaunchd(port, logPath) {
|
|
408
|
+
const serveEntrypoint = resolveServeEntrypoint();
|
|
409
|
+
const plistPath = getLaunchAgentPlistPath();
|
|
410
|
+
const domain = getLaunchctlDomain();
|
|
411
|
+
const label = getLaunchAgentLabel();
|
|
412
|
+
const plist = buildLaunchAgentPlist(serveEntrypoint.command, [...serveEntrypoint.args, "--port", String(port)], logPath);
|
|
413
|
+
node_fs_1.default.writeFileSync(plistPath, plist, "utf8");
|
|
414
|
+
// 先卸载旧 job,再用最新配置重载,避免端口或命令参数更新后仍复用旧定义。
|
|
415
|
+
runLaunchctl(["bootout", domain, plistPath], [3, 5, 36, 64, 113]);
|
|
416
|
+
runLaunchctl(["bootstrap", domain, plistPath]);
|
|
417
|
+
runLaunchctl(["enable", `${domain}/${label}`], [3, 5, 64, 113]);
|
|
418
|
+
runLaunchctl(["kickstart", "-k", `${domain}/${label}`]);
|
|
419
|
+
const pid = await waitForManagedServicePid();
|
|
420
|
+
return pid;
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* 停止并卸载 launchd 托管的 cslot 服务,同时清理本地 plist 工件。
|
|
424
|
+
*
|
|
425
|
+
* @returns 停止前记录到的 PID;若当时没有可见运行 PID 则返回 `null`。
|
|
426
|
+
* @throws 当 launchctl 卸载失败时抛出异常。
|
|
427
|
+
*/
|
|
428
|
+
function stopManagedServiceWithLaunchd() {
|
|
429
|
+
const pid = getRunningPid();
|
|
430
|
+
const plistPath = getLaunchAgentPlistPath();
|
|
431
|
+
const domain = getLaunchctlDomain();
|
|
432
|
+
runLaunchctl(["bootout", domain, plistPath], [3, 5, 36, 64, 113]);
|
|
433
|
+
node_fs_1.default.rmSync(plistPath, { force: true });
|
|
434
|
+
node_fs_1.default.rmSync((0, config_1.getPidPath)(), { force: true });
|
|
435
|
+
return pid;
|
|
436
|
+
}
|
|
78
437
|
/**
|
|
79
438
|
* 检查指定地址与端口当前是否可绑定,用于启动前规避端口冲突。
|
|
80
439
|
*
|
|
@@ -165,6 +524,18 @@ function rollbackFailedStart(pid, previousConfig) {
|
|
|
165
524
|
}
|
|
166
525
|
}
|
|
167
526
|
node_fs_1.default.rmSync((0, config_1.getPidPath)(), { force: true });
|
|
527
|
+
try {
|
|
528
|
+
const manager = resolveServiceManagerKind();
|
|
529
|
+
if (manager === "launchd") {
|
|
530
|
+
stopManagedServiceWithLaunchd();
|
|
531
|
+
}
|
|
532
|
+
else if (manager === "systemd-user") {
|
|
533
|
+
stopManagedServiceWithSystemdUser();
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
catch {
|
|
537
|
+
// 回滚阶段以尽力清理为主,不覆盖原始启动异常。
|
|
538
|
+
}
|
|
168
539
|
(0, config_1.saveConfig)(previousConfig);
|
|
169
540
|
(0, codex_config_1.deactivateManagedCodexConfig)();
|
|
170
541
|
(0, codex_auth_1.deactivateManagedCodexAuth)();
|
|
@@ -255,6 +626,7 @@ async function startManagedService(portOverride) {
|
|
|
255
626
|
const config = (0, config_1.loadConfig)();
|
|
256
627
|
const previousConfig = structuredClone(config);
|
|
257
628
|
const { port, autoSwitched } = await resolveStartPort(config.server.host, portOverride);
|
|
629
|
+
const manager = resolveServiceManagerKind();
|
|
258
630
|
const runningPid = getRunningPid();
|
|
259
631
|
if (runningPid) {
|
|
260
632
|
return {
|
|
@@ -262,7 +634,8 @@ async function startManagedService(portOverride) {
|
|
|
262
634
|
pid: runningPid,
|
|
263
635
|
port: config.server.port,
|
|
264
636
|
logPath: (0, config_1.getServiceLogPath)(),
|
|
265
|
-
autoSwitched: false
|
|
637
|
+
autoSwitched: false,
|
|
638
|
+
manager
|
|
266
639
|
};
|
|
267
640
|
}
|
|
268
641
|
if (config.server.port !== port) {
|
|
@@ -272,21 +645,40 @@ async function startManagedService(portOverride) {
|
|
|
272
645
|
(0, codex_config_1.applyManagedCodexConfig)(undefined, { config });
|
|
273
646
|
applyManagedAuthIfPossible();
|
|
274
647
|
const logPath = (0, config_1.getServiceLogPath)();
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
648
|
+
let childPid = null;
|
|
649
|
+
if (manager === "launchd") {
|
|
650
|
+
try {
|
|
651
|
+
childPid = await startManagedServiceWithLaunchd(port, logPath);
|
|
652
|
+
}
|
|
653
|
+
catch (error) {
|
|
654
|
+
rollbackFailedStart(null, previousConfig);
|
|
655
|
+
throw error;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
else if (manager === "systemd-user") {
|
|
659
|
+
try {
|
|
660
|
+
childPid = await startManagedServiceWithSystemdUser(port, logPath);
|
|
661
|
+
}
|
|
662
|
+
catch (error) {
|
|
663
|
+
rollbackFailedStart(null, previousConfig);
|
|
664
|
+
throw error;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
else {
|
|
668
|
+
const logFd = node_fs_1.default.openSync(logPath, "a");
|
|
669
|
+
const serveEntrypoint = resolveServeEntrypoint();
|
|
670
|
+
const child = (0, node_child_process_1.spawn)(serveEntrypoint.command, [...serveEntrypoint.args, "--port", String(port)], {
|
|
671
|
+
detached: true,
|
|
672
|
+
stdio: ["ignore", logFd, logFd]
|
|
673
|
+
});
|
|
674
|
+
childPid = child.pid ?? null;
|
|
675
|
+
child.unref();
|
|
284
676
|
node_fs_1.default.closeSync(logFd);
|
|
285
|
-
|
|
286
|
-
|
|
677
|
+
if (!childPid) {
|
|
678
|
+
rollbackFailedStart(null, previousConfig);
|
|
679
|
+
throw new Error("后台服务启动失败,未获取到有效子进程 PID");
|
|
680
|
+
}
|
|
287
681
|
}
|
|
288
|
-
node_fs_1.default.writeFileSync((0, config_1.getPidPath)(), `${childPid}\n`, "utf8");
|
|
289
|
-
node_fs_1.default.closeSync(logFd);
|
|
290
682
|
try {
|
|
291
683
|
await waitForManagedServiceReady(config.server.host, port, childPid);
|
|
292
684
|
}
|
|
@@ -299,7 +691,8 @@ async function startManagedService(portOverride) {
|
|
|
299
691
|
pid: childPid,
|
|
300
692
|
port,
|
|
301
693
|
logPath,
|
|
302
|
-
autoSwitched
|
|
694
|
+
autoSwitched,
|
|
695
|
+
manager
|
|
303
696
|
};
|
|
304
697
|
}
|
|
305
698
|
/**
|
|
@@ -309,14 +702,25 @@ async function startManagedService(portOverride) {
|
|
|
309
702
|
* @throws 当进程终止失败时透传底层异常。
|
|
310
703
|
*/
|
|
311
704
|
function stopManagedService() {
|
|
705
|
+
const manager = resolveServiceManagerKind();
|
|
706
|
+
const hasLaunchAgent = manager === "launchd" && node_fs_1.default.existsSync(getLaunchAgentPlistPath());
|
|
707
|
+
const hasSystemdUnit = manager === "systemd-user" && node_fs_1.default.existsSync(getSystemdUserUnitPath());
|
|
312
708
|
const pid = getRunningPid();
|
|
313
|
-
if (!pid) {
|
|
709
|
+
if (!pid && !hasLaunchAgent && !hasSystemdUnit) {
|
|
314
710
|
(0, codex_config_1.deactivateManagedCodexConfig)();
|
|
315
711
|
(0, codex_auth_1.deactivateManagedCodexAuth)();
|
|
316
712
|
return { stoppedPid: null };
|
|
317
713
|
}
|
|
318
|
-
|
|
319
|
-
|
|
714
|
+
if (hasLaunchAgent) {
|
|
715
|
+
stopManagedServiceWithLaunchd();
|
|
716
|
+
}
|
|
717
|
+
else if (hasSystemdUnit) {
|
|
718
|
+
stopManagedServiceWithSystemdUser();
|
|
719
|
+
}
|
|
720
|
+
else if (pid) {
|
|
721
|
+
process.kill(pid, "SIGTERM");
|
|
722
|
+
node_fs_1.default.rmSync((0, config_1.getPidPath)(), { force: true });
|
|
723
|
+
}
|
|
320
724
|
(0, codex_config_1.deactivateManagedCodexConfig)();
|
|
321
725
|
(0, codex_auth_1.deactivateManagedCodexAuth)();
|
|
322
726
|
return { stoppedPid: pid };
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var _a;
|
|
2
3
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.proxyResponsesWithRetry = void 0;
|
|
4
|
+
exports.proxyResponsesWithRetry = exports.proxyCodexWithRetry = void 0;
|
|
4
5
|
exports.createProxyRetryService = createProxyRetryService;
|
|
5
6
|
const account_store_1 = require("./account-store");
|
|
6
7
|
const config_1 = require("./config");
|
|
@@ -14,6 +15,7 @@ const usage_sync_1 = require("./usage-sync");
|
|
|
14
15
|
/**
|
|
15
16
|
* 为当前请求失败的账号设置临时熔断状态,避免短时间内被重复选中。
|
|
16
17
|
*
|
|
18
|
+
* @param dependencies 代理服务依赖集合。
|
|
17
19
|
* @param accountId 账号标识。
|
|
18
20
|
* @param reason 本地状态中记录的失败原因。
|
|
19
21
|
* @param blockSeconds 熔断持续秒数。
|
|
@@ -60,24 +62,61 @@ function buildSendResult(statusCode, payload, headers) {
|
|
|
60
62
|
};
|
|
61
63
|
}
|
|
62
64
|
/**
|
|
63
|
-
*
|
|
65
|
+
* 解析本地 Codex-compatible 代理请求,并转换成上游 codex path。
|
|
64
66
|
*
|
|
67
|
+
* 业务含义:
|
|
68
|
+
* 1. 对外暴露的 `/v1/*` 请求需要统一映射到上游 `codex_base_url` 的同名子路径,避免继续按接口逐个补洞。
|
|
69
|
+
* 2. 为兼容历史入口,也保留 `/backend-api/codex/*` 映射到同一上游 path 的能力。
|
|
70
|
+
*
|
|
71
|
+
* @param request 原始本地代理请求。
|
|
72
|
+
* @returns 可发往上游的 codex path;不属于代理范围时返回错误结果。
|
|
73
|
+
* @throws 当 URL 解析失败时返回错误结果,不向上游发请求。
|
|
74
|
+
*/
|
|
75
|
+
function resolveCodexPath(request) {
|
|
76
|
+
const parsedUrl = new URL(request.url, "http://127.0.0.1");
|
|
77
|
+
const openAiPrefix = "/v1";
|
|
78
|
+
const legacyBackendPrefix = "/backend-api/codex";
|
|
79
|
+
if (parsedUrl.pathname.startsWith(`${openAiPrefix}/`)) {
|
|
80
|
+
return {
|
|
81
|
+
pathWithQuery: `${parsedUrl.pathname.slice(openAiPrefix.length)}${parsedUrl.search}`
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (parsedUrl.pathname.startsWith(`${legacyBackendPrefix}/`)) {
|
|
85
|
+
return {
|
|
86
|
+
pathWithQuery: `${parsedUrl.pathname.slice(legacyBackendPrefix.length)}${parsedUrl.search}`
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
error: buildSendResult(404, {
|
|
91
|
+
error: {
|
|
92
|
+
message: (0, text_1.bi)("不支持的 Codex 代理路径", "Unsupported Codex proxy path"),
|
|
93
|
+
type: "unsupported_codex_proxy_path"
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* 对单个候选账号发送通用的 codex 上游请求。
|
|
100
|
+
*
|
|
101
|
+
* @param dependencies 代理服务依赖集合。
|
|
65
102
|
* @param picked 当前候选账号。
|
|
66
103
|
* @param accessToken 可用 access token。
|
|
67
|
-
* @param
|
|
68
|
-
* @param
|
|
104
|
+
* @param pathWithQuery 已解析的 codex path 与 query。
|
|
105
|
+
* @param request 原始本地代理请求。
|
|
69
106
|
* @returns 上游响应。
|
|
70
107
|
* @throws 当网络层或 undici 请求失败时透传底层异常。
|
|
71
108
|
*/
|
|
72
|
-
async function sendWithAccount(dependencies, picked, accessToken,
|
|
109
|
+
async function sendWithAccount(dependencies, picked, accessToken, pathWithQuery, request) {
|
|
73
110
|
const config = dependencies.loadConfig();
|
|
74
111
|
const auth = dependencies.readAuthFile(picked.account.codex_home);
|
|
75
|
-
return await dependencies.
|
|
112
|
+
return await dependencies.sendCodexRequest({
|
|
76
113
|
codexBaseUrl: config.upstream.codex_base_url,
|
|
77
|
-
|
|
114
|
+
method: request.method.toUpperCase(),
|
|
115
|
+
pathWithQuery,
|
|
116
|
+
requestHeaders: request.headers,
|
|
78
117
|
accessToken,
|
|
79
118
|
accountIdHeader: auth?.tokens?.account_id,
|
|
80
|
-
body:
|
|
119
|
+
body: request.body
|
|
81
120
|
});
|
|
82
121
|
}
|
|
83
122
|
/**
|
|
@@ -85,7 +124,7 @@ async function sendWithAccount(dependencies, picked, accessToken, requestHeaders
|
|
|
85
124
|
*
|
|
86
125
|
* 业务含义:
|
|
87
126
|
* 1. 默认依赖绑定真实配置、账号、状态和上游请求。
|
|
88
|
-
* 2.
|
|
127
|
+
* 2. `/v1/*` 与历史 `/backend-api/codex/*` 都复用同一套账号调度、401 刷新与异常兜底语义。
|
|
89
128
|
*
|
|
90
129
|
* @param overrides 可选依赖覆盖项。
|
|
91
130
|
* @returns 代理重试服务实例。
|
|
@@ -96,47 +135,71 @@ function createProxyRetryService(overrides) {
|
|
|
96
135
|
loadConfig: config_1.loadConfig,
|
|
97
136
|
listCandidateAccounts: scheduler_1.listCandidateAccounts,
|
|
98
137
|
readAuthFile: account_store_1.readAuthFile,
|
|
99
|
-
|
|
138
|
+
sendCodexRequest: upstream_client_1.sendCodexRequest,
|
|
100
139
|
refreshAccountTokens: usage_sync_1.refreshAccountTokens,
|
|
101
140
|
setAccountBlock: state_1.setAccountBlock,
|
|
102
141
|
recordAccountScheduleSuccess: state_repository_1.recordAccountScheduleSuccess,
|
|
103
142
|
...overrides
|
|
104
143
|
};
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
144
|
+
const proxyCodexWithRetry = async (request) => {
|
|
145
|
+
const route = resolveCodexPath(request);
|
|
146
|
+
if (route.error) {
|
|
147
|
+
return route.error;
|
|
148
|
+
}
|
|
149
|
+
const candidates = dependencies.listCandidateAccounts();
|
|
150
|
+
if (candidates.length === 0) {
|
|
151
|
+
return buildSendResult(503, {
|
|
152
|
+
error: {
|
|
153
|
+
message: (0, text_1.bi)("当前没有可用账号", "No available account"),
|
|
154
|
+
type: "no_available_account"
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
let lastErrorPayload = {
|
|
159
|
+
error: {
|
|
160
|
+
message: (0, text_1.bi)("所有账号都请求失败", "All accounts failed"),
|
|
161
|
+
type: "all_accounts_failed"
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
let lastStatusCode = 503;
|
|
165
|
+
for (const picked of candidates) {
|
|
166
|
+
const auth = dependencies.readAuthFile(picked.account.codex_home);
|
|
167
|
+
let accessToken = auth?.tokens?.access_token;
|
|
168
|
+
if (!accessToken) {
|
|
169
|
+
markAccountFailure(dependencies, picked.account.id, "invalid_account_auth", 10 * 60);
|
|
170
|
+
lastStatusCode = 503;
|
|
171
|
+
lastErrorPayload = {
|
|
110
172
|
error: {
|
|
111
|
-
message: (0, text_1.bi)(
|
|
112
|
-
type: "
|
|
173
|
+
message: (0, text_1.bi)(`账号 ${picked.account.id} 缺少 access_token`, `Account ${picked.account.id} is missing access_token`),
|
|
174
|
+
type: "invalid_account_auth"
|
|
113
175
|
}
|
|
114
|
-
}
|
|
176
|
+
};
|
|
177
|
+
continue;
|
|
115
178
|
}
|
|
116
|
-
let
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const auth = dependencies.readAuthFile(picked.account.codex_home);
|
|
125
|
-
let accessToken = auth?.tokens?.access_token;
|
|
126
|
-
if (!accessToken) {
|
|
127
|
-
markAccountFailure(dependencies, picked.account.id, "invalid_account_auth", 10 * 60);
|
|
128
|
-
lastStatusCode = 503;
|
|
129
|
-
lastErrorPayload = {
|
|
130
|
-
error: {
|
|
131
|
-
message: (0, text_1.bi)(`账号 ${picked.account.id} 缺少 access_token`, `Account ${picked.account.id} is missing access_token`),
|
|
132
|
-
type: "invalid_account_auth"
|
|
133
|
-
}
|
|
134
|
-
};
|
|
179
|
+
let upstream;
|
|
180
|
+
try {
|
|
181
|
+
upstream = await sendWithAccount(dependencies, picked, accessToken, route.pathWithQuery, request);
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
lastStatusCode = 503;
|
|
185
|
+
if ((0, upstream_error_policy_1.isNetworkUnavailableError)(error)) {
|
|
186
|
+
lastErrorPayload = (0, upstream_error_policy_1.buildNetworkUnavailablePayload)(picked.account.id, error);
|
|
135
187
|
continue;
|
|
136
188
|
}
|
|
137
|
-
|
|
189
|
+
markAccountFailure(dependencies, picked.account.id, "request_failed", 60);
|
|
190
|
+
lastErrorPayload = {
|
|
191
|
+
error: {
|
|
192
|
+
message: `账号 ${picked.account.id} 请求上游失败: ${error instanceof Error ? error.message : String(error)}`,
|
|
193
|
+
type: "account_request_failed"
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (upstream.statusCode === 401) {
|
|
138
199
|
try {
|
|
139
|
-
|
|
200
|
+
const refreshed = await dependencies.refreshAccountTokens(picked.account.id);
|
|
201
|
+
accessToken = refreshed.tokens?.access_token ?? accessToken;
|
|
202
|
+
upstream = await sendWithAccount(dependencies, picked, accessToken, route.pathWithQuery, request);
|
|
140
203
|
}
|
|
141
204
|
catch (error) {
|
|
142
205
|
lastStatusCode = 503;
|
|
@@ -144,94 +207,82 @@ function createProxyRetryService(overrides) {
|
|
|
144
207
|
lastErrorPayload = (0, upstream_error_policy_1.buildNetworkUnavailablePayload)(picked.account.id, error);
|
|
145
208
|
continue;
|
|
146
209
|
}
|
|
147
|
-
markAccountFailure(dependencies, picked.account.id, "
|
|
210
|
+
markAccountFailure(dependencies, picked.account.id, "token_refresh_failed", 10 * 60);
|
|
148
211
|
lastErrorPayload = {
|
|
149
212
|
error: {
|
|
150
|
-
message: `账号 ${picked.account.id}
|
|
151
|
-
type: "
|
|
213
|
+
message: `账号 ${picked.account.id} 刷新 token 失败: ${error instanceof Error ? error.message : String(error)}`,
|
|
214
|
+
type: "account_token_refresh_failed"
|
|
152
215
|
}
|
|
153
216
|
};
|
|
154
217
|
continue;
|
|
155
218
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
}
|
|
168
|
-
markAccountFailure(dependencies, picked.account.id, "token_refresh_failed", 10 * 60);
|
|
169
|
-
lastErrorPayload = {
|
|
170
|
-
error: {
|
|
171
|
-
message: `账号 ${picked.account.id} 刷新 token 失败: ${error instanceof Error ? error.message : String(error)}`,
|
|
172
|
-
type: "account_token_refresh_failed"
|
|
173
|
-
}
|
|
174
|
-
};
|
|
175
|
-
continue;
|
|
219
|
+
}
|
|
220
|
+
const responseHeaders = pickResponseHeaders(upstream.headers);
|
|
221
|
+
if (upstream.statusCode === 429 || upstream.statusCode === 403) {
|
|
222
|
+
const errorText = await upstream.body.text();
|
|
223
|
+
const block = (0, upstream_error_policy_1.resolveBlockWindow)(picked, errorText);
|
|
224
|
+
dependencies.setAccountBlock(picked.account.id, block.until, block.reason);
|
|
225
|
+
lastStatusCode = upstream.statusCode;
|
|
226
|
+
lastErrorPayload = {
|
|
227
|
+
error: {
|
|
228
|
+
message: `账号 ${picked.account.id} 受限: ${errorText}`,
|
|
229
|
+
type: "account_rate_limited"
|
|
176
230
|
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
231
|
+
};
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (upstream.statusCode >= 400) {
|
|
235
|
+
const errorText = await upstream.body.text();
|
|
236
|
+
if ((0, upstream_error_policy_1.isUsageLimitErrorText)(errorText)) {
|
|
181
237
|
const block = (0, upstream_error_policy_1.resolveBlockWindow)(picked, errorText);
|
|
182
238
|
dependencies.setAccountBlock(picked.account.id, block.until, block.reason);
|
|
183
239
|
lastStatusCode = upstream.statusCode;
|
|
184
240
|
lastErrorPayload = {
|
|
185
241
|
error: {
|
|
186
|
-
message: `账号 ${picked.account.id}
|
|
187
|
-
type: "
|
|
242
|
+
message: `账号 ${picked.account.id} 命中额度限制: ${errorText}`,
|
|
243
|
+
type: "account_usage_limited"
|
|
188
244
|
}
|
|
189
245
|
};
|
|
190
246
|
continue;
|
|
191
247
|
}
|
|
192
|
-
if (upstream.statusCode >=
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
}
|
|
203
|
-
};
|
|
204
|
-
continue;
|
|
205
|
-
}
|
|
206
|
-
if (upstream.statusCode >= 500) {
|
|
207
|
-
markAccountFailure(dependencies, picked.account.id, "upstream_5xx", 60);
|
|
208
|
-
lastStatusCode = upstream.statusCode;
|
|
209
|
-
lastErrorPayload = {
|
|
210
|
-
error: {
|
|
211
|
-
message: `账号 ${picked.account.id} 上游异常: ${errorText}`,
|
|
212
|
-
type: "account_upstream_failed"
|
|
213
|
-
}
|
|
214
|
-
};
|
|
215
|
-
continue;
|
|
216
|
-
}
|
|
217
|
-
return buildSendResult(upstream.statusCode, errorText, {
|
|
218
|
-
"content-type": responseHeaders["content-type"] ?? "application/json",
|
|
219
|
-
...responseHeaders
|
|
220
|
-
});
|
|
248
|
+
if (upstream.statusCode >= 500) {
|
|
249
|
+
markAccountFailure(dependencies, picked.account.id, "upstream_5xx", 60);
|
|
250
|
+
lastStatusCode = upstream.statusCode;
|
|
251
|
+
lastErrorPayload = {
|
|
252
|
+
error: {
|
|
253
|
+
message: `账号 ${picked.account.id} 上游异常: ${errorText}`,
|
|
254
|
+
type: "account_upstream_failed"
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
continue;
|
|
221
258
|
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
headers: {
|
|
227
|
-
...responseHeaders,
|
|
228
|
-
connection: "keep-alive"
|
|
229
|
-
},
|
|
230
|
-
body: upstream.body
|
|
231
|
-
};
|
|
259
|
+
return buildSendResult(upstream.statusCode, errorText, {
|
|
260
|
+
"content-type": responseHeaders["content-type"] ?? "application/json",
|
|
261
|
+
...responseHeaders
|
|
262
|
+
});
|
|
232
263
|
}
|
|
233
|
-
|
|
264
|
+
dependencies.recordAccountScheduleSuccess(picked.account.id);
|
|
265
|
+
return {
|
|
266
|
+
type: "proxy",
|
|
267
|
+
statusCode: upstream.statusCode,
|
|
268
|
+
headers: {
|
|
269
|
+
...responseHeaders,
|
|
270
|
+
connection: "keep-alive"
|
|
271
|
+
},
|
|
272
|
+
body: upstream.body
|
|
273
|
+
};
|
|
234
274
|
}
|
|
275
|
+
return buildSendResult(lastStatusCode, lastErrorPayload);
|
|
276
|
+
};
|
|
277
|
+
const proxyResponsesWithRetry = async (requestHeaders, requestBody) => await proxyCodexWithRetry({
|
|
278
|
+
method: "POST",
|
|
279
|
+
url: "/v1/responses",
|
|
280
|
+
headers: requestHeaders,
|
|
281
|
+
body: requestBody
|
|
282
|
+
});
|
|
283
|
+
return {
|
|
284
|
+
proxyCodexWithRetry,
|
|
285
|
+
proxyResponsesWithRetry
|
|
235
286
|
};
|
|
236
287
|
}
|
|
237
|
-
|
|
288
|
+
_a = createProxyRetryService(), exports.proxyCodexWithRetry = _a.proxyCodexWithRetry, exports.proxyResponsesWithRetry = _a.proxyResponsesWithRetry;
|
package/dist/serve.js
CHANGED
|
@@ -1,9 +1,60 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
"use strict";
|
|
3
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
4
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
5
|
+
};
|
|
3
6
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
4
8
|
const config_1 = require("./config");
|
|
5
9
|
const server_1 = require("./server");
|
|
6
10
|
const text_1 = require("./text");
|
|
11
|
+
/**
|
|
12
|
+
* 将当前服务进程 PID 持久化到本地状态文件,供 `cslot stop` 与健康检查流程复用。
|
|
13
|
+
*
|
|
14
|
+
* @returns 无返回值。
|
|
15
|
+
* @throws 当 PID 文件写入失败时抛出文件系统错误。
|
|
16
|
+
*/
|
|
17
|
+
function writeCurrentPid() {
|
|
18
|
+
node_fs_1.default.writeFileSync((0, config_1.getPidPath)(), `${process.pid}\n`, "utf8");
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* 按幂等方式清理当前服务进程留下的 PID 文件,避免异常退出后残留脏状态。
|
|
22
|
+
*
|
|
23
|
+
* @returns 无返回值。
|
|
24
|
+
* @throws 无显式抛出。
|
|
25
|
+
*/
|
|
26
|
+
function cleanupPidFile() {
|
|
27
|
+
try {
|
|
28
|
+
const pidPath = (0, config_1.getPidPath)();
|
|
29
|
+
if (!node_fs_1.default.existsSync(pidPath)) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const raw = node_fs_1.default.readFileSync(pidPath, "utf8").trim();
|
|
33
|
+
if (Number(raw) === process.pid) {
|
|
34
|
+
node_fs_1.default.rmSync(pidPath, { force: true });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// 退出清理阶段以幂等为主,不阻塞真实退出流程。
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* 注册服务进程退出时的 PID 清理逻辑,兼容正常停止与 launchd 重启场景。
|
|
43
|
+
*
|
|
44
|
+
* @returns 无返回值。
|
|
45
|
+
* @throws 无显式抛出。
|
|
46
|
+
*/
|
|
47
|
+
function registerPidCleanupHandlers() {
|
|
48
|
+
process.once("exit", cleanupPidFile);
|
|
49
|
+
process.once("SIGINT", () => {
|
|
50
|
+
cleanupPidFile();
|
|
51
|
+
process.exit(0);
|
|
52
|
+
});
|
|
53
|
+
process.once("SIGTERM", () => {
|
|
54
|
+
cleanupPidFile();
|
|
55
|
+
process.exit(0);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
7
58
|
/**
|
|
8
59
|
* 后台服务进程入口。
|
|
9
60
|
*
|
|
@@ -16,9 +67,12 @@ async function main() {
|
|
|
16
67
|
const port = portArgIndex >= 0 && process.argv[portArgIndex + 1]
|
|
17
68
|
? Number(process.argv[portArgIndex + 1])
|
|
18
69
|
: config.server.port;
|
|
70
|
+
writeCurrentPid();
|
|
71
|
+
registerPidCleanupHandlers();
|
|
19
72
|
await (0, server_1.startServer)(port);
|
|
20
73
|
}
|
|
21
74
|
void main().catch((error) => {
|
|
75
|
+
cleanupPidFile();
|
|
22
76
|
const message = error instanceof Error ? error.message : String(error);
|
|
23
77
|
console.error((0, text_1.bi)(`cslot service 启动失败: ${message}`, `cslot service failed to start: ${message}`));
|
|
24
78
|
process.exit(1);
|
package/dist/server.js
CHANGED
|
@@ -14,18 +14,50 @@ const usage_sync_1 = require("./usage-sync");
|
|
|
14
14
|
/**
|
|
15
15
|
* 读取代理请求的原始 body 字节,供多账号重试时重复发送同一份载荷。
|
|
16
16
|
*
|
|
17
|
-
* @param stream
|
|
17
|
+
* @param stream 客户端发到代理路由的原始可读流;无 body 时允许为空。
|
|
18
18
|
* @returns 完整请求体的 Buffer;空请求体时返回空 Buffer。
|
|
19
19
|
* @throws 当读取流失败时抛出底层 I/O 错误。
|
|
20
20
|
*/
|
|
21
21
|
async function readRawRequestBody(stream) {
|
|
22
22
|
const chunks = [];
|
|
23
|
+
if (!stream) {
|
|
24
|
+
return Buffer.alloc(0);
|
|
25
|
+
}
|
|
23
26
|
for await (const chunk of stream) {
|
|
24
27
|
// 统一转成 Buffer,避免不同 chunk 类型在后续重发时出现编码歧义。
|
|
25
28
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
26
29
|
}
|
|
27
30
|
return Buffer.concat(chunks);
|
|
28
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* 将上游响应体安全地透传给已 hijack 的本地响应。
|
|
34
|
+
*
|
|
35
|
+
* 业务含义:
|
|
36
|
+
* 1. 一旦开始写出上游 header,就不能再把异常抛回 Fastify 默认错误处理器。
|
|
37
|
+
* 2. 上游流中途断开或客户端提前关闭时,只终止当前连接,避免整个 cslot 进程崩溃。
|
|
38
|
+
*
|
|
39
|
+
* @param reply 当前请求对应的 Fastify reply。
|
|
40
|
+
* @param result 已成功拿到响应头的上游代理结果。
|
|
41
|
+
* @returns Promise,流复制结束或被安全终止后返回。
|
|
42
|
+
* @throws 无显式抛出;异常会被当前方法内部吞掉并转成连接销毁。
|
|
43
|
+
*/
|
|
44
|
+
async function streamProxyResponse(reply, result) {
|
|
45
|
+
reply.hijack();
|
|
46
|
+
reply.raw.writeHead(result.statusCode, result.headers);
|
|
47
|
+
try {
|
|
48
|
+
for await (const chunk of result.body) {
|
|
49
|
+
reply.raw.write(chunk);
|
|
50
|
+
}
|
|
51
|
+
reply.raw.end();
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
console.error("cslot proxy stream aborted", error);
|
|
55
|
+
// 响应已开始输出,此时只能销毁当前连接,不能再交给 Fastify 二次写 header。
|
|
56
|
+
if (!reply.raw.destroyed) {
|
|
57
|
+
reply.raw.destroy?.(error instanceof Error ? error : new Error(String(error)));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
29
61
|
/**
|
|
30
62
|
* 启动一个极轻量本地服务,供后续接入代理或脚本化查询使用。
|
|
31
63
|
*
|
|
@@ -57,9 +89,14 @@ async function startServer(port) {
|
|
|
57
89
|
selected: (0, scheduler_1.pickBestAccount)()
|
|
58
90
|
};
|
|
59
91
|
});
|
|
60
|
-
const
|
|
61
|
-
const requestBody = await readRawRequestBody(
|
|
62
|
-
const result = await (0, proxy_retry_service_1.
|
|
92
|
+
const codexProxyHandler = async (request, reply) => {
|
|
93
|
+
const requestBody = await readRawRequestBody(request.body);
|
|
94
|
+
const result = await (0, proxy_retry_service_1.proxyCodexWithRetry)({
|
|
95
|
+
method: request.method,
|
|
96
|
+
url: request.url,
|
|
97
|
+
headers: request.headers,
|
|
98
|
+
body: requestBody
|
|
99
|
+
});
|
|
63
100
|
if (result.type === "send") {
|
|
64
101
|
reply.code(result.statusCode);
|
|
65
102
|
for (const [headerName, headerValue] of Object.entries(result.headers ?? {})) {
|
|
@@ -68,12 +105,7 @@ async function startServer(port) {
|
|
|
68
105
|
reply.send(result.payload);
|
|
69
106
|
return;
|
|
70
107
|
}
|
|
71
|
-
reply
|
|
72
|
-
reply.raw.writeHead(result.statusCode, result.headers);
|
|
73
|
-
for await (const chunk of result.body) {
|
|
74
|
-
reply.raw.write(chunk);
|
|
75
|
-
}
|
|
76
|
-
reply.raw.end();
|
|
108
|
+
await streamProxyResponse(reply, result);
|
|
77
109
|
};
|
|
78
110
|
const backendProxyHandler = async (request, reply) => {
|
|
79
111
|
const requestBody = request.body ? await readRawRequestBody(request.body) : undefined;
|
|
@@ -91,12 +123,7 @@ async function startServer(port) {
|
|
|
91
123
|
reply.send(result.payload);
|
|
92
124
|
return;
|
|
93
125
|
}
|
|
94
|
-
reply
|
|
95
|
-
reply.raw.writeHead(result.statusCode, result.headers);
|
|
96
|
-
for await (const chunk of result.body) {
|
|
97
|
-
reply.raw.write(chunk);
|
|
98
|
-
}
|
|
99
|
-
reply.raw.end();
|
|
126
|
+
await streamProxyResponse(reply, result);
|
|
100
127
|
};
|
|
101
128
|
await app.register(async (proxyApp) => {
|
|
102
129
|
// 代理路由需要原样透传 body,不能在本地先做 JSON 解析与大小限制拦截。
|
|
@@ -104,11 +131,33 @@ async function startServer(port) {
|
|
|
104
131
|
proxyApp.addContentTypeParser("*", (request, payload, done) => {
|
|
105
132
|
done(null, payload);
|
|
106
133
|
});
|
|
107
|
-
proxyApp.
|
|
108
|
-
|
|
134
|
+
proxyApp.route({
|
|
135
|
+
method: ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"],
|
|
136
|
+
url: "/v1/*",
|
|
137
|
+
bodyLimit: Number.MAX_SAFE_INTEGER,
|
|
138
|
+
handler: async (request, reply) => {
|
|
139
|
+
const body = request.body;
|
|
140
|
+
await codexProxyHandler({
|
|
141
|
+
method: request.method,
|
|
142
|
+
url: request.url,
|
|
143
|
+
headers: request.headers,
|
|
144
|
+
body
|
|
145
|
+
}, reply);
|
|
146
|
+
}
|
|
109
147
|
});
|
|
110
|
-
proxyApp.
|
|
111
|
-
|
|
148
|
+
proxyApp.route({
|
|
149
|
+
method: ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"],
|
|
150
|
+
url: "/backend-api/codex/*",
|
|
151
|
+
bodyLimit: Number.MAX_SAFE_INTEGER,
|
|
152
|
+
handler: async (request, reply) => {
|
|
153
|
+
const body = request.body;
|
|
154
|
+
await codexProxyHandler({
|
|
155
|
+
method: request.method,
|
|
156
|
+
url: request.url,
|
|
157
|
+
headers: request.headers,
|
|
158
|
+
body
|
|
159
|
+
}, reply);
|
|
160
|
+
}
|
|
112
161
|
});
|
|
113
162
|
proxyApp.route({
|
|
114
163
|
method: ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"],
|
package/dist/service-control.js
CHANGED
|
@@ -5,6 +5,16 @@ exports.handleStop = handleStop;
|
|
|
5
5
|
const service_lifecycle_service_1 = require("./app/service-lifecycle-service");
|
|
6
6
|
const config_1 = require("./config");
|
|
7
7
|
const text_1 = require("./text");
|
|
8
|
+
function describeServiceManager(manager) {
|
|
9
|
+
switch (manager) {
|
|
10
|
+
case "launchd":
|
|
11
|
+
return (0, text_1.bi)("launchd(开机自启 + 异常自动拉起)", "launchd (auto-start on boot + auto-restart on failure)");
|
|
12
|
+
case "systemd-user":
|
|
13
|
+
return (0, text_1.bi)("systemd --user(开机自启 + 异常自动拉起)", "systemd --user (auto-start on boot + auto-restart on failure)");
|
|
14
|
+
default:
|
|
15
|
+
return (0, text_1.bi)("detached 进程(仅本次后台运行,不保证自动拉起)", "detached process (background only, no guaranteed auto-restart)");
|
|
16
|
+
}
|
|
17
|
+
}
|
|
8
18
|
/**
|
|
9
19
|
* 后台启动 cslot 服务并写入 PID 文件。
|
|
10
20
|
*
|
|
@@ -17,6 +27,7 @@ async function handleStart(portOverride) {
|
|
|
17
27
|
const result = await (0, service_lifecycle_service_1.startManagedService)(portOverride);
|
|
18
28
|
if (result.alreadyRunning) {
|
|
19
29
|
console.log((0, text_1.bi)(`服务已在运行,PID=${result.pid}`, `Service is already running. PID=${result.pid}`));
|
|
30
|
+
console.log(`托管 / Manager: ${describeServiceManager(result.manager)}`);
|
|
20
31
|
if (portOverride) {
|
|
21
32
|
console.log((0, text_1.bi)(`已将新端口写入配置: ${result.port}`, `The new port has been saved to config: ${result.port}`));
|
|
22
33
|
console.log((0, text_1.bi)("请先执行 cslot stop,再执行 cslot start 使新端口生效。", "Run cslot stop first, then cslot start to apply the new port."));
|
|
@@ -27,6 +38,7 @@ async function handleStart(portOverride) {
|
|
|
27
38
|
console.log((0, text_1.bi)(`默认端口 4399 已被占用,已自动切换到 ${result.port}`, `Default port 4399 is busy. Automatically switched to ${result.port}`));
|
|
28
39
|
}
|
|
29
40
|
console.log((0, text_1.bi)(`服务已启动: http://${config.server.host}:${result.port}`, `Service started: http://${config.server.host}:${result.port}`));
|
|
41
|
+
console.log(`托管 / Manager: ${describeServiceManager(result.manager)}`);
|
|
30
42
|
console.log(`PID: ${result.pid}`);
|
|
31
43
|
console.log((0, text_1.bi)(`日志: ${result.logPath}`, `Log: ${result.logPath}`));
|
|
32
44
|
}
|
package/dist/upstream-client.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.buildUpstreamHeaders = buildUpstreamHeaders;
|
|
4
|
+
exports.sendCodexRequest = sendCodexRequest;
|
|
4
5
|
exports.sendCodexResponsesRequest = sendCodexResponsesRequest;
|
|
5
6
|
exports.buildChatGptBackendHeaders = buildChatGptBackendHeaders;
|
|
6
7
|
exports.sendChatGptBackendRequest = sendChatGptBackendRequest;
|
|
@@ -38,13 +39,38 @@ function buildUpstreamHeaders(requestHeaders, accessToken, bodyLength, accountId
|
|
|
38
39
|
if (!headers.accept) {
|
|
39
40
|
headers.accept = "text/event-stream, application/json";
|
|
40
41
|
}
|
|
41
|
-
|
|
42
|
+
if (typeof bodyLength === "number") {
|
|
43
|
+
headers["content-length"] = String(bodyLength);
|
|
44
|
+
}
|
|
42
45
|
headers["user-agent"] = "codex-slot/0.1.1";
|
|
43
46
|
if (accountIdHeader) {
|
|
44
47
|
headers["chatgpt-account-id"] = accountIdHeader;
|
|
45
48
|
}
|
|
46
49
|
return headers;
|
|
47
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* 向 Codex-compatible 上游发送一次通用请求。
|
|
53
|
+
*
|
|
54
|
+
* 业务含义:
|
|
55
|
+
* 1. 本地 `/v1/*` 或旧 `/backend-api/codex/*` 路由都应复用同一条上游转发逻辑,避免再按接口逐个补洞。
|
|
56
|
+
* 2. 路由后缀与 query 原样拼接到 `codexBaseUrl` 后,仅由 cslot 负责替换官方 access token 与账号头。
|
|
57
|
+
*
|
|
58
|
+
* @param options 上游请求参数,包含方法、目标 path/query、原始请求头以及可选 body。
|
|
59
|
+
* @returns undici 上游响应对象。
|
|
60
|
+
* @throws 当网络层或 undici 请求失败时透传底层异常。
|
|
61
|
+
*/
|
|
62
|
+
async function sendCodexRequest(options) {
|
|
63
|
+
const baseUrl = options.codexBaseUrl.replace(/\/+$/, "");
|
|
64
|
+
const pathWithQuery = options.pathWithQuery.startsWith("/")
|
|
65
|
+
? options.pathWithQuery
|
|
66
|
+
: `/${options.pathWithQuery}`;
|
|
67
|
+
const bodyLength = options.body && options.body.length > 0 ? options.body.length : undefined;
|
|
68
|
+
return await (0, undici_1.request)(`${baseUrl}${pathWithQuery}`, {
|
|
69
|
+
method: options.method,
|
|
70
|
+
headers: buildUpstreamHeaders(options.requestHeaders, options.accessToken, bodyLength, options.accountIdHeader),
|
|
71
|
+
body: options.body && options.body.length > 0 ? options.body : undefined
|
|
72
|
+
});
|
|
73
|
+
}
|
|
48
74
|
/**
|
|
49
75
|
* 向 Codex responses 上游发送一次请求。
|
|
50
76
|
*
|
|
@@ -57,9 +83,13 @@ function buildUpstreamHeaders(requestHeaders, accessToken, bodyLength, accountId
|
|
|
57
83
|
* @throws 当网络层或 undici 请求失败时透传底层异常。
|
|
58
84
|
*/
|
|
59
85
|
async function sendCodexResponsesRequest(options) {
|
|
60
|
-
return await (
|
|
86
|
+
return await sendCodexRequest({
|
|
87
|
+
codexBaseUrl: options.codexBaseUrl,
|
|
61
88
|
method: "POST",
|
|
62
|
-
|
|
89
|
+
pathWithQuery: "/responses",
|
|
90
|
+
requestHeaders: options.requestHeaders,
|
|
91
|
+
accessToken: options.accessToken,
|
|
92
|
+
accountIdHeader: options.accountIdHeader,
|
|
63
93
|
body: options.body
|
|
64
94
|
});
|
|
65
95
|
}
|