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
|
@@ -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
|
};
|