coding-tool-x 3.9.5-261009 → 3.9.5-261013
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 +53 -27
- package/src/commands/proxy-control.js +14 -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
|
@@ -12,7 +12,8 @@ const {
|
|
|
12
12
|
formatPortToolIssue
|
|
13
13
|
} = require('../utils/port-helper');
|
|
14
14
|
const { hasHostFlag } = require('../utils/cli-flags');
|
|
15
|
-
const { getPlatformRegistry
|
|
15
|
+
const { getPlatformRegistry } = require('../platforms/runtime');
|
|
16
|
+
const { createProxyControl } = require('./proxy-control');
|
|
16
17
|
const PM2_APP_NAME = 'cc-tool';
|
|
17
18
|
const STARTUP_LOG_FILE = 'cc-tool-out.log';
|
|
18
19
|
const PM2_RUNTIME_ENV_KEYS = [
|
|
@@ -279,34 +280,48 @@ function getPlatformPort(platform, config = {}) {
|
|
|
279
280
|
return configured || platform.defaultPort || null;
|
|
280
281
|
}
|
|
281
282
|
|
|
282
|
-
|
|
283
|
-
const
|
|
284
|
-
|
|
283
|
+
function getUIConnectionOptions(existing, config = loadConfig()) {
|
|
284
|
+
const env = existing?.pm2_env || {};
|
|
285
|
+
const port = Number(env.CC_TOOL_PORT || env.env?.CC_TOOL_PORT || config.ports?.webUI || 19999);
|
|
286
|
+
const args = Array.isArray(env.args) ? env.args : String(env.args || '').split(/\s+/);
|
|
287
|
+
return { port, useHttps: args.includes('--https') };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function getProxyStatusEntries({ registry = getPlatformRegistry(), config = loadConfig(), httpRequest, connection } = {}) {
|
|
291
|
+
// Servers live in the UI daemon. Loading a driver here creates a separate
|
|
292
|
+
// singleton in the CLI process whose status will always be stopped.
|
|
293
|
+
const control = createProxyControl({ registry, loadConfig: () => config, httpRequest, connection });
|
|
294
|
+
return Promise.all(listProxyPlatforms(registry).map(async (platform) => {
|
|
285
295
|
const context = {
|
|
286
296
|
platform: platform.key,
|
|
287
297
|
capability: 'proxy',
|
|
288
298
|
operation: 'status'
|
|
289
299
|
};
|
|
290
300
|
try {
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
: null;
|
|
294
|
-
if (!driver || (typeof driver.status === 'string' && driver.status !== 'ok')) {
|
|
295
|
-
entries.push({ platform, state: { status: 'unsupported', ...context }, port: getPlatformPort(platform, config) });
|
|
296
|
-
continue;
|
|
301
|
+
if (platform.capabilities?.proxy === 'unsupported' || platform.capabilities?.proxy === false) {
|
|
302
|
+
return { platform, state: { status: 'unsupported', ...context }, port: getPlatformPort(platform, config) };
|
|
297
303
|
}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
304
|
+
const { response } = await control.status(platform.key);
|
|
305
|
+
const result = response.data;
|
|
306
|
+
if (!Number.isInteger(response.status) || response.status < 200 || response.status >= 300) {
|
|
307
|
+
const failure = result?.error;
|
|
308
|
+
if (failure && typeof failure === 'object') {
|
|
309
|
+
return { platform, state: { ...failure, status: failure.status && failure.status !== 'ok' ? failure.status : 'failed', ...context }, port: getPlatformPort(platform, config) };
|
|
310
|
+
}
|
|
311
|
+
throw new Error(typeof failure === 'string' ? failure : `HTTP ${response.status}`);
|
|
301
312
|
}
|
|
302
|
-
|
|
313
|
+
// The API normally unwraps the Driver result. Accept wrapped payloads
|
|
314
|
+
// too, but never interpret an invalid response as a stopped proxy.
|
|
315
|
+
const state = result && result.status === 'ok'
|
|
316
|
+
? (result.data && typeof result.data === 'object' ? result.data : {})
|
|
317
|
+
: result;
|
|
303
318
|
if (state && typeof state.status === 'string' && state.status !== 'ok') {
|
|
304
|
-
|
|
305
|
-
} else {
|
|
306
|
-
entries.push({ platform, state: state || {}, port: getPlatformPort(platform, config) });
|
|
319
|
+
return { platform, state: { ...state, ...context }, port: getPlatformPort(platform, config) };
|
|
307
320
|
}
|
|
321
|
+
if (!state || typeof state.running !== 'boolean') throw new Error('Invalid proxy status response');
|
|
322
|
+
return { platform, state, port: getPlatformPort(platform, config) };
|
|
308
323
|
} catch (error) {
|
|
309
|
-
|
|
324
|
+
return {
|
|
310
325
|
platform,
|
|
311
326
|
state: {
|
|
312
327
|
status: 'failed',
|
|
@@ -314,10 +329,9 @@ async function getProxyStatusEntries({ registry = getPlatformRegistry(), runtime
|
|
|
314
329
|
error: error && error.message ? error.message : String(error)
|
|
315
330
|
},
|
|
316
331
|
port: getPlatformPort(platform, config)
|
|
317
|
-
}
|
|
332
|
+
};
|
|
318
333
|
}
|
|
319
|
-
}
|
|
320
|
-
return entries;
|
|
334
|
+
}));
|
|
321
335
|
}
|
|
322
336
|
|
|
323
337
|
function getManagedPorts(config = loadConfig(), registry = getPlatformRegistry()) {
|
|
@@ -508,8 +522,10 @@ function buildStartOptions(port, enableHost, enableHttps) {
|
|
|
508
522
|
interpreter: 'node',
|
|
509
523
|
windowsHide: true,
|
|
510
524
|
autorestart: true,
|
|
511
|
-
|
|
525
|
+
// Let active OMP streams drain before PM2 forcibly terminates the process.
|
|
526
|
+
kill_timeout: 125000,
|
|
512
527
|
max_memory_restart: '500M',
|
|
528
|
+
node_args: '--expose-gc',
|
|
513
529
|
env,
|
|
514
530
|
output: path.join(PATHS.logs, STARTUP_LOG_FILE),
|
|
515
531
|
error: path.join(PATHS.logs, STARTUP_LOG_FILE),
|
|
@@ -731,6 +747,12 @@ async function handleStatus() {
|
|
|
731
747
|
|
|
732
748
|
const existing = await getCCToolProcess();
|
|
733
749
|
const config = loadConfig();
|
|
750
|
+
const connection = getUIConnectionOptions(existing, config);
|
|
751
|
+
const protocol = connection.useHttps ? 'https' : 'http';
|
|
752
|
+
if (connection.useHttps && PATHS.config) {
|
|
753
|
+
const certPath = path.join(PATHS.config, 'https', 'localhost-cert.pem');
|
|
754
|
+
if (fs.existsSync(certPath)) connection.ca = fs.readFileSync(certPath);
|
|
755
|
+
}
|
|
734
756
|
|
|
735
757
|
console.log(chalk.bold.cyan('\n╔======================================╗'));
|
|
736
758
|
console.log(chalk.bold.cyan('║ Coding-Tool 服务状态 ║'));
|
|
@@ -740,7 +762,7 @@ async function handleStatus() {
|
|
|
740
762
|
console.log(chalk.bold('[UI] Web UI 服务:'));
|
|
741
763
|
if (existing && existing.pm2_env.status === 'online') {
|
|
742
764
|
console.log(chalk.green(' [OK] 状态: 运行中'));
|
|
743
|
-
console.log(chalk.gray(` [NET] 地址:
|
|
765
|
+
console.log(chalk.gray(` [NET] 地址: ${protocol}://localhost:${connection.port}`));
|
|
744
766
|
console.log(chalk.gray(` [KEY] 进程 ID: ${existing.pid}`));
|
|
745
767
|
console.log(chalk.gray(` [TIMER] 运行时长: ${formatUptime(existing.pm2_env.pm_uptime)}`));
|
|
746
768
|
console.log(chalk.gray(` [SAVE] 内存使用: ${formatMemory(existing.monit?.memory)}`));
|
|
@@ -749,8 +771,11 @@ async function handleStatus() {
|
|
|
749
771
|
console.log(chalk.gray(' [ERROR] 状态: 未运行'));
|
|
750
772
|
}
|
|
751
773
|
|
|
752
|
-
//
|
|
753
|
-
const proxyStatuses = await getProxyStatusEntries({
|
|
774
|
+
// Query the drivers in the running backend through manifest-declared APIs.
|
|
775
|
+
const proxyStatuses = await getProxyStatusEntries({
|
|
776
|
+
config: { ...config, ports: { ...config.ports, webUI: connection.port } },
|
|
777
|
+
connection
|
|
778
|
+
});
|
|
754
779
|
|
|
755
780
|
console.log(chalk.bold('\n[PROXY] 代理服务:'));
|
|
756
781
|
for (const entry of proxyStatuses) {
|
|
@@ -761,9 +786,9 @@ async function handleStatus() {
|
|
|
761
786
|
console.log(chalk.gray(` ${label}${chalk.gray('[N/A] 不支持')}`));
|
|
762
787
|
continue;
|
|
763
788
|
}
|
|
764
|
-
if (state.status
|
|
789
|
+
if (state.status && state.status !== 'ok') {
|
|
765
790
|
console.log(chalk.red(` ${label}${chalk.red('[ERROR] 查询失败')}`) +
|
|
766
|
-
chalk.gray(` (${state.error || 'unknown error'})`));
|
|
791
|
+
chalk.gray(` (${state.error || state.status || 'unknown error'})`));
|
|
767
792
|
continue;
|
|
768
793
|
}
|
|
769
794
|
console.log(chalk.gray(` ${label}`) + (state.running
|
|
@@ -827,6 +852,7 @@ module.exports = {
|
|
|
827
852
|
shouldTreatPortOwnershipAsReady,
|
|
828
853
|
getManagedPorts,
|
|
829
854
|
getProxyStatusEntries,
|
|
855
|
+
getUIConnectionOptions,
|
|
830
856
|
listProxyPlatforms,
|
|
831
857
|
shouldStopPM2Process
|
|
832
858
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const chalk = require('chalk');
|
|
2
2
|
const http = require('http');
|
|
3
|
+
const https = require('https');
|
|
3
4
|
const { loadConfig } = require('../config/loader');
|
|
4
5
|
const { normalizePlatformKey } = require('../shared/platforms');
|
|
5
6
|
|
|
@@ -38,7 +39,7 @@ function getPlatformInfo(channel, registry = getDefaultRegistry()) {
|
|
|
38
39
|
};
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
function createHttpRequest(loadConfigImpl = loadConfig) {
|
|
42
|
+
function createHttpRequest(loadConfigImpl = loadConfig, { useHttps = false, ca } = {}) {
|
|
42
43
|
return function request(method, requestPath, data = null) {
|
|
43
44
|
const config = loadConfigImpl();
|
|
44
45
|
const port = config.ports?.webUI || 19999;
|
|
@@ -56,10 +57,19 @@ function createHttpRequest(loadConfigImpl = loadConfig) {
|
|
|
56
57
|
},
|
|
57
58
|
timeout: 5000
|
|
58
59
|
};
|
|
60
|
+
if (useHttps) {
|
|
61
|
+
// This client only connects to localhost. Trust the daemon's generated
|
|
62
|
+
// certificate when available (Windows may use a PFX instead of PEM).
|
|
63
|
+
options.ca = ca;
|
|
64
|
+
options.rejectUnauthorized = Boolean(ca);
|
|
65
|
+
}
|
|
59
66
|
|
|
60
|
-
const req = http.request(options, (res) => {
|
|
67
|
+
const req = (useHttps ? https : http).request(options, (res) => {
|
|
61
68
|
let responseData = '';
|
|
62
69
|
|
|
70
|
+
res.on('error', reject);
|
|
71
|
+
res.on('aborted', () => reject(new Error('Response connection closed unexpectedly')));
|
|
72
|
+
|
|
63
73
|
res.on('data', (chunk) => {
|
|
64
74
|
responseData += chunk;
|
|
65
75
|
});
|
|
@@ -86,8 +96,8 @@ function createHttpRequest(loadConfigImpl = loadConfig) {
|
|
|
86
96
|
};
|
|
87
97
|
}
|
|
88
98
|
|
|
89
|
-
function createProxyControl({ registry = getDefaultRegistry(), httpRequest: request, loadConfig: loadConfigImpl, sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)) } = {}) {
|
|
90
|
-
const requestImpl = request || createHttpRequest(loadConfigImpl || loadConfig);
|
|
99
|
+
function createProxyControl({ registry = getDefaultRegistry(), httpRequest: request, loadConfig: loadConfigImpl, connection, sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)) } = {}) {
|
|
100
|
+
const requestImpl = request || createHttpRequest(loadConfigImpl || loadConfig, connection);
|
|
91
101
|
|
|
92
102
|
function resolve(channel) {
|
|
93
103
|
const info = getPlatformInfo(channel, registry);
|
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(() => {
|