coding-tool-x 3.9.4 → 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/config/model-metadata.json +153 -10
- 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/api-operations.js +46 -43
- 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/api/mcp.js +2 -2
- package/src/server/index.js +3 -0
- package/src/server/services/mcp-service.js +2 -19
- package/src/server/services/memory-reclaimer.js +88 -0
- package/src/server/services/server-shutdown.js +28 -3
- package/src/shared/driver-factories/mcp.js +2 -8
- package/src/shared/mcp-import.js +107 -0
- package/src/shared/proxy-transport.js +33 -0
|
@@ -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(() => {
|
|
@@ -9,6 +9,7 @@ const { installPlugin, uninstallPlugin, updatePlugin } = require('./plugin-manag
|
|
|
9
9
|
const { createDriver: createChannelsDriver } = require('./channels');
|
|
10
10
|
const { createDriver: createProxyDriver } = require('./proxy');
|
|
11
11
|
const { createDriver: createStatisticsDriver } = require('./statistics');
|
|
12
|
+
const { importMcpServer } = require('../../../shared/mcp-import');
|
|
12
13
|
|
|
13
14
|
function requiredId(value, label) {
|
|
14
15
|
const id = String(value || '').trim();
|
|
@@ -153,18 +154,34 @@ function dshMcpSpecToConfig(spec = {}, serverName) {
|
|
|
153
154
|
return config;
|
|
154
155
|
}
|
|
155
156
|
|
|
156
|
-
function
|
|
157
|
+
function dshMcpServerName(server, fallbackId) {
|
|
158
|
+
const requested = String(server?.name || '').trim();
|
|
159
|
+
if (/^[A-Za-z0-9_-]{1,32}$/.test(requested)) return requested;
|
|
160
|
+
return String(fallbackId || '')
|
|
161
|
+
.replace(/[^A-Za-z0-9_-]/g, '_')
|
|
162
|
+
.slice(0, 32) || 'mcp';
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function readDshMcpRecords(context = {}) {
|
|
157
166
|
const profile = resolveProfileName(context);
|
|
158
|
-
if (!profile) return
|
|
167
|
+
if (!profile) return [];
|
|
159
168
|
const result = listProfileMcp(context, profile);
|
|
160
|
-
const entries =
|
|
169
|
+
const entries = [];
|
|
161
170
|
for (const server of result?.servers || []) {
|
|
162
171
|
if (!server?.id || server.disabled) continue;
|
|
163
|
-
entries
|
|
172
|
+
entries.push({
|
|
173
|
+
id: server.id,
|
|
174
|
+
name: server.config?.serverName || server.id,
|
|
175
|
+
spec: dshMcpConfigToSpec(server.config || {})
|
|
176
|
+
});
|
|
164
177
|
}
|
|
165
178
|
return entries;
|
|
166
179
|
}
|
|
167
180
|
|
|
181
|
+
function readDshMcpEntries(context = {}) {
|
|
182
|
+
return Object.fromEntries(readDshMcpRecords(context).map(({ id, spec }) => [id, spec]));
|
|
183
|
+
}
|
|
184
|
+
|
|
168
185
|
function promptContentFromDshConfig(config = {}) {
|
|
169
186
|
for (const key of ['content', 'prompt', 'text']) {
|
|
170
187
|
if (typeof config[key] === 'string') return config[key];
|
|
@@ -359,47 +376,33 @@ function createDriver(context = {}) {
|
|
|
359
376
|
driver.read = () => resultFor(mcpContext('read'), 'ok', { mcpServers: readDshMcpEntries(context) });
|
|
360
377
|
driver.normalize = spec => resultFor(mcpContext('normalize'), 'ok', spec);
|
|
361
378
|
driver.sync = async server => {
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
379
|
+
const profileName = profileNameForRequest(context, server);
|
|
380
|
+
const id = String(server?.id || '').trim();
|
|
381
|
+
if (!id) throw new Error('MCP server id is required');
|
|
382
|
+
const patch = requirePatchResult(
|
|
383
|
+
upsertProfilePatchRow(context, profileName, {
|
|
384
|
+
id,
|
|
385
|
+
name: '@deepseek-ai/dsh-mcp-client',
|
|
386
|
+
config: dshMcpSpecToConfig(server.server || {}, dshMcpServerName(server, id))
|
|
387
|
+
}),
|
|
388
|
+
profileName
|
|
389
|
+
);
|
|
390
|
+
return resultFor(mcpContext('sync'), 'ok', patch);
|
|
391
|
+
};
|
|
392
|
+
driver.remove = async serverId => {
|
|
393
|
+
const profileName = profileNameForRequest(context);
|
|
394
|
+
const id = String(serverId || '').trim();
|
|
395
|
+
if (!id) throw new Error('MCP server id is required');
|
|
396
|
+
if (!profileName) return resultFor(mcpContext('remove'), 'ok', true);
|
|
397
|
+
const patch = requirePatchResult(deleteProfilePatchRow(context, profileName, id), profileName);
|
|
398
|
+
return resultFor(mcpContext('remove'), 'ok', patch);
|
|
399
|
+
};
|
|
383
400
|
driver.import = async servers => {
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
if (servers[id]) {
|
|
388
|
-
servers[id].apps = { ...(servers[id].apps || {}), dsh: true };
|
|
389
|
-
continue;
|
|
401
|
+
let count = 0;
|
|
402
|
+
for (const entry of readDshMcpRecords(context)) {
|
|
403
|
+
if (importMcpServer(servers, { ...entry, platform: 'dsh' })) count++;
|
|
390
404
|
}
|
|
391
|
-
|
|
392
|
-
servers[id] = {
|
|
393
|
-
id,
|
|
394
|
-
name: id,
|
|
395
|
-
server: spec,
|
|
396
|
-
apps: { dsh: true },
|
|
397
|
-
createdAt: now,
|
|
398
|
-
updatedAt: now
|
|
399
|
-
};
|
|
400
|
-
count++;
|
|
401
|
-
}
|
|
402
|
-
return resultFor(mcpContext('import'), 'ok', count);
|
|
405
|
+
return resultFor(mcpContext('import'), 'ok', count);
|
|
403
406
|
};
|
|
404
407
|
}
|
|
405
408
|
|
|
@@ -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');
|