coding-tool-x 3.9.5-261009 → 3.9.5-261012
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/CHANGELOG.md +2 -0
- package/package.json +1 -1
- package/src/commands/daemon.js +12 -4
- package/src/index.js +98 -14
- package/src/platforms/drivers/claude/openai-gateway.js +1 -1
- package/src/platforms/drivers/claude/proxy-implementation.js +13 -8
- package/src/platforms/drivers/codex/proxy-implementation.js +16 -10
- package/src/platforms/drivers/dsh/proxy-implementation.js +32 -17
- package/src/platforms/drivers/gemini/proxy-implementation.js +16 -10
- package/src/platforms/drivers/omp/gateway.js +35 -11
- package/src/platforms/drivers/opencode/proxy-implementation.js +36 -29
- package/src/server/index.js +3 -0
- package/src/server/services/memory-reclaimer.js +88 -0
- package/src/server/services/server-shutdown.js +28 -3
- package/src/shared/proxy-transport.js +33 -0
package/CHANGELOG.md
CHANGED
|
@@ -23,6 +23,8 @@
|
|
|
23
23
|
- **依赖安全升级** - adm-zip 升至 0.6.0(GHSA-xcpc-8h2w-3j85)、js-yaml 升至 4.3.1(GHSA-h67p-54hq-rp68 / GHSA-52cp-r559-cp3m / GHSA-5p4m-2wfm-xmqj)、pm2 升至 7.0.3(GHSA-x5gf-qvw8-r2rm),overrides 同步更新 basic-ftp / qs / systeminformation / js-yaml;`npm audit` 报告归零
|
|
24
24
|
|
|
25
25
|
### Fixed
|
|
26
|
+
- **代理连接与内存回收** - 修复各 CLI 请求残留、客户端断开后的上游取消和渠道释放;后台退出时排空代理请求,延长上游空闲超时;OMP 重试取消失败响应,过滤连接保活头,并增加内存压力回收。
|
|
27
|
+
- **CLI 状态和错误子命令** - 修复 `ctx status` 的代理 Driver 状态解析;错误子命令返回非零退出码,不再进入控制面。
|
|
26
28
|
- **Claude / Gemini 原生 wire 兼容** - OpenCode Claude / Gemini 代理、模型探测和测速统一使用 Anthropic Messages 与 Google Generative AI(含 Gemini CLI)原生请求格式、认证头与目标 URL;OMP managed provider 写入 `anthropic-messages` / `google-generative-ai` 标识,避免代理、adapter 与探测各自维护不一致的请求格式
|
|
27
29
|
- **OMP Codex Responses 路径兼容** - managed gateway 将 OMP 原生 `openai-codex-responses` 的 `/codex/responses` 请求映射到 OpenAI-compatible 渠道的 `/v1/responses`,避免 Codex-only 服务返回 HTTP 404
|
|
28
30
|
- **OMP 渠道失败不再冻结** - OMP 动态切换开启后,失败渠道不再进入冻结状态。OMP 网关按请求即时回退其他渠道,冻结会导致短暂故障的渠道在恢复前被排除在后续分配之外
|
package/package.json
CHANGED
package/src/commands/daemon.js
CHANGED
|
@@ -299,7 +299,13 @@ async function getProxyStatusEntries({ registry = getPlatformRegistry(), runtime
|
|
|
299
299
|
entries.push({ platform, state: { status: 'unsupported', ...context }, port: getPlatformPort(platform, config) });
|
|
300
300
|
continue;
|
|
301
301
|
}
|
|
302
|
-
const
|
|
302
|
+
const result = await Promise.resolve(driver.status());
|
|
303
|
+
// Proxy drivers use the shared Driver contract and wrap successful
|
|
304
|
+
// service status payloads in { status: 'ok', data }. Keep compatibility
|
|
305
|
+
// with legacy drivers that return the service payload directly.
|
|
306
|
+
const state = result && result.status === 'ok'
|
|
307
|
+
? (result.data && typeof result.data === 'object' ? result.data : {})
|
|
308
|
+
: result;
|
|
303
309
|
if (state && typeof state.status === 'string' && state.status !== 'ok') {
|
|
304
310
|
entries.push({ platform, state: { ...state, ...context }, port: getPlatformPort(platform, config) });
|
|
305
311
|
} else {
|
|
@@ -508,8 +514,10 @@ function buildStartOptions(port, enableHost, enableHttps) {
|
|
|
508
514
|
interpreter: 'node',
|
|
509
515
|
windowsHide: true,
|
|
510
516
|
autorestart: true,
|
|
511
|
-
|
|
517
|
+
// Let active OMP streams drain before PM2 forcibly terminates the process.
|
|
518
|
+
kill_timeout: 125000,
|
|
512
519
|
max_memory_restart: '500M',
|
|
520
|
+
node_args: '--expose-gc',
|
|
513
521
|
env,
|
|
514
522
|
output: path.join(PATHS.logs, STARTUP_LOG_FILE),
|
|
515
523
|
error: path.join(PATHS.logs, STARTUP_LOG_FILE),
|
|
@@ -761,9 +769,9 @@ async function handleStatus() {
|
|
|
761
769
|
console.log(chalk.gray(` ${label}${chalk.gray('[N/A] 不支持')}`));
|
|
762
770
|
continue;
|
|
763
771
|
}
|
|
764
|
-
if (state.status
|
|
772
|
+
if (state.status && state.status !== 'ok') {
|
|
765
773
|
console.log(chalk.red(` ${label}${chalk.red('[ERROR] 查询失败')}`) +
|
|
766
|
-
chalk.gray(` (${state.error || 'unknown error'})`));
|
|
774
|
+
chalk.gray(` (${state.error || state.status || 'unknown error'})`));
|
|
767
775
|
continue;
|
|
768
776
|
}
|
|
769
777
|
console.log(chalk.gray(` ${label}`) + (state.running
|
package/src/index.js
CHANGED
|
@@ -164,6 +164,11 @@ async function dispatchPlatformCommand(platform, args = [], dependencies = {}) {
|
|
|
164
164
|
const channel = platform.key;
|
|
165
165
|
const options = dependencies.registry ? { registry: dependencies.registry } : {};
|
|
166
166
|
|
|
167
|
+
if (args.length > 1) {
|
|
168
|
+
reportInvalidCommand('参数', args[1], `ctx ${platform.command || platform.key} ${action}`);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
167
172
|
switch (action) {
|
|
168
173
|
case 'start':
|
|
169
174
|
await proxyStart(channel, options);
|
|
@@ -178,11 +183,23 @@ async function dispatchPlatformCommand(platform, args = [], dependencies = {}) {
|
|
|
178
183
|
await proxyStatus(channel, options);
|
|
179
184
|
return;
|
|
180
185
|
default:
|
|
181
|
-
|
|
182
|
-
console.log(chalk.gray('支持的操作: start, stop, restart, status\n'));
|
|
186
|
+
reportInvalidCommand('操作', action, '支持的操作: start, stop, restart, status');
|
|
183
187
|
}
|
|
184
188
|
}
|
|
185
189
|
|
|
190
|
+
function reportInvalidCommand(kind, value, usage = '') {
|
|
191
|
+
console.error(chalk.red(`\n[ERROR] 未知${kind}: ${value}\n`));
|
|
192
|
+
if (usage) console.error(chalk.gray(`${usage}\n`));
|
|
193
|
+
process.exitCode = 1;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function validateCommandOptions(command, args, allowedOptions) {
|
|
197
|
+
const invalidOption = args.find(argument => !allowedOptions.has(argument));
|
|
198
|
+
if (!invalidOption) return true;
|
|
199
|
+
reportInvalidCommand('参数', invalidOption, `用法: ${command}`);
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
|
|
186
203
|
let processShutdownPromise = null;
|
|
187
204
|
|
|
188
205
|
async function stopOwnedOmpGatewayBeforeExit() {
|
|
@@ -193,7 +210,7 @@ async function stopOwnedOmpGatewayBeforeExit() {
|
|
|
193
210
|
} = require('./platforms/drivers/omp/proxy-implementation');
|
|
194
211
|
if (getOmpProxyStatus().running) {
|
|
195
212
|
const preserveManagedMode = process.argv.includes('--daemon');
|
|
196
|
-
await stopOmpProxyServer({ forceAfterMs:
|
|
213
|
+
await stopOmpProxyServer({ forceAfterMs: 120000, preserveManagedMode });
|
|
197
214
|
}
|
|
198
215
|
} catch (error) {
|
|
199
216
|
console.error(chalk.yellow(`[WARN] OMP 动态网关退出清理失败: ${error.message}`));
|
|
@@ -203,12 +220,21 @@ async function stopOwnedOmpGatewayBeforeExit() {
|
|
|
203
220
|
function shutdownProcess(code = 0, error = null) {
|
|
204
221
|
if (processShutdownPromise) return processShutdownPromise;
|
|
205
222
|
processShutdownPromise = (async () => {
|
|
223
|
+
try {
|
|
224
|
+
require('./server/services/memory-reclaimer').stopMemoryReclaimer();
|
|
225
|
+
} catch (memoryError) {
|
|
226
|
+
console.error(chalk.yellow(`[WARN] 内存回收器退出清理失败: ${memoryError.message}`));
|
|
227
|
+
}
|
|
206
228
|
try {
|
|
207
229
|
require('./server/services/native-log-observer').shutdownNativeCliLogObserver();
|
|
208
230
|
} catch (observerError) {
|
|
209
231
|
console.error(chalk.yellow(`[WARN] CLI 原生会话日志退出清理失败: ${observerError.message}`));
|
|
210
232
|
}
|
|
211
|
-
await
|
|
233
|
+
await Promise.all([
|
|
234
|
+
stopOwnedOmpGatewayBeforeExit(),
|
|
235
|
+
require('./server/services/server-shutdown').drainProxyServers({ forceAfterMs: 120000 })
|
|
236
|
+
.catch(drainError => console.error(`[WARN] Proxy drain failed: ${drainError.message}`))
|
|
237
|
+
]);
|
|
212
238
|
eventBus.emitSync('cli:shutdown', {});
|
|
213
239
|
PluginManager.shutdownPlugins();
|
|
214
240
|
try {
|
|
@@ -282,6 +308,11 @@ async function main({ menuDependencies } = {}) {
|
|
|
282
308
|
if (args[0] === 'daemon') {
|
|
283
309
|
const subCommand = args[1] || 'status';
|
|
284
310
|
|
|
311
|
+
if (['start', 'stop', 'restart', 'status'].includes(subCommand) && args.length > 2) {
|
|
312
|
+
reportInvalidCommand('参数', args[2], `ctx daemon ${subCommand}`);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
|
|
285
316
|
if (subCommand === 'start') {
|
|
286
317
|
await handleStart();
|
|
287
318
|
return finishCli(0);
|
|
@@ -314,25 +345,37 @@ async function main({ menuDependencies } = {}) {
|
|
|
314
345
|
return;
|
|
315
346
|
}
|
|
316
347
|
|
|
317
|
-
|
|
318
|
-
console.log(chalk.gray('支持的命令: start, stop, restart, status, logs\n'));
|
|
348
|
+
reportInvalidCommand('daemon 子命令', subCommand, '支持的命令: start, stop, restart, status, logs');
|
|
319
349
|
return;
|
|
320
350
|
}
|
|
321
351
|
|
|
322
352
|
// reset 命令 - 恢复默认配置
|
|
323
353
|
if (args[0] === 'reset') {
|
|
354
|
+
if (args.length > 1) {
|
|
355
|
+
reportInvalidCommand('参数', args[1], '用法: ctx reset');
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
324
358
|
await resetConfig();
|
|
325
359
|
return;
|
|
326
360
|
}
|
|
327
361
|
|
|
328
362
|
// security 命令 - 安全设置
|
|
329
363
|
if (args[0] === 'security') {
|
|
330
|
-
const { showSecurityHelp, handleSecurityReset } = require('./commands/security');
|
|
331
364
|
const subCommand = args[1] || 'help';
|
|
332
365
|
|
|
366
|
+
if (!['reset', 'disable', 'off', 'help'].includes(subCommand)) {
|
|
367
|
+
reportInvalidCommand('security 子命令', subCommand, '支持的命令: reset, disable, off, help');
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
const { showSecurityHelp, handleSecurityReset } = require('./commands/security');
|
|
371
|
+
|
|
333
372
|
if (subCommand === 'reset' || subCommand === 'disable' || subCommand === 'off') {
|
|
373
|
+
if (args.length > 2) {
|
|
374
|
+
reportInvalidCommand('参数', args[2], `ctx security ${subCommand}`);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
334
377
|
await handleSecurityReset();
|
|
335
|
-
} else {
|
|
378
|
+
} else if (subCommand === 'help') {
|
|
336
379
|
showSecurityHelp();
|
|
337
380
|
}
|
|
338
381
|
return;
|
|
@@ -340,31 +383,52 @@ async function main({ menuDependencies } = {}) {
|
|
|
340
383
|
|
|
341
384
|
// start 命令 - 启动服务(后台)
|
|
342
385
|
if (args[0] === 'start') {
|
|
386
|
+
if (!validateCommandOptions('ctx start [--host|--hosts] [--https]', args.slice(1), new Set(['--host', '--hosts', '--https']))) return;
|
|
343
387
|
await handleStart();
|
|
344
388
|
return finishCli(0);
|
|
345
389
|
}
|
|
346
390
|
|
|
347
391
|
// stop 命令 - 停止服务
|
|
348
392
|
if (args[0] === 'stop') {
|
|
393
|
+
if (args.length > 1) {
|
|
394
|
+
reportInvalidCommand('参数', args[1], '用法: ctx stop');
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
349
397
|
await handleStop();
|
|
350
398
|
return finishCli(0);
|
|
351
399
|
}
|
|
352
400
|
|
|
353
401
|
// restart 命令 - 重启服务
|
|
354
402
|
if (args[0] === 'restart') {
|
|
403
|
+
if (!validateCommandOptions('ctx restart [--host|--hosts] [--https]', args.slice(1), new Set(['--host', '--hosts', '--https']))) return;
|
|
355
404
|
await handleRestart();
|
|
356
405
|
return finishCli(0);
|
|
357
406
|
}
|
|
358
407
|
|
|
359
408
|
// status 命令 - 查看服务状态
|
|
360
409
|
if (args[0] === 'status') {
|
|
410
|
+
if (args.length > 1) {
|
|
411
|
+
reportInvalidCommand('参数', args[1], '用法: ctx status');
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
361
414
|
await handleStatus();
|
|
362
415
|
return finishCli(0);
|
|
363
416
|
}
|
|
364
417
|
|
|
365
418
|
// ui 命令 - Web UI 管理
|
|
366
419
|
if (args[0] === 'ui') {
|
|
367
|
-
const
|
|
420
|
+
const commandArgs = args.slice(1);
|
|
421
|
+
const subCommand = commandArgs[0] && !commandArgs[0].startsWith('-') ? commandArgs[0] : null;
|
|
422
|
+
const optionArgs = subCommand ? commandArgs.slice(1) : commandArgs;
|
|
423
|
+
const allowedOptions = new Set(['--https', '--host', '--hosts', '--daemon']);
|
|
424
|
+
|
|
425
|
+
if (subCommand && !['start', 'stop', 'restart'].includes(subCommand)) {
|
|
426
|
+
reportInvalidCommand('ui 子命令', subCommand, '支持的命令: start, stop, restart');
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
if (!validateCommandOptions(`ctx ui${subCommand ? ` ${subCommand}` : ''}`, optionArgs,
|
|
430
|
+
subCommand === 'stop' ? new Set() : allowedOptions)) return;
|
|
431
|
+
|
|
368
432
|
if (subCommand === 'start') {
|
|
369
433
|
await handleStart(); // UI start 实际上就是启动整个服务
|
|
370
434
|
return finishCli(0);
|
|
@@ -440,12 +504,17 @@ async function main({ menuDependencies } = {}) {
|
|
|
440
504
|
|
|
441
505
|
// doctor 命令 - 系统诊断
|
|
442
506
|
if (args[0] === 'doctor') {
|
|
507
|
+
if (args.length > 1) {
|
|
508
|
+
reportInvalidCommand('参数', args[1], '用法: ctx doctor');
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
443
511
|
await handleDoctor();
|
|
444
512
|
return;
|
|
445
513
|
}
|
|
446
514
|
|
|
447
515
|
// update 命令 - 检查并更新到最新版本
|
|
448
516
|
if (args[0] === 'update') {
|
|
517
|
+
if (!validateCommandOptions('ctx update [--check]', args.slice(1), new Set(['--check']))) return;
|
|
449
518
|
const checkOnly = args.includes('--check');
|
|
450
519
|
await handleUpdate({ checkOnly });
|
|
451
520
|
return;
|
|
@@ -453,6 +522,10 @@ async function main({ menuDependencies } = {}) {
|
|
|
453
522
|
|
|
454
523
|
// port 命令 - 配置端口
|
|
455
524
|
if (args[0] === 'port') {
|
|
525
|
+
if (args.length > 1) {
|
|
526
|
+
reportInvalidCommand('参数', args[1], '用法: ctx port');
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
456
529
|
const { handlePortConfig } = require('./commands/port-config');
|
|
457
530
|
await handlePortConfig();
|
|
458
531
|
return;
|
|
@@ -460,9 +533,18 @@ async function main({ menuDependencies } = {}) {
|
|
|
460
533
|
|
|
461
534
|
// 代理命令
|
|
462
535
|
if (args[0] === 'proxy') {
|
|
463
|
-
const { handleProxyStart, handleProxyStop, handleProxyStatus } = require('./commands/proxy');
|
|
464
536
|
const subCommand = args[1] || 'start';
|
|
465
537
|
|
|
538
|
+
if (!['start', 'stop', 'status'].includes(subCommand)) {
|
|
539
|
+
reportInvalidCommand('proxy 子命令', subCommand, '支持的命令: start, stop, status');
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
if (args.length > 2) {
|
|
543
|
+
reportInvalidCommand('参数', args[2], `ctx proxy ${subCommand}`);
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
const { handleProxyStart, handleProxyStop, handleProxyStatus } = require('./commands/proxy');
|
|
547
|
+
|
|
466
548
|
switch (subCommand) {
|
|
467
549
|
case 'start':
|
|
468
550
|
await handleProxyStart();
|
|
@@ -476,10 +558,6 @@ async function main({ menuDependencies } = {}) {
|
|
|
476
558
|
handleProxyStatus();
|
|
477
559
|
return;
|
|
478
560
|
|
|
479
|
-
default:
|
|
480
|
-
// 默认执行 start
|
|
481
|
-
await handleProxyStart();
|
|
482
|
-
return;
|
|
483
561
|
}
|
|
484
562
|
}
|
|
485
563
|
|
|
@@ -508,6 +586,12 @@ async function main({ menuDependencies } = {}) {
|
|
|
508
586
|
return;
|
|
509
587
|
}
|
|
510
588
|
|
|
589
|
+
if (args.length > 0) {
|
|
590
|
+
reportInvalidCommand('命令', args[0], '运行 ctx --help 查看可用命令');
|
|
591
|
+
PluginManager.shutdownPlugins();
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
|
|
511
595
|
while (true) {
|
|
512
596
|
// 显示主菜单
|
|
513
597
|
const { showMainMenu } = require('./ui/menu');
|
|
@@ -7,7 +7,7 @@ const { createDecodedStream } = require('../../../server/services/response-decod
|
|
|
7
7
|
const { parseNonStreamingUsage } = require('../../../shared/response-usage-parser');
|
|
8
8
|
const { convertClaudeToOpenCodePayload } = require('../opencode/gateway-converter');
|
|
9
9
|
|
|
10
|
-
const REQUEST_TIMEOUT_MS =
|
|
10
|
+
const { PROXY_IDLE_TIMEOUT_MS: REQUEST_TIMEOUT_MS } = require('../../../server/services/server-shutdown');
|
|
11
11
|
|
|
12
12
|
function getRequestPathname(urlPath = '') {
|
|
13
13
|
try {
|
|
@@ -19,14 +19,15 @@ const {
|
|
|
19
19
|
const { persistProxyRequestSnapshot, persistClaudeRequestTemplate } = require('../../../server/services/request-logger');
|
|
20
20
|
const { redirectModel, normalizeGatewaySourceType } = require('../../../shared/proxy-utils');
|
|
21
21
|
const { handleClaudeOpenAiGatewayRequest } = require('./openai-gateway');
|
|
22
|
-
const {
|
|
22
|
+
const { bindProxyRequest, prepareProxyResponse } = require('../../../shared/proxy-transport');
|
|
23
|
+
const { attachServerShutdownHandling, expediteServerShutdown, PROXY_IDLE_TIMEOUT_MS } = require('../../../server/services/server-shutdown');
|
|
23
24
|
|
|
24
25
|
let proxyServer = null;
|
|
25
26
|
let proxyApp = null;
|
|
26
27
|
let currentPort = null;
|
|
27
28
|
|
|
28
29
|
// 用于存储每个请求的元数据(用于 WebSocket 日志)
|
|
29
|
-
|
|
30
|
+
let requestMetadata = new WeakMap();
|
|
30
31
|
|
|
31
32
|
// 用于缓存已打印过的模型重定向规则,避免重复打印
|
|
32
33
|
// 格式: { channelId: { "originalModel": "redirectedModel", ... } }
|
|
@@ -35,7 +36,7 @@ const printedRedirectCache = new Map();
|
|
|
35
36
|
const jsonBodyParser = express.json({
|
|
36
37
|
limit: '100mb',
|
|
37
38
|
verify: (req, res, buf) => {
|
|
38
|
-
req.rawBody =
|
|
39
|
+
req.rawBody = buf;
|
|
39
40
|
}
|
|
40
41
|
});
|
|
41
42
|
|
|
@@ -174,6 +175,7 @@ async function startProxyServer(options = {}) {
|
|
|
174
175
|
const proxy = httpProxy.createProxyServer({});
|
|
175
176
|
|
|
176
177
|
proxy.on('proxyReq', (proxyReq, req, res) => {
|
|
178
|
+
bindProxyRequest(proxyReq, res);
|
|
177
179
|
const selectedChannel = req.selectedChannel;
|
|
178
180
|
if (selectedChannel) {
|
|
179
181
|
const requestId = `${Date.now()}-${Math.random()}`;
|
|
@@ -238,9 +240,10 @@ async function startProxyServer(options = {}) {
|
|
|
238
240
|
};
|
|
239
241
|
|
|
240
242
|
req.__releaseChannel = release;
|
|
241
|
-
|
|
243
|
+
res.on('finish', release);
|
|
242
244
|
res.on('close', release);
|
|
243
245
|
res.on('error', release);
|
|
246
|
+
if (res.destroyed || res.writableEnded) { release(); return; }
|
|
244
247
|
|
|
245
248
|
const effectiveKey = getEffectiveApiKey(channel);
|
|
246
249
|
if (!effectiveKey) {
|
|
@@ -306,8 +309,8 @@ async function startProxyServer(options = {}) {
|
|
|
306
309
|
const proxyOptions = {
|
|
307
310
|
target: channel.baseUrl,
|
|
308
311
|
changeOrigin: true,
|
|
309
|
-
proxyTimeout:
|
|
310
|
-
timeout:
|
|
312
|
+
proxyTimeout: PROXY_IDLE_TIMEOUT_MS, // Allow long reasoning gaps without an unbounded upstream wait
|
|
313
|
+
timeout: 0 // The upstream timeout bounds idle waits; streaming clients may pause
|
|
311
314
|
};
|
|
312
315
|
|
|
313
316
|
if (channel.proxyUrl) {
|
|
@@ -329,6 +332,7 @@ async function startProxyServer(options = {}) {
|
|
|
329
332
|
}
|
|
330
333
|
});
|
|
331
334
|
} catch (error) {
|
|
335
|
+
req.__releaseChannel?.();
|
|
332
336
|
console.error('Channel allocation error:', error);
|
|
333
337
|
if (!res.headersSent) {
|
|
334
338
|
res.status(503).json({
|
|
@@ -341,6 +345,7 @@ async function startProxyServer(options = {}) {
|
|
|
341
345
|
|
|
342
346
|
// 代理响应只负责维护传输健康状态;实时日志和 usage 来自 CLI 原生日志。
|
|
343
347
|
proxy.on('proxyRes', (proxyRes, req, res) => {
|
|
348
|
+
prepareProxyResponse(proxyRes, res);
|
|
344
349
|
const metadata = requestMetadata.get(req);
|
|
345
350
|
if (!metadata) return;
|
|
346
351
|
|
|
@@ -425,9 +430,9 @@ async function stopProxyServer(options = {}) {
|
|
|
425
430
|
return { success: true, message: 'Proxy server not running' };
|
|
426
431
|
}
|
|
427
432
|
|
|
428
|
-
requestMetadata
|
|
433
|
+
requestMetadata = new WeakMap();
|
|
429
434
|
|
|
430
|
-
const shutdownTimer = expediteServerShutdown(proxyServer);
|
|
435
|
+
const shutdownTimer = expediteServerShutdown(proxyServer, { forceAfterMs: options.forceAfterMs });
|
|
431
436
|
|
|
432
437
|
return new Promise((resolve) => {
|
|
433
438
|
proxyServer.close(() => {
|
|
@@ -15,14 +15,15 @@ const {
|
|
|
15
15
|
isChatCompletionsPath,
|
|
16
16
|
ensureOpenAiStreamUsage
|
|
17
17
|
} = require('../../../shared/proxy-utils');
|
|
18
|
-
const {
|
|
18
|
+
const { bindProxyRequest, prepareProxyResponse } = require('../../../shared/proxy-transport');
|
|
19
|
+
const { attachServerShutdownHandling, expediteServerShutdown, PROXY_IDLE_TIMEOUT_MS } = require('../../../server/services/server-shutdown');
|
|
19
20
|
|
|
20
21
|
let proxyServer = null;
|
|
21
22
|
let proxyApp = null;
|
|
22
23
|
let currentPort = null;
|
|
23
24
|
|
|
24
25
|
// 用于存储每个请求的元数据
|
|
25
|
-
|
|
26
|
+
let requestMetadata = new WeakMap();
|
|
26
27
|
|
|
27
28
|
// 用于缓存已打印过的模型重定向规则,避免重复打印
|
|
28
29
|
// 格式: { channelId: { "originalModel": "redirectedModel", ... } }
|
|
@@ -37,7 +38,7 @@ const resolveCodexTarget = resolveTargetUrl;
|
|
|
37
38
|
const jsonBodyParser = express.json({
|
|
38
39
|
limit: '100mb',
|
|
39
40
|
verify: (req, res, buf) => {
|
|
40
|
-
req.rawBody =
|
|
41
|
+
req.rawBody = buf;
|
|
41
42
|
}
|
|
42
43
|
});
|
|
43
44
|
|
|
@@ -77,7 +78,8 @@ async function startCodexProxyServer(options = {}) {
|
|
|
77
78
|
|
|
78
79
|
const proxy = httpProxy.createProxyServer({});
|
|
79
80
|
|
|
80
|
-
proxy.on('proxyReq', (proxyReq, req) => {
|
|
81
|
+
proxy.on('proxyReq', (proxyReq, req, res) => {
|
|
82
|
+
bindProxyRequest(proxyReq, res);
|
|
81
83
|
const activeChannel = req.selectedChannel;
|
|
82
84
|
if (!activeChannel) return;
|
|
83
85
|
|
|
@@ -127,8 +129,11 @@ async function startCodexProxyServer(options = {}) {
|
|
|
127
129
|
};
|
|
128
130
|
})();
|
|
129
131
|
|
|
132
|
+
req.__releaseChannel = release;
|
|
133
|
+
res.on('finish', release);
|
|
130
134
|
res.on('close', release);
|
|
131
135
|
res.on('error', release);
|
|
136
|
+
if (res.destroyed || res.writableEnded) { release(); return; }
|
|
132
137
|
|
|
133
138
|
broadcastSchedulerState('codex', getSchedulerState('codex'));
|
|
134
139
|
|
|
@@ -200,8 +205,8 @@ async function startCodexProxyServer(options = {}) {
|
|
|
200
205
|
proxy.web(req, res, {
|
|
201
206
|
target,
|
|
202
207
|
changeOrigin: true,
|
|
203
|
-
proxyTimeout:
|
|
204
|
-
timeout:
|
|
208
|
+
proxyTimeout: PROXY_IDLE_TIMEOUT_MS, // Allow long reasoning gaps without an unbounded upstream wait
|
|
209
|
+
timeout: 0 // The upstream timeout bounds idle waits; streaming clients may pause
|
|
205
210
|
}, (err) => {
|
|
206
211
|
release();
|
|
207
212
|
if (err) {
|
|
@@ -218,6 +223,7 @@ async function startCodexProxyServer(options = {}) {
|
|
|
218
223
|
}
|
|
219
224
|
});
|
|
220
225
|
} catch (error) {
|
|
226
|
+
req.__releaseChannel?.();
|
|
221
227
|
console.error('Codex channel allocation error:', error);
|
|
222
228
|
if (!res.headersSent) {
|
|
223
229
|
res.status(503).json({
|
|
@@ -232,6 +238,7 @@ async function startCodexProxyServer(options = {}) {
|
|
|
232
238
|
|
|
233
239
|
// 代理响应只负责维护传输健康状态;实时日志和 usage 来自 CLI 原生日志。
|
|
234
240
|
proxy.on('proxyRes', (proxyRes, req, res) => {
|
|
241
|
+
prepareProxyResponse(proxyRes, res);
|
|
235
242
|
const metadata = requestMetadata.get(req);
|
|
236
243
|
if (!metadata) return;
|
|
237
244
|
|
|
@@ -277,8 +284,7 @@ async function startCodexProxyServer(options = {}) {
|
|
|
277
284
|
console.error('Codex proxy error:', err);
|
|
278
285
|
if (req && req.selectedChannel) {
|
|
279
286
|
recordFailure(req.selectedChannel.id, 'codex', err);
|
|
280
|
-
|
|
281
|
-
broadcastSchedulerState('codex', getSchedulerState('codex'));
|
|
287
|
+
req.__releaseChannel?.();
|
|
282
288
|
}
|
|
283
289
|
if (res && !res.headersSent) {
|
|
284
290
|
res.status(502).json({
|
|
@@ -331,9 +337,9 @@ async function stopCodexProxyServer(options = {}) {
|
|
|
331
337
|
return { success: true, message: 'Codex proxy server not running' };
|
|
332
338
|
}
|
|
333
339
|
|
|
334
|
-
requestMetadata
|
|
340
|
+
requestMetadata = new WeakMap();
|
|
335
341
|
|
|
336
|
-
const shutdownTimer = expediteServerShutdown(proxyServer);
|
|
342
|
+
const shutdownTimer = expediteServerShutdown(proxyServer, { forceAfterMs: options.forceAfterMs });
|
|
337
343
|
|
|
338
344
|
return new Promise((resolve) => {
|
|
339
345
|
proxyServer.close(() => {
|
|
@@ -20,16 +20,17 @@ const {
|
|
|
20
20
|
const { persistProxyRequestSnapshot } = require('../../../server/services/request-logger');
|
|
21
21
|
const { redirectModel, resolveTargetUrl, isChatCompletionsPath, ensureOpenAiStreamUsage } = require('../../../shared/proxy-utils');
|
|
22
22
|
const { saveProxyStartTime, clearProxyStartTime, getProxyStartTime, getProxyRuntime } = require('../../../server/services/proxy-runtime');
|
|
23
|
-
const {
|
|
23
|
+
const { bindProxyRequest, prepareProxyResponse } = require('../../../shared/proxy-transport');
|
|
24
|
+
const { attachServerShutdownHandling, expediteServerShutdown, PROXY_IDLE_TIMEOUT_MS } = require('../../../server/services/server-shutdown');
|
|
24
25
|
|
|
25
26
|
let proxyServer = null;
|
|
26
27
|
let currentPort = null;
|
|
27
|
-
|
|
28
|
+
let requestMetadata = new WeakMap();
|
|
28
29
|
|
|
29
30
|
const jsonBodyParser = express.json({
|
|
30
31
|
limit: '100mb',
|
|
31
32
|
verify: (req, _res, buffer) => {
|
|
32
|
-
req.rawBody =
|
|
33
|
+
req.rawBody = buffer;
|
|
33
34
|
}
|
|
34
35
|
});
|
|
35
36
|
|
|
@@ -84,8 +85,11 @@ async function startDshProxyServer(options = {}) {
|
|
|
84
85
|
});
|
|
85
86
|
req.selectedChannel = channel;
|
|
86
87
|
release = releaseOnce(channel);
|
|
88
|
+
req.__releaseChannel = release;
|
|
89
|
+
res.on('finish', release);
|
|
87
90
|
res.on('close', release);
|
|
88
91
|
res.on('error', release);
|
|
92
|
+
if (res.destroyed || res.writableEnded) { release(); return; }
|
|
89
93
|
broadcastSchedulerState('dsh', getSchedulerState('dsh'));
|
|
90
94
|
|
|
91
95
|
const apiKey = getEffectiveApiKey(channel);
|
|
@@ -119,8 +123,8 @@ async function startDshProxyServer(options = {}) {
|
|
|
119
123
|
proxy.web(req, res, {
|
|
120
124
|
target: resolveTargetUrl(channel.baseUrl, req.url),
|
|
121
125
|
changeOrigin: true,
|
|
122
|
-
proxyTimeout:
|
|
123
|
-
timeout:
|
|
126
|
+
proxyTimeout: PROXY_IDLE_TIMEOUT_MS,
|
|
127
|
+
timeout: 0
|
|
124
128
|
}, error => {
|
|
125
129
|
release?.();
|
|
126
130
|
if (!error) return;
|
|
@@ -137,7 +141,8 @@ async function startDshProxyServer(options = {}) {
|
|
|
137
141
|
}
|
|
138
142
|
});
|
|
139
143
|
|
|
140
|
-
proxy.on('proxyReq', (proxyReq, req) => {
|
|
144
|
+
proxy.on('proxyReq', (proxyReq, req, res) => {
|
|
145
|
+
bindProxyRequest(proxyReq, res);
|
|
141
146
|
const channel = req.selectedChannel;
|
|
142
147
|
if (!channel) return;
|
|
143
148
|
const metadata = {
|
|
@@ -161,22 +166,32 @@ async function startDshProxyServer(options = {}) {
|
|
|
161
166
|
});
|
|
162
167
|
|
|
163
168
|
proxy.on('proxyRes', (proxyRes, req, res) => {
|
|
169
|
+
prepareProxyResponse(proxyRes, res);
|
|
164
170
|
const metadata = requestMetadata.get(req);
|
|
165
171
|
if (!metadata) return;
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
172
|
+
let completed = false;
|
|
173
|
+
const finish = error => {
|
|
174
|
+
if (completed) return;
|
|
175
|
+
completed = true;
|
|
176
|
+
requestMetadata.delete(req);
|
|
177
|
+
if (error || Number(proxyRes.statusCode) >= 400) {
|
|
178
|
+
recordFailure(metadata.channelId, 'dsh', error || new Error(`DSH upstream HTTP ${proxyRes.statusCode}`));
|
|
179
|
+
} else {
|
|
180
|
+
recordSuccess(metadata.channelId, 'dsh');
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
proxyRes.once('end', () => finish());
|
|
184
|
+
proxyRes.once('error', finish);
|
|
185
|
+
proxyRes.once('close', () => {
|
|
186
|
+
if (!completed) finish(new Error('DSH upstream response closed early'));
|
|
187
|
+
});
|
|
188
|
+
res?.once('close', () => requestMetadata.delete(req));
|
|
173
189
|
});
|
|
174
190
|
|
|
175
191
|
proxy.on('error', (error, req, res) => {
|
|
176
192
|
if (req?.selectedChannel) {
|
|
177
193
|
recordFailure(req.selectedChannel.id, 'dsh', error);
|
|
178
|
-
|
|
179
|
-
broadcastSchedulerState('dsh', getSchedulerState('dsh'));
|
|
194
|
+
req.__releaseChannel?.();
|
|
180
195
|
}
|
|
181
196
|
if (res && !res.headersSent) res.status(502).json({ error: { message: `Proxy error: ${error.message}`, type: 'proxy_error' } });
|
|
182
197
|
});
|
|
@@ -213,10 +228,10 @@ async function startDshProxyServer(options = {}) {
|
|
|
213
228
|
async function stopDshProxyServer(options = {}) {
|
|
214
229
|
const clearStartTime = options.clearStartTime !== false;
|
|
215
230
|
const stoppedPort = currentPort;
|
|
216
|
-
requestMetadata
|
|
231
|
+
requestMetadata = new WeakMap();
|
|
217
232
|
|
|
218
233
|
if (proxyServer) {
|
|
219
|
-
const shutdownTimer = expediteServerShutdown(proxyServer);
|
|
234
|
+
const shutdownTimer = expediteServerShutdown(proxyServer, { forceAfterMs: options.forceAfterMs });
|
|
220
235
|
await new Promise(resolve => proxyServer.close(resolve));
|
|
221
236
|
if (shutdownTimer) clearTimeout(shutdownTimer);
|
|
222
237
|
proxyServer = null;
|
|
@@ -13,14 +13,15 @@ const {
|
|
|
13
13
|
} = require('./channels-implementation');
|
|
14
14
|
const { persistProxyRequestSnapshot } = require('../../../server/services/request-logger');
|
|
15
15
|
const { redirectModel: redirectModelBase, resolveTargetUrl } = require('../../../shared/proxy-utils');
|
|
16
|
-
const {
|
|
16
|
+
const { bindProxyRequest, prepareProxyResponse } = require('../../../shared/proxy-transport');
|
|
17
|
+
const { attachServerShutdownHandling, expediteServerShutdown, PROXY_IDLE_TIMEOUT_MS } = require('../../../server/services/server-shutdown');
|
|
17
18
|
|
|
18
19
|
let proxyServer = null;
|
|
19
20
|
let proxyApp = null;
|
|
20
21
|
let currentPort = null;
|
|
21
22
|
|
|
22
23
|
// 用于存储每个请求的元数据
|
|
23
|
-
|
|
24
|
+
let requestMetadata = new WeakMap();
|
|
24
25
|
|
|
25
26
|
// 用于缓存已打印过的模型重定向规则,避免重复打印
|
|
26
27
|
// 格式: { channelId: { "originalModel": "redirectedModel", ... } }
|
|
@@ -30,7 +31,7 @@ const printedGeminiRedirectCache = new Map();
|
|
|
30
31
|
const jsonBodyParser = express.json({
|
|
31
32
|
limit: '100mb',
|
|
32
33
|
verify: (req, res, buf) => {
|
|
33
|
-
req.rawBody =
|
|
34
|
+
req.rawBody = buf;
|
|
34
35
|
}
|
|
35
36
|
});
|
|
36
37
|
|
|
@@ -198,7 +199,8 @@ async function startGeminiProxyServer(options = {}) {
|
|
|
198
199
|
|
|
199
200
|
const proxy = httpProxy.createProxyServer({});
|
|
200
201
|
|
|
201
|
-
proxy.on('proxyReq', (proxyReq, req) => {
|
|
202
|
+
proxy.on('proxyReq', (proxyReq, req, res) => {
|
|
203
|
+
bindProxyRequest(proxyReq, res);
|
|
202
204
|
const activeChannel = req.selectedChannel;
|
|
203
205
|
if (!activeChannel) return;
|
|
204
206
|
|
|
@@ -255,8 +257,11 @@ async function startGeminiProxyServer(options = {}) {
|
|
|
255
257
|
};
|
|
256
258
|
})();
|
|
257
259
|
|
|
260
|
+
req.__releaseChannel = release;
|
|
261
|
+
res.on('finish', release);
|
|
258
262
|
res.on('close', release);
|
|
259
263
|
res.on('error', release);
|
|
264
|
+
if (res.destroyed || res.writableEnded) { release(); return; }
|
|
260
265
|
|
|
261
266
|
broadcastSchedulerState('gemini', getSchedulerState('gemini'));
|
|
262
267
|
|
|
@@ -335,8 +340,8 @@ async function startGeminiProxyServer(options = {}) {
|
|
|
335
340
|
proxy.web(req, res, {
|
|
336
341
|
target,
|
|
337
342
|
changeOrigin: true,
|
|
338
|
-
proxyTimeout:
|
|
339
|
-
timeout:
|
|
343
|
+
proxyTimeout: PROXY_IDLE_TIMEOUT_MS, // Allow long reasoning gaps without an unbounded upstream wait
|
|
344
|
+
timeout: 0 // The upstream timeout bounds idle waits; streaming clients may pause
|
|
340
345
|
}, (err) => {
|
|
341
346
|
release();
|
|
342
347
|
if (err) {
|
|
@@ -353,6 +358,7 @@ async function startGeminiProxyServer(options = {}) {
|
|
|
353
358
|
}
|
|
354
359
|
});
|
|
355
360
|
} catch (error) {
|
|
361
|
+
req.__releaseChannel?.();
|
|
356
362
|
console.error('Gemini channel allocation error:', error);
|
|
357
363
|
if (!res.headersSent) {
|
|
358
364
|
res.status(503).json({
|
|
@@ -367,6 +373,7 @@ async function startGeminiProxyServer(options = {}) {
|
|
|
367
373
|
|
|
368
374
|
// 代理响应只负责维护传输健康状态;实时日志和 usage 来自 CLI 原生日志。
|
|
369
375
|
proxy.on('proxyRes', (proxyRes, req, res) => {
|
|
376
|
+
prepareProxyResponse(proxyRes, res);
|
|
370
377
|
const metadata = requestMetadata.get(req);
|
|
371
378
|
if (!metadata) return;
|
|
372
379
|
|
|
@@ -403,8 +410,7 @@ async function startGeminiProxyServer(options = {}) {
|
|
|
403
410
|
console.error('Gemini proxy error:', err);
|
|
404
411
|
if (req && req.selectedChannel) {
|
|
405
412
|
recordFailure(req.selectedChannel.id, 'gemini', err);
|
|
406
|
-
|
|
407
|
-
broadcastSchedulerState('gemini', getSchedulerState('gemini'));
|
|
413
|
+
req.__releaseChannel?.();
|
|
408
414
|
}
|
|
409
415
|
if (res && !res.headersSent) {
|
|
410
416
|
res.status(502).json({
|
|
@@ -457,9 +463,9 @@ async function stopGeminiProxyServer(options = {}) {
|
|
|
457
463
|
return { success: true, message: 'Gemini proxy server not running' };
|
|
458
464
|
}
|
|
459
465
|
|
|
460
|
-
requestMetadata
|
|
466
|
+
requestMetadata = new WeakMap();
|
|
461
467
|
|
|
462
|
-
const shutdownTimer = expediteServerShutdown(proxyServer);
|
|
468
|
+
const shutdownTimer = expediteServerShutdown(proxyServer, { forceAfterMs: options.forceAfterMs });
|
|
463
469
|
|
|
464
470
|
return new Promise((resolve) => {
|
|
465
471
|
proxyServer.close(() => {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const crypto = require('crypto');
|
|
4
|
+
const { downstreamHeaders } = require('../../../shared/proxy-transport');
|
|
4
5
|
const http = require('http');
|
|
5
6
|
const https = require('https');
|
|
6
7
|
const httpProxy = require('http-proxy');
|
|
@@ -118,12 +119,20 @@ function readRequestBody(req) {
|
|
|
118
119
|
const chunks = [];
|
|
119
120
|
let size = 0;
|
|
120
121
|
let settled = false;
|
|
122
|
+
const cleanup = () => {
|
|
123
|
+
chunks.length = 0;
|
|
124
|
+
req.removeListener('data', onData);
|
|
125
|
+
req.removeListener('end', onEnd);
|
|
126
|
+
req.removeListener('error', fail);
|
|
127
|
+
req.removeListener('aborted', onAborted);
|
|
128
|
+
};
|
|
121
129
|
const fail = (error) => {
|
|
122
130
|
if (settled) return;
|
|
123
131
|
settled = true;
|
|
132
|
+
cleanup();
|
|
124
133
|
reject(error);
|
|
125
134
|
};
|
|
126
|
-
|
|
135
|
+
const onData = (chunk) => {
|
|
127
136
|
if (settled) return;
|
|
128
137
|
size += chunk.length;
|
|
129
138
|
if (size > MAX_REQUEST_BYTES) {
|
|
@@ -132,14 +141,19 @@ function readRequestBody(req) {
|
|
|
132
141
|
return;
|
|
133
142
|
}
|
|
134
143
|
chunks.push(chunk);
|
|
135
|
-
}
|
|
136
|
-
|
|
144
|
+
};
|
|
145
|
+
const onEnd = () => {
|
|
137
146
|
if (settled) return;
|
|
138
147
|
settled = true;
|
|
139
|
-
|
|
140
|
-
|
|
148
|
+
const body = Buffer.concat(chunks, size);
|
|
149
|
+
cleanup();
|
|
150
|
+
resolve(body);
|
|
151
|
+
};
|
|
152
|
+
const onAborted = () => fail(new Error('Client aborted request'));
|
|
153
|
+
req.on('data', onData);
|
|
154
|
+
req.on('end', onEnd);
|
|
141
155
|
req.on('error', fail);
|
|
142
|
-
req.on('aborted',
|
|
156
|
+
req.on('aborted', onAborted);
|
|
143
157
|
});
|
|
144
158
|
}
|
|
145
159
|
|
|
@@ -184,6 +198,7 @@ function writeJson(res, statusCode, payload) {
|
|
|
184
198
|
res.end(body);
|
|
185
199
|
}
|
|
186
200
|
|
|
201
|
+
|
|
187
202
|
function redactSensitiveQuery(path) {
|
|
188
203
|
const url = new URL(String(path || '/'), 'http://127.0.0.1');
|
|
189
204
|
for (const key of [...url.searchParams.keys()]) {
|
|
@@ -330,8 +345,13 @@ function createOmpGateway(options = {}) {
|
|
|
330
345
|
upstreamResponse = response;
|
|
331
346
|
activeUpstreams.delete(upstreamRequest);
|
|
332
347
|
activeUpstreams.add(response);
|
|
348
|
+
// Install error handling before resolving: discarded retry responses
|
|
349
|
+
// must never raise an unhandled stream error and terminate the daemon.
|
|
350
|
+
response.on('error', () => {});
|
|
333
351
|
response.once('close', () => {
|
|
352
|
+
activeUpstreams.delete(response);
|
|
334
353
|
signal?.removeEventListener('abort', abortUpstream);
|
|
354
|
+
releaseOnce();
|
|
335
355
|
});
|
|
336
356
|
const statusCode = response.statusCode || 502;
|
|
337
357
|
resolve({
|
|
@@ -387,8 +407,7 @@ function createOmpGateway(options = {}) {
|
|
|
387
407
|
try {
|
|
388
408
|
const result = await forwardAttempt(req, route, channel, body, signal);
|
|
389
409
|
if (attempt === 0 && RETRYABLE_STATUS.has(result.statusCode)) {
|
|
390
|
-
result.response.
|
|
391
|
-
result.response.resume();
|
|
410
|
+
result.response.destroy();
|
|
392
411
|
result.release();
|
|
393
412
|
onFailure(channel.id, 'omp');
|
|
394
413
|
lastError = Object.assign(new Error(`OMP upstream returned HTTP ${result.statusCode}`), {
|
|
@@ -397,7 +416,7 @@ function createOmpGateway(options = {}) {
|
|
|
397
416
|
continue;
|
|
398
417
|
}
|
|
399
418
|
|
|
400
|
-
res.writeHead(result.statusCode, result.response.headers);
|
|
419
|
+
res.writeHead(result.statusCode, downstreamHeaders(result.response.headers));
|
|
401
420
|
await new Promise((resolve) => {
|
|
402
421
|
let completed = false;
|
|
403
422
|
const finish = (error) => {
|
|
@@ -413,7 +432,12 @@ function createOmpGateway(options = {}) {
|
|
|
413
432
|
resolve();
|
|
414
433
|
};
|
|
415
434
|
result.response.once('end', () => finish());
|
|
416
|
-
result.response.once('close', () =>
|
|
435
|
+
result.response.once('close', () => {
|
|
436
|
+
if (completed) return;
|
|
437
|
+
const error = new Error('OMP upstream response closed early');
|
|
438
|
+
if (!res.destroyed) res.destroy(error);
|
|
439
|
+
finish(error);
|
|
440
|
+
});
|
|
417
441
|
result.response.once('error', (error) => {
|
|
418
442
|
if (!res.destroyed) res.destroy(error);
|
|
419
443
|
finish(error);
|
|
@@ -640,7 +664,7 @@ function createOmpGateway(options = {}) {
|
|
|
640
664
|
handleRequest(req, res).catch((error) => {
|
|
641
665
|
writeJson(res, 500, { error: error.message || 'OMP gateway request failed' });
|
|
642
666
|
});
|
|
643
|
-
}));
|
|
667
|
+
}), { drainOnExit: false });
|
|
644
668
|
server.on('upgrade', (req, socket, head) => {
|
|
645
669
|
handleUpgrade(req, socket, head).catch((error) => {
|
|
646
670
|
rejectUpgrade(socket, 503, error.message || 'OMP websocket proxy failed');
|
|
@@ -22,7 +22,8 @@ const {
|
|
|
22
22
|
resolveTargetUrl,
|
|
23
23
|
ensureOpenAiStreamUsage
|
|
24
24
|
} = require('../../../shared/proxy-utils');
|
|
25
|
-
const {
|
|
25
|
+
const { bindProxyRequest, prepareProxyResponse } = require('../../../shared/proxy-transport');
|
|
26
|
+
const { attachServerShutdownHandling, expediteServerShutdown, PROXY_IDLE_TIMEOUT_MS } = require('../../../server/services/server-shutdown');
|
|
26
27
|
const { buildCodexTargetUrl, createCodexRequest } = require('../codex/wire');
|
|
27
28
|
const { createClaudeRequest, buildClaudeTargetUrl, buildClaudeCountTokensTargetUrl, buildClaudeCountTokensHeaders } = require('../claude/wire');
|
|
28
29
|
const { createGeminiRequest, buildGeminiTargetUrl, shouldUseGeminiCliFormat } = require('../gemini/wire');
|
|
@@ -32,7 +33,7 @@ let proxyApp = null;
|
|
|
32
33
|
let currentPort = null;
|
|
33
34
|
|
|
34
35
|
// 用于存储每个请求的元数据
|
|
35
|
-
|
|
36
|
+
let requestMetadata = new WeakMap();
|
|
36
37
|
|
|
37
38
|
// 用于缓存已打印过的模型重定向规则,避免重复打印
|
|
38
39
|
// 格式: { channelId: { "originalModel": "redirectedModel", ... } }
|
|
@@ -52,7 +53,7 @@ const resolveOpenCodeTarget = resolveTargetUrl;
|
|
|
52
53
|
const jsonBodyParser = express.json({
|
|
53
54
|
limit: '100mb',
|
|
54
55
|
verify: (req, res, buf) => {
|
|
55
|
-
req.rawBody =
|
|
56
|
+
req.rawBody = buf;
|
|
56
57
|
}
|
|
57
58
|
});
|
|
58
59
|
|
|
@@ -350,7 +351,7 @@ function collectHttpResponseBody(res) {
|
|
|
350
351
|
});
|
|
351
352
|
}
|
|
352
353
|
|
|
353
|
-
function postJson(url, headers, payload, timeoutMs =
|
|
354
|
+
function postJson(url, headers, payload, timeoutMs = PROXY_IDLE_TIMEOUT_MS) {
|
|
354
355
|
return new Promise((resolve, reject) => {
|
|
355
356
|
const target = new URL(url);
|
|
356
357
|
const isHttps = target.protocol === 'https:';
|
|
@@ -388,7 +389,7 @@ function postJson(url, headers, payload, timeoutMs = 120000) {
|
|
|
388
389
|
});
|
|
389
390
|
}
|
|
390
391
|
|
|
391
|
-
function postJsonStream(url, headers, payload, timeoutMs =
|
|
392
|
+
function postJsonStream(url, headers, payload, timeoutMs = PROXY_IDLE_TIMEOUT_MS) {
|
|
392
393
|
return new Promise((resolve, reject) => {
|
|
393
394
|
const target = new URL(url);
|
|
394
395
|
const isHttps = target.protocol === 'https:';
|
|
@@ -3069,7 +3070,8 @@ async function startOpenCodeProxyServer(options = {}) {
|
|
|
3069
3070
|
|
|
3070
3071
|
const proxy = httpProxy.createProxyServer({});
|
|
3071
3072
|
|
|
3072
|
-
proxy.on('proxyReq', (proxyReq, req) => {
|
|
3073
|
+
proxy.on('proxyReq', (proxyReq, req, res) => {
|
|
3074
|
+
bindProxyRequest(proxyReq, res);
|
|
3073
3075
|
const activeChannel = req.selectedChannel;
|
|
3074
3076
|
if (!activeChannel) return;
|
|
3075
3077
|
|
|
@@ -3128,11 +3130,30 @@ async function startOpenCodeProxyServer(options = {}) {
|
|
|
3128
3130
|
const channel = await allocateChannel({ source: 'opencode', enableSessionBinding: false });
|
|
3129
3131
|
req.selectedChannel = channel;
|
|
3130
3132
|
|
|
3133
|
+
const release = (() => {
|
|
3134
|
+
let released = false;
|
|
3135
|
+
return () => {
|
|
3136
|
+
if (released) return;
|
|
3137
|
+
released = true;
|
|
3138
|
+
releaseChannel(channel.id, 'opencode');
|
|
3139
|
+
broadcastSchedulerState('opencode', getSchedulerState('opencode'));
|
|
3140
|
+
};
|
|
3141
|
+
})();
|
|
3142
|
+
|
|
3143
|
+
req.__releaseChannel = release;
|
|
3144
|
+
res.on('finish', release);
|
|
3145
|
+
res.on('close', release);
|
|
3146
|
+
res.on('error', release);
|
|
3147
|
+
|
|
3148
|
+
broadcastSchedulerState('opencode', getSchedulerState('opencode'));
|
|
3149
|
+
|
|
3150
|
+
if (res.destroyed || res.writableEnded) { release(); return; }
|
|
3151
|
+
|
|
3131
3152
|
// 检查 API key 是否有效
|
|
3132
3153
|
const effectiveKey = await getEffectiveApiKey(channel);
|
|
3154
|
+
if (res.destroyed || res.writableEnded) { release(); return; }
|
|
3133
3155
|
if (!effectiveKey) {
|
|
3134
|
-
|
|
3135
|
-
broadcastSchedulerState('opencode', getSchedulerState('opencode'));
|
|
3156
|
+
release();
|
|
3136
3157
|
return res.status(401).json({
|
|
3137
3158
|
error: {
|
|
3138
3159
|
message: 'API key not configured or expired. Please update your channel key.',
|
|
@@ -3195,21 +3216,6 @@ async function startOpenCodeProxyServer(options = {}) {
|
|
|
3195
3216
|
req.rawBody = Buffer.from(JSON.stringify(req.body));
|
|
3196
3217
|
}
|
|
3197
3218
|
|
|
3198
|
-
const release = (() => {
|
|
3199
|
-
let released = false;
|
|
3200
|
-
return () => {
|
|
3201
|
-
if (released) return;
|
|
3202
|
-
released = true;
|
|
3203
|
-
releaseChannel(channel.id, 'opencode');
|
|
3204
|
-
broadcastSchedulerState('opencode', getSchedulerState('opencode'));
|
|
3205
|
-
};
|
|
3206
|
-
})();
|
|
3207
|
-
|
|
3208
|
-
res.on('close', release);
|
|
3209
|
-
res.on('error', release);
|
|
3210
|
-
|
|
3211
|
-
broadcastSchedulerState('opencode', getSchedulerState('opencode'));
|
|
3212
|
-
|
|
3213
3219
|
const gatewaySourceType = normalizeGatewaySourceType(channel);
|
|
3214
3220
|
if (gatewaySourceType === 'codex') {
|
|
3215
3221
|
const handled = await handleCodexGatewayRequest(req, res, channel, effectiveKey);
|
|
@@ -3235,8 +3241,8 @@ async function startOpenCodeProxyServer(options = {}) {
|
|
|
3235
3241
|
proxy.web(req, res, {
|
|
3236
3242
|
target,
|
|
3237
3243
|
changeOrigin: true,
|
|
3238
|
-
proxyTimeout:
|
|
3239
|
-
timeout:
|
|
3244
|
+
proxyTimeout: PROXY_IDLE_TIMEOUT_MS, // Allow long reasoning gaps without an unbounded upstream wait
|
|
3245
|
+
timeout: 0 // The upstream timeout bounds idle waits; streaming clients may pause
|
|
3240
3246
|
}, (err) => {
|
|
3241
3247
|
release();
|
|
3242
3248
|
if (err) {
|
|
@@ -3253,6 +3259,7 @@ async function startOpenCodeProxyServer(options = {}) {
|
|
|
3253
3259
|
}
|
|
3254
3260
|
});
|
|
3255
3261
|
} catch (error) {
|
|
3262
|
+
req.__releaseChannel?.();
|
|
3256
3263
|
console.error('OpenCode channel allocation error:', error);
|
|
3257
3264
|
if (!res.headersSent) {
|
|
3258
3265
|
res.status(503).json({
|
|
@@ -3267,6 +3274,7 @@ async function startOpenCodeProxyServer(options = {}) {
|
|
|
3267
3274
|
|
|
3268
3275
|
// 代理响应只负责维护传输健康状态;实时日志和 usage 来自 CLI 原生日志。
|
|
3269
3276
|
proxy.on('proxyRes', (proxyRes, req, res) => {
|
|
3277
|
+
prepareProxyResponse(proxyRes, res);
|
|
3270
3278
|
const metadata = requestMetadata.get(req);
|
|
3271
3279
|
if (!metadata) return;
|
|
3272
3280
|
|
|
@@ -3303,8 +3311,7 @@ async function startOpenCodeProxyServer(options = {}) {
|
|
|
3303
3311
|
console.error('OpenCode proxy error:', err);
|
|
3304
3312
|
if (req && req.selectedChannel) {
|
|
3305
3313
|
recordFailure(req.selectedChannel.id, 'opencode', err);
|
|
3306
|
-
|
|
3307
|
-
broadcastSchedulerState('opencode', getSchedulerState('opencode'));
|
|
3314
|
+
req.__releaseChannel?.();
|
|
3308
3315
|
}
|
|
3309
3316
|
if (res && !res.headersSent) {
|
|
3310
3317
|
res.status(502).json({
|
|
@@ -3359,9 +3366,9 @@ async function stopOpenCodeProxyServer(options = {}) {
|
|
|
3359
3366
|
return { success: true, message: 'OpenCode proxy server not running' };
|
|
3360
3367
|
}
|
|
3361
3368
|
|
|
3362
|
-
requestMetadata
|
|
3369
|
+
requestMetadata = new WeakMap();
|
|
3363
3370
|
|
|
3364
|
-
const shutdownTimer = expediteServerShutdown(proxyServer);
|
|
3371
|
+
const shutdownTimer = expediteServerShutdown(proxyServer, { forceAfterMs: options.forceAfterMs });
|
|
3365
3372
|
|
|
3366
3373
|
return new Promise((resolve) => {
|
|
3367
3374
|
proxyServer.close(() => {
|
package/src/server/index.js
CHANGED
|
@@ -24,6 +24,7 @@ const {
|
|
|
24
24
|
createSameOriginGuard
|
|
25
25
|
} = require('./services/network-access');
|
|
26
26
|
const { createApiRequestLogger } = require('./services/request-logger');
|
|
27
|
+
const { startMemoryReclaimer, trackMemoryActivity } = require('./services/memory-reclaimer');
|
|
27
28
|
const { inspectWebBuildState, ensureWebDistReady } = require('./services/web-build');
|
|
28
29
|
const { ensureHttpsCredentials } = require('./services/https-cert');
|
|
29
30
|
const { scheduleSessionHistoryMaintenance } = require('./services/session-history-maintenance');
|
|
@@ -197,6 +198,7 @@ async function startServer(port, host = '127.0.0.1', options = {}) {
|
|
|
197
198
|
const allowRemoteMutation = isRemoteMutationAllowedByEnv(process.env);
|
|
198
199
|
|
|
199
200
|
// Middleware
|
|
201
|
+
app.use(trackMemoryActivity);
|
|
200
202
|
app.use(express.json({ limit: '100mb' }));
|
|
201
203
|
app.use(express.urlencoded({ limit: '100mb', extended: true }));
|
|
202
204
|
|
|
@@ -368,6 +370,7 @@ async function startServer(port, host = '127.0.0.1', options = {}) {
|
|
|
368
370
|
traceStartupMemory('proxy-restore-complete');
|
|
369
371
|
await performStartupHealthCheck({ config });
|
|
370
372
|
traceStartupMemory('health-check-complete');
|
|
373
|
+
startMemoryReclaimer();
|
|
371
374
|
return server;
|
|
372
375
|
} finally {
|
|
373
376
|
try {
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const MIB = 1024 * 1024;
|
|
4
|
+
const CHECK_INTERVAL_MS = 5_000;
|
|
5
|
+
const MIN_IDLE_MS = 30_000;
|
|
6
|
+
const GC_COOLDOWN_MS = 5 * 60_000;
|
|
7
|
+
const RSS_PRESSURE_BYTES = 380 * MIB;
|
|
8
|
+
const HEAP_PRESSURE_BYTES = 256 * MIB;
|
|
9
|
+
const URGENT_RSS_BYTES = 450 * MIB;
|
|
10
|
+
const URGENT_GC_COOLDOWN_MS = 30_000;
|
|
11
|
+
|
|
12
|
+
let timer = null;
|
|
13
|
+
let activeRequests = 0;
|
|
14
|
+
let lastActivityAt = Date.now();
|
|
15
|
+
let lastCollectionAt = 0;
|
|
16
|
+
|
|
17
|
+
function trackMemoryActivity(_req, res, next) {
|
|
18
|
+
if (typeof global.gc !== 'function') {
|
|
19
|
+
if (typeof next === 'function') next();
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
activeRequests += 1;
|
|
24
|
+
lastActivityAt = Date.now();
|
|
25
|
+
let completed = false;
|
|
26
|
+
const complete = () => {
|
|
27
|
+
if (completed) return;
|
|
28
|
+
completed = true;
|
|
29
|
+
activeRequests = Math.max(0, activeRequests - 1);
|
|
30
|
+
lastActivityAt = Date.now();
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// HTTP completion ends a request; upgraded sockets stay active until close.
|
|
34
|
+
if (typeof res.writeHead === 'function') res.once('finish', complete);
|
|
35
|
+
res.once('close', complete);
|
|
36
|
+
if (typeof next === 'function') next();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function collectIfIdleAndUnderPressure() {
|
|
40
|
+
if (activeRequests > 0) return;
|
|
41
|
+
|
|
42
|
+
const before = process.memoryUsage();
|
|
43
|
+
const urgent = before.rss >= URGENT_RSS_BYTES;
|
|
44
|
+
if (!urgent && Date.now() - lastActivityAt < MIN_IDLE_MS) return;
|
|
45
|
+
if (before.rss < RSS_PRESSURE_BYTES && before.heapUsed < HEAP_PRESSURE_BYTES) return;
|
|
46
|
+
|
|
47
|
+
const now = Date.now();
|
|
48
|
+
const cooldown = urgent ? URGENT_GC_COOLDOWN_MS : GC_COOLDOWN_MS;
|
|
49
|
+
if (now - lastCollectionAt < cooldown) return;
|
|
50
|
+
lastCollectionAt = now;
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
global.gc();
|
|
54
|
+
if (process.env.CC_TOOL_MEMORY_TRACE === '1') {
|
|
55
|
+
const after = process.memoryUsage();
|
|
56
|
+
console.log(`[MEM] idle-gc ${JSON.stringify({
|
|
57
|
+
pid: process.pid,
|
|
58
|
+
urgent,
|
|
59
|
+
rssBefore: before.rss,
|
|
60
|
+
rssAfter: after.rss,
|
|
61
|
+
heapUsedBefore: before.heapUsed,
|
|
62
|
+
heapUsedAfter: after.heapUsed
|
|
63
|
+
})}`);
|
|
64
|
+
}
|
|
65
|
+
} catch (error) {
|
|
66
|
+
console.warn(`[MEM] Idle memory collection failed: ${error.message}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function startMemoryReclaimer() {
|
|
71
|
+
if (timer || typeof global.gc !== 'function') return false;
|
|
72
|
+
timer = setInterval(collectIfIdleAndUnderPressure, CHECK_INTERVAL_MS);
|
|
73
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function stopMemoryReclaimer() {
|
|
78
|
+
if (timer) clearInterval(timer);
|
|
79
|
+
timer = null;
|
|
80
|
+
activeRequests = 0;
|
|
81
|
+
lastActivityAt = Date.now();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = {
|
|
85
|
+
startMemoryReclaimer,
|
|
86
|
+
stopMemoryReclaimer,
|
|
87
|
+
trackMemoryActivity
|
|
88
|
+
};
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
const SOCKET_TRACKER = Symbol('ccTool.socketTracker');
|
|
2
|
+
const { trackMemoryActivity } = require('./memory-reclaimer');
|
|
3
|
+
const proxyServers = new Set();
|
|
4
|
+
const PROXY_IDLE_TIMEOUT_MS = 10 * 60_000;
|
|
2
5
|
|
|
3
6
|
function attachServerShutdownHandling(server, options = {}) {
|
|
4
7
|
if (!server || server[SOCKET_TRACKER]) {
|
|
@@ -6,6 +9,10 @@ function attachServerShutdownHandling(server, options = {}) {
|
|
|
6
9
|
}
|
|
7
10
|
|
|
8
11
|
const sockets = new Set();
|
|
12
|
+
// Proxy servers share the process with the UI; streaming requests must also
|
|
13
|
+
// prevent idle memory collection until their response finishes or closes.
|
|
14
|
+
server.prependListener('request', trackMemoryActivity);
|
|
15
|
+
server.prependListener('upgrade', (req, socket) => trackMemoryActivity(req, socket));
|
|
9
16
|
server.on('connection', (socket) => {
|
|
10
17
|
sockets.add(socket);
|
|
11
18
|
socket.on('close', () => {
|
|
@@ -15,17 +22,33 @@ function attachServerShutdownHandling(server, options = {}) {
|
|
|
15
22
|
|
|
16
23
|
const keepAliveTimeout = Number.isFinite(options.keepAliveTimeout)
|
|
17
24
|
? Math.max(0, options.keepAliveTimeout)
|
|
18
|
-
:
|
|
25
|
+
: 5000;
|
|
19
26
|
const headersTimeout = Number.isFinite(options.headersTimeout)
|
|
20
27
|
? Math.max(keepAliveTimeout + 1000, options.headersTimeout)
|
|
21
|
-
: keepAliveTimeout + 1000;
|
|
28
|
+
: Math.max(60_000, keepAliveTimeout + 1000);
|
|
22
29
|
|
|
23
30
|
server.keepAliveTimeout = keepAliveTimeout;
|
|
24
31
|
server.headersTimeout = headersTimeout;
|
|
25
32
|
server[SOCKET_TRACKER] = { sockets };
|
|
33
|
+
if (options.drainOnExit !== false) {
|
|
34
|
+
if (server.listening) proxyServers.add(server);
|
|
35
|
+
else server.once('listening', () => proxyServers.add(server));
|
|
36
|
+
server.once('close', () => proxyServers.delete(server));
|
|
37
|
+
}
|
|
26
38
|
return server;
|
|
27
39
|
}
|
|
28
40
|
|
|
41
|
+
async function drainProxyServers(options = {}) {
|
|
42
|
+
await Promise.all([...proxyServers].map(server => new Promise((resolve, reject) => {
|
|
43
|
+
const timer = expediteServerShutdown(server, { forceAfterMs: options.forceAfterMs ?? 120_000 });
|
|
44
|
+
server.close(error => {
|
|
45
|
+
if (timer) clearTimeout(timer);
|
|
46
|
+
if (error && error.code !== 'ERR_SERVER_NOT_RUNNING') reject(error);
|
|
47
|
+
else resolve();
|
|
48
|
+
});
|
|
49
|
+
})));
|
|
50
|
+
}
|
|
51
|
+
|
|
29
52
|
function expediteServerShutdown(server, options = {}) {
|
|
30
53
|
if (!server) {
|
|
31
54
|
return null;
|
|
@@ -75,5 +98,7 @@ function expediteServerShutdown(server, options = {}) {
|
|
|
75
98
|
|
|
76
99
|
module.exports = {
|
|
77
100
|
attachServerShutdownHandling,
|
|
78
|
-
expediteServerShutdown
|
|
101
|
+
expediteServerShutdown,
|
|
102
|
+
drainProxyServers,
|
|
103
|
+
PROXY_IDLE_TIMEOUT_MS
|
|
79
104
|
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Connection headers describe one transport hop, not the downstream socket.
|
|
4
|
+
function downstreamHeaders(headers = {}) {
|
|
5
|
+
const result = { ...headers };
|
|
6
|
+
const connectionHeaders = String(headers.connection || '').split(',').map(name => name.trim().toLowerCase());
|
|
7
|
+
for (const name of ['connection', 'keep-alive', 'proxy-connection', 'proxy-authenticate',
|
|
8
|
+
'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade', ...connectionHeaders]) {
|
|
9
|
+
delete result[name];
|
|
10
|
+
}
|
|
11
|
+
return result;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function bindProxyRequest(proxyReq, res) {
|
|
15
|
+
if (!res) return;
|
|
16
|
+
const abort = () => {
|
|
17
|
+
if (!res.writableEnded && typeof proxyReq.destroy === 'function') proxyReq.destroy();
|
|
18
|
+
};
|
|
19
|
+
res.once('close', abort);
|
|
20
|
+
proxyReq.once('close', () => res.removeListener('close', abort));
|
|
21
|
+
if (res.destroyed) abort();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function prepareProxyResponse(proxyRes, res) {
|
|
25
|
+
proxyRes.headers = downstreamHeaders(proxyRes.headers);
|
|
26
|
+
const fail = error => {
|
|
27
|
+
if (res && !res.destroyed) res.destroy(error);
|
|
28
|
+
};
|
|
29
|
+
proxyRes.once('error', fail);
|
|
30
|
+
proxyRes.once('aborted', () => fail(new Error('Upstream response aborted')));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = { downstreamHeaders, bindProxyRequest, prepareProxyResponse };
|