coding-tool-x 3.3.8 → 3.4.0
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 +17 -2
- package/README.md +253 -326
- package/dist/web/assets/{Analytics-DLpoDZ2M.js → Analytics-DEjfL5Jx.js} +4 -4
- package/dist/web/assets/Analytics-RNn1BUbG.css +1 -0
- package/dist/web/assets/{ConfigTemplates-D_hRb55W.js → ConfigTemplates-DkRL_-tf.js} +1 -1
- package/dist/web/assets/Home-BQxQ1LhR.css +1 -0
- package/dist/web/assets/Home-CF-L640I.js +1 -0
- package/dist/web/assets/{PluginManager-JXsyym1s.js → PluginManager-BzNYTdNB.js} +1 -1
- package/dist/web/assets/{ProjectList-DZWSeb-q.js → ProjectList-C0-JgHMM.js} +1 -1
- package/dist/web/assets/{SessionList-Cs624DR3.js → SessionList-CkZUdX5N.js} +1 -1
- package/dist/web/assets/{SkillManager-bEliz7qz.js → SkillManager-Cak0-4d4.js} +1 -1
- package/dist/web/assets/{WorkspaceManager-J3RecFGn.js → WorkspaceManager-CGDJzwEr.js} +1 -1
- package/dist/web/assets/{icons-Cuc23WS7.js → icons-B5Pl4lrD.js} +1 -1
- package/dist/web/assets/index-D_WItvHE.js +2 -0
- package/dist/web/assets/index-Dz7v9OM0.css +1 -0
- package/dist/web/assets/{markdown-C9MYpaSi.js → markdown-DyTJGI4N.js} +1 -1
- package/dist/web/assets/{naive-ui-CxpuzdjU.js → naive-ui-Bdxp09n2.js} +1 -1
- package/dist/web/assets/{vendors-DMjSfzlv.js → vendors-CKPV1OAU.js} +2 -2
- package/dist/web/assets/{vue-vendor-DET08QYg.js → vue-vendor-3bf-fPGP.js} +1 -1
- package/dist/web/index.html +7 -7
- package/docs/home.png +0 -0
- package/package.json +13 -5
- package/src/commands/daemon.js +3 -2
- package/src/commands/security.js +1 -2
- package/src/config/paths.js +638 -93
- package/src/server/api/agents.js +1 -1
- package/src/server/api/claude-hooks.js +13 -8
- package/src/server/api/codex-proxy.js +5 -4
- package/src/server/api/hooks.js +45 -0
- package/src/server/api/plugins.js +0 -1
- package/src/server/api/statistics.js +4 -4
- package/src/server/api/ui-config.js +5 -0
- package/src/server/api/workspaces.js +1 -3
- package/src/server/codex-proxy-server.js +89 -59
- package/src/server/gemini-proxy-server.js +107 -88
- package/src/server/index.js +1 -0
- package/src/server/opencode-proxy-server.js +381 -225
- package/src/server/proxy-server.js +86 -60
- package/src/server/services/alias.js +3 -3
- package/src/server/services/channels.js +3 -2
- package/src/server/services/codex-channels.js +38 -87
- package/src/server/services/codex-env-manager.js +426 -0
- package/src/server/services/codex-settings-manager.js +15 -15
- package/src/server/services/codex-statistics-service.js +3 -27
- package/src/server/services/config-export-service.js +20 -7
- package/src/server/services/config-registry-service.js +3 -2
- package/src/server/services/config-sync-manager.js +1 -1
- package/src/server/services/favorites.js +4 -3
- package/src/server/services/gemini-channels.js +3 -3
- package/src/server/services/gemini-statistics-service.js +3 -25
- package/src/server/services/mcp-service.js +2 -3
- package/src/server/services/model-detector.js +4 -3
- package/src/server/services/native-oauth-adapters.js +2 -1
- package/src/server/services/network-access.js +39 -1
- package/src/server/services/notification-hooks.js +951 -0
- package/src/server/services/opencode-channels.js +6 -6
- package/src/server/services/opencode-sessions.js +2 -2
- package/src/server/services/opencode-statistics-service.js +3 -27
- package/src/server/services/plugins-service.js +110 -31
- package/src/server/services/prompts-service.js +2 -3
- package/src/server/services/proxy-log-helper.js +242 -0
- package/src/server/services/proxy-runtime.js +6 -4
- package/src/server/services/repo-scanner-base.js +12 -4
- package/src/server/services/request-logger.js +7 -7
- package/src/server/services/security-config.js +4 -4
- package/src/server/services/session-cache.js +2 -2
- package/src/server/services/sessions.js +2 -2
- package/src/server/services/skill-service.js +174 -55
- package/src/server/services/statistics-service.js +10 -6
- package/src/server/services/ui-config.js +4 -3
- package/src/server/services/workspace-service.js +101 -156
- package/src/server/websocket-server.js +5 -4
- package/dist/web/assets/Analytics-DuYvId7u.css +0 -1
- package/dist/web/assets/Home-BMoFdAwy.css +0 -1
- package/dist/web/assets/Home-DNwp-0J-.js +0 -1
- package/dist/web/assets/index-BXeSvAwU.js +0 -2
- package/dist/web/assets/index-DWAC3Tdv.css +0 -1
- package/docs/bannel.png +0 -0
- package/docs/model-redirection.md +0 -251
package/src/server/api/agents.js
CHANGED
|
@@ -105,7 +105,7 @@ function getAllowedProjectRoots() {
|
|
|
105
105
|
|
|
106
106
|
// 从工作区配置中扩展允许目录,避免误拦截外部磁盘/自定义根目录项目
|
|
107
107
|
try {
|
|
108
|
-
const workspaceConfigPath =
|
|
108
|
+
const workspaceConfigPath = PATHS.workspaces;
|
|
109
109
|
if (fs.existsSync(workspaceConfigPath)) {
|
|
110
110
|
const raw = fs.readFileSync(workspaceConfigPath, 'utf-8');
|
|
111
111
|
const parsed = JSON.parse(raw || '{}');
|
|
@@ -5,21 +5,26 @@ const path = require('path');
|
|
|
5
5
|
const os = require('os');
|
|
6
6
|
const https = require('https');
|
|
7
7
|
const http = require('http');
|
|
8
|
+
const { PATHS, NATIVE_PATHS } = require('../../config/paths');
|
|
8
9
|
const { resolvePreferredHomeDir, normalizeWindowsHomePath } = require('../../utils/home-dir');
|
|
10
|
+
const { createSameOriginGuard } = require('../services/network-access');
|
|
9
11
|
|
|
10
12
|
// 检测操作系统
|
|
11
13
|
const platform = os.platform(); // 'darwin' | 'win32' | 'linux'
|
|
14
|
+
router.use(createSameOriginGuard({
|
|
15
|
+
message: '禁止跨站访问 Claude Hooks 配置接口'
|
|
16
|
+
}));
|
|
12
17
|
|
|
13
18
|
const HOME_DIR = resolvePreferredHomeDir(platform, process.env, os.homedir());
|
|
14
19
|
|
|
15
20
|
// Claude settings.json 路径
|
|
16
|
-
const CLAUDE_SETTINGS_PATH =
|
|
21
|
+
const CLAUDE_SETTINGS_PATH = NATIVE_PATHS.claude.settings;
|
|
17
22
|
|
|
18
23
|
// UI 配置路径(记录用户是否主动关闭过、飞书配置等)
|
|
19
|
-
const UI_CONFIG_PATH =
|
|
24
|
+
const UI_CONFIG_PATH = PATHS.uiConfig;
|
|
20
25
|
|
|
21
26
|
// 通知脚本路径(用于飞书通知)
|
|
22
|
-
const NOTIFY_SCRIPT_PATH =
|
|
27
|
+
const NOTIFY_SCRIPT_PATH = PATHS.notifyHook;
|
|
23
28
|
|
|
24
29
|
// 读取 Claude settings.json
|
|
25
30
|
function readClaudeSettings() {
|
|
@@ -222,17 +227,17 @@ function shouldRepairStopHook(settings, expectedScriptPath = NOTIFY_SCRIPT_PATH,
|
|
|
222
227
|
return false;
|
|
223
228
|
}
|
|
224
229
|
|
|
225
|
-
const markerType = parseNotifyTypeMarker(command);
|
|
226
|
-
if (!markerType) {
|
|
227
|
-
return false;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
230
|
const normalizedCommand = normalizePathForCompare(command);
|
|
231
231
|
const normalizedExpected = normalizePathForCompare(expectedScriptPath);
|
|
232
232
|
if (!normalizedCommand.includes(normalizedExpected)) {
|
|
233
233
|
return true;
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
+
const markerType = parseNotifyTypeMarker(command);
|
|
237
|
+
if (!markerType) {
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
|
|
236
241
|
return !fileExists(expectedScriptPath);
|
|
237
242
|
}
|
|
238
243
|
|
|
@@ -174,11 +174,12 @@ router.post('/start', async (req, res) => {
|
|
|
174
174
|
let message = `Codex proxy started on port ${proxyResult.port}, active channel: ${currentChannel.name}`;
|
|
175
175
|
let envHint = null;
|
|
176
176
|
|
|
177
|
-
|
|
178
|
-
if (configResult.envInjected && configResult.isFirstTime) {
|
|
177
|
+
if (configResult.envInjected && configResult.reloadRequired) {
|
|
179
178
|
envHint = {
|
|
180
|
-
command: configResult.sourceCommand,
|
|
181
|
-
message:
|
|
179
|
+
command: configResult.sourceCommand || null,
|
|
180
|
+
message: configResult.sourceCommand
|
|
181
|
+
? `请在 Codex 终端执行: ${configResult.sourceCommand}`
|
|
182
|
+
: '请重新打开 Codex 终端以加载新的用户环境变量'
|
|
182
183
|
};
|
|
183
184
|
}
|
|
184
185
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const router = express.Router();
|
|
3
|
+
const notificationHooks = require('../services/notification-hooks');
|
|
4
|
+
const { createSameOriginGuard } = require('../services/network-access');
|
|
5
|
+
|
|
6
|
+
router.use(createSameOriginGuard({
|
|
7
|
+
message: '禁止跨站访问通知配置接口'
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
router.get('/', (req, res) => {
|
|
11
|
+
try {
|
|
12
|
+
res.json(notificationHooks.getNotificationSettings());
|
|
13
|
+
} catch (error) {
|
|
14
|
+
console.error('Error getting notification hook settings:', error);
|
|
15
|
+
res.status(error.statusCode || 500).json({ error: error.message });
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
router.post('/', (req, res) => {
|
|
20
|
+
try {
|
|
21
|
+
const result = notificationHooks.saveNotificationSettings(req.body || {});
|
|
22
|
+
res.json({
|
|
23
|
+
...result,
|
|
24
|
+
message: '通知设置已保存'
|
|
25
|
+
});
|
|
26
|
+
} catch (error) {
|
|
27
|
+
console.error('Error saving notification hook settings:', error);
|
|
28
|
+
res.status(error.statusCode || 500).json({ error: error.message });
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
router.post('/test', async (req, res) => {
|
|
33
|
+
try {
|
|
34
|
+
await notificationHooks.testNotification(req.body || {});
|
|
35
|
+
res.json({
|
|
36
|
+
success: true,
|
|
37
|
+
message: req.body?.testFeishu ? '飞书测试通知已发送' : '系统测试通知已发送'
|
|
38
|
+
});
|
|
39
|
+
} catch (error) {
|
|
40
|
+
console.error('Error testing notification hook settings:', error);
|
|
41
|
+
res.status(error.statusCode || 500).json({ error: error.message });
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
module.exports = router;
|
|
@@ -58,7 +58,6 @@ router.get('/market', async (req, res) => {
|
|
|
58
58
|
try {
|
|
59
59
|
const { platform, service } = getPluginsService(req);
|
|
60
60
|
const forceRefresh = req.query.refresh === '1';
|
|
61
|
-
if (forceRefresh) service._marketCache = null;
|
|
62
61
|
const plugins = await service.getMarketPlugins(forceRefresh);
|
|
63
62
|
|
|
64
63
|
res.json({
|
|
@@ -151,8 +151,8 @@ router.get('/trend', async (req, res) => {
|
|
|
151
151
|
return res.status(400).json({ error: 'Hour granularity is limited to 7 days' });
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
-
if (diffDays >
|
|
155
|
-
return res.status(400).json({ error: 'Date range cannot exceed
|
|
154
|
+
if (diffDays > 365) {
|
|
155
|
+
return res.status(400).json({ error: 'Date range cannot exceed 365 days' });
|
|
156
156
|
}
|
|
157
157
|
|
|
158
158
|
const filters = {
|
|
@@ -204,8 +204,8 @@ router.get('/trend/export', async (req, res) => {
|
|
|
204
204
|
return res.status(400).json({ error: 'Hour granularity is limited to 7 days' });
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
-
if (diffDays >
|
|
208
|
-
return res.status(400).json({ error: 'Date range cannot exceed
|
|
207
|
+
if (diffDays > 365) {
|
|
208
|
+
return res.status(400).json({ error: 'Date range cannot exceed 365 days' });
|
|
209
209
|
}
|
|
210
210
|
|
|
211
211
|
const result = await getTrendStatistics({ startDate, endDate, granularity, step, groupBy, metric });
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const express = require('express');
|
|
2
2
|
const router = express.Router();
|
|
3
|
+
const { createSameOriginGuard } = require('../services/network-access');
|
|
3
4
|
const {
|
|
4
5
|
loadUIConfig,
|
|
5
6
|
saveUIConfig,
|
|
@@ -7,6 +8,10 @@ const {
|
|
|
7
8
|
updateNestedUIConfig
|
|
8
9
|
} = require('../services/ui-config');
|
|
9
10
|
|
|
11
|
+
router.use(createSameOriginGuard({
|
|
12
|
+
message: '禁止跨站访问 UI 配置接口'
|
|
13
|
+
}));
|
|
14
|
+
|
|
10
15
|
// Get all UI config
|
|
11
16
|
router.get('/', (req, res) => {
|
|
12
17
|
try {
|
|
@@ -402,12 +402,10 @@ router.post('/:id/projects', (req, res) => {
|
|
|
402
402
|
router.delete('/:id/projects/:projectName', (req, res) => {
|
|
403
403
|
try {
|
|
404
404
|
const { id, projectName } = req.params;
|
|
405
|
-
const removeWorktrees = req.query.removeWorktrees === 'true';
|
|
406
405
|
|
|
407
406
|
const workspace = workspaceService.removeProjectFromWorkspace(
|
|
408
407
|
id,
|
|
409
|
-
projectName
|
|
410
|
-
removeWorktrees
|
|
408
|
+
projectName
|
|
411
409
|
);
|
|
412
410
|
|
|
413
411
|
res.json({
|
|
@@ -13,6 +13,7 @@ const { saveProxyStartTime, clearProxyStartTime, getProxyStartTime, getProxyRunt
|
|
|
13
13
|
const { createDecodedStream } = require('./services/response-decoder');
|
|
14
14
|
const { getEffectiveApiKey } = require('./services/codex-channels');
|
|
15
15
|
const { persistProxyRequestSnapshot } = require('./services/request-logger');
|
|
16
|
+
const { publishUsageLog, publishFailureLog } = require('./services/proxy-log-helper');
|
|
16
17
|
|
|
17
18
|
let proxyServer = null;
|
|
18
19
|
let proxyApp = null;
|
|
@@ -270,6 +271,14 @@ async function startCodexProxyServer(options = {}) {
|
|
|
270
271
|
const effectiveKey = getEffectiveApiKey(channel);
|
|
271
272
|
if (!effectiveKey) {
|
|
272
273
|
release();
|
|
274
|
+
publishFailureLog({
|
|
275
|
+
source: 'codex',
|
|
276
|
+
channel: channel.name,
|
|
277
|
+
message: 'API key not configured or expired. Please update your channel key.',
|
|
278
|
+
statusCode: 401,
|
|
279
|
+
stage: 'preflight',
|
|
280
|
+
broadcastLog
|
|
281
|
+
});
|
|
273
282
|
return res.status(401).json({
|
|
274
283
|
error: {
|
|
275
284
|
message: 'API key not configured or expired. Please update your channel key.',
|
|
@@ -324,6 +333,20 @@ async function startCodexProxyServer(options = {}) {
|
|
|
324
333
|
release();
|
|
325
334
|
if (err) {
|
|
326
335
|
recordFailure(channel.id, 'codex', err);
|
|
336
|
+
const metadata = requestMetadata.get(req) || {
|
|
337
|
+
channel: channel.name,
|
|
338
|
+
channelId: channel.id,
|
|
339
|
+
startTime: Date.now()
|
|
340
|
+
};
|
|
341
|
+
publishFailureLog({
|
|
342
|
+
source: 'codex',
|
|
343
|
+
metadata,
|
|
344
|
+
message: err.message,
|
|
345
|
+
error: err,
|
|
346
|
+
statusCode: 502,
|
|
347
|
+
stage: 'proxy_web',
|
|
348
|
+
broadcastLog
|
|
349
|
+
});
|
|
327
350
|
console.error('Codex proxy error:', err);
|
|
328
351
|
if (res && !res.headersSent) {
|
|
329
352
|
res.status(502).json({
|
|
@@ -337,6 +360,13 @@ async function startCodexProxyServer(options = {}) {
|
|
|
337
360
|
});
|
|
338
361
|
} catch (error) {
|
|
339
362
|
console.error('Codex channel allocation error:', error);
|
|
363
|
+
publishFailureLog({
|
|
364
|
+
source: 'codex',
|
|
365
|
+
message: error.message || 'No Codex channel available',
|
|
366
|
+
statusCode: 503,
|
|
367
|
+
stage: 'allocate_channel',
|
|
368
|
+
broadcastLog
|
|
369
|
+
});
|
|
340
370
|
if (!res.headersSent) {
|
|
341
371
|
res.status(503).json({
|
|
342
372
|
error: {
|
|
@@ -389,8 +419,40 @@ async function startCodexProxyServer(options = {}) {
|
|
|
389
419
|
totalTokens: 0,
|
|
390
420
|
model: ''
|
|
391
421
|
};
|
|
422
|
+
let usageRecorded = false;
|
|
392
423
|
const parsedStream = createDecodedStream(proxyRes);
|
|
393
424
|
|
|
425
|
+
function recordUsageIfReady() {
|
|
426
|
+
if (usageRecorded) {
|
|
427
|
+
return false;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const result = publishUsageLog({
|
|
431
|
+
source: 'codex',
|
|
432
|
+
metadata,
|
|
433
|
+
model: tokenData.model,
|
|
434
|
+
tokens: {
|
|
435
|
+
input: tokenData.inputTokens,
|
|
436
|
+
output: tokenData.outputTokens,
|
|
437
|
+
cached: tokenData.cachedTokens,
|
|
438
|
+
reasoning: tokenData.reasoningTokens,
|
|
439
|
+
total: tokenData.totalTokens
|
|
440
|
+
},
|
|
441
|
+
calculateCost,
|
|
442
|
+
broadcastLog,
|
|
443
|
+
recordRequest: recordCodexRequest,
|
|
444
|
+
recordSuccess,
|
|
445
|
+
allowBroadcast: !isResponseClosed
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
if (!result) {
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
usageRecorded = true;
|
|
453
|
+
return true;
|
|
454
|
+
}
|
|
455
|
+
|
|
394
456
|
parsedStream.on('data', (chunk) => {
|
|
395
457
|
// 如果响应已关闭,停止处理
|
|
396
458
|
if (isResponseClosed) {
|
|
@@ -455,7 +517,10 @@ async function startCodexProxyServer(options = {}) {
|
|
|
455
517
|
// 兼容 Responses API 和 Chat Completions API
|
|
456
518
|
tokenData.inputTokens = parsed.usage.input_tokens || parsed.usage.prompt_tokens || 0;
|
|
457
519
|
tokenData.outputTokens = parsed.usage.output_tokens || parsed.usage.completion_tokens || 0;
|
|
520
|
+
tokenData.totalTokens = parsed.usage.total_tokens || (tokenData.inputTokens + tokenData.outputTokens);
|
|
458
521
|
}
|
|
522
|
+
|
|
523
|
+
recordUsageIfReady();
|
|
459
524
|
} catch (err) {
|
|
460
525
|
// 忽略解析错误
|
|
461
526
|
}
|
|
@@ -475,71 +540,14 @@ async function startCodexProxyServer(options = {}) {
|
|
|
475
540
|
// 兼容两种格式
|
|
476
541
|
tokenData.inputTokens = parsed.usage.input_tokens || parsed.usage.prompt_tokens || 0;
|
|
477
542
|
tokenData.outputTokens = parsed.usage.output_tokens || parsed.usage.completion_tokens || 0;
|
|
543
|
+
tokenData.totalTokens = parsed.usage.total_tokens || (tokenData.inputTokens + tokenData.outputTokens);
|
|
478
544
|
}
|
|
479
545
|
} catch (err) {
|
|
480
546
|
// 忽略解析错误
|
|
481
547
|
}
|
|
482
548
|
}
|
|
483
549
|
|
|
484
|
-
|
|
485
|
-
if (tokenData.inputTokens > 0 || tokenData.outputTokens > 0) {
|
|
486
|
-
const now = new Date();
|
|
487
|
-
const time = now.toLocaleTimeString('zh-CN', {
|
|
488
|
-
hour12: false,
|
|
489
|
-
hour: '2-digit',
|
|
490
|
-
minute: '2-digit',
|
|
491
|
-
second: '2-digit'
|
|
492
|
-
});
|
|
493
|
-
|
|
494
|
-
// 记录统计数据(先计算)
|
|
495
|
-
const tokens = {
|
|
496
|
-
input: tokenData.inputTokens,
|
|
497
|
-
output: tokenData.outputTokens,
|
|
498
|
-
total: tokenData.inputTokens + tokenData.outputTokens
|
|
499
|
-
};
|
|
500
|
-
const cost = calculateCost(tokenData.model, tokens);
|
|
501
|
-
|
|
502
|
-
// 广播日志(仅当响应仍然开放时)
|
|
503
|
-
if (!isResponseClosed) {
|
|
504
|
-
broadcastLog({
|
|
505
|
-
type: 'log',
|
|
506
|
-
id: metadata.id,
|
|
507
|
-
time: time,
|
|
508
|
-
channel: metadata.channel,
|
|
509
|
-
model: tokenData.model,
|
|
510
|
-
inputTokens: tokenData.inputTokens,
|
|
511
|
-
outputTokens: tokenData.outputTokens,
|
|
512
|
-
cachedTokens: tokenData.cachedTokens,
|
|
513
|
-
reasoningTokens: tokenData.reasoningTokens,
|
|
514
|
-
totalTokens: tokenData.totalTokens,
|
|
515
|
-
cost: cost,
|
|
516
|
-
source: 'codex'
|
|
517
|
-
});
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
const duration = Date.now() - metadata.startTime;
|
|
521
|
-
|
|
522
|
-
recordCodexRequest({
|
|
523
|
-
id: metadata.id,
|
|
524
|
-
timestamp: new Date(metadata.startTime).toISOString(),
|
|
525
|
-
toolType: 'codex',
|
|
526
|
-
channel: metadata.channel,
|
|
527
|
-
channelId: metadata.channelId,
|
|
528
|
-
model: tokenData.model,
|
|
529
|
-
tokens: {
|
|
530
|
-
input: tokenData.inputTokens,
|
|
531
|
-
output: tokenData.outputTokens,
|
|
532
|
-
reasoning: tokenData.reasoningTokens,
|
|
533
|
-
cached: tokenData.cachedTokens,
|
|
534
|
-
total: tokens.total
|
|
535
|
-
},
|
|
536
|
-
duration: duration,
|
|
537
|
-
success: true,
|
|
538
|
-
cost: cost
|
|
539
|
-
});
|
|
540
|
-
|
|
541
|
-
recordSuccess(metadata.channelId, 'codex');
|
|
542
|
-
}
|
|
550
|
+
recordUsageIfReady();
|
|
543
551
|
|
|
544
552
|
if (!isResponseClosed) {
|
|
545
553
|
requestMetadata.delete(req);
|
|
@@ -553,6 +561,15 @@ async function startCodexProxyServer(options = {}) {
|
|
|
553
561
|
}
|
|
554
562
|
isResponseClosed = true;
|
|
555
563
|
recordFailure(metadata.channelId, 'codex', err);
|
|
564
|
+
publishFailureLog({
|
|
565
|
+
source: 'codex',
|
|
566
|
+
metadata,
|
|
567
|
+
message: err.message,
|
|
568
|
+
error: err,
|
|
569
|
+
statusCode: proxyRes.statusCode,
|
|
570
|
+
stage: 'response_stream',
|
|
571
|
+
broadcastLog
|
|
572
|
+
});
|
|
556
573
|
requestMetadata.delete(req);
|
|
557
574
|
});
|
|
558
575
|
});
|
|
@@ -565,6 +582,19 @@ async function startCodexProxyServer(options = {}) {
|
|
|
565
582
|
releaseChannel(req.selectedChannel.id, 'codex');
|
|
566
583
|
broadcastSchedulerState('codex', getSchedulerState('codex'));
|
|
567
584
|
}
|
|
585
|
+
publishFailureLog({
|
|
586
|
+
source: 'codex',
|
|
587
|
+
metadata: (req && requestMetadata.get(req)) || {
|
|
588
|
+
channel: req?.selectedChannel?.name,
|
|
589
|
+
channelId: req?.selectedChannel?.id,
|
|
590
|
+
model: req?.body?.model
|
|
591
|
+
},
|
|
592
|
+
message: err.message,
|
|
593
|
+
error: err,
|
|
594
|
+
statusCode: 502,
|
|
595
|
+
stage: 'proxy',
|
|
596
|
+
broadcastLog
|
|
597
|
+
});
|
|
568
598
|
if (res && !res.headersSent) {
|
|
569
599
|
res.status(502).json({
|
|
570
600
|
error: {
|
|
@@ -13,6 +13,7 @@ const { saveProxyStartTime, clearProxyStartTime, getProxyStartTime, getProxyRunt
|
|
|
13
13
|
const { createDecodedStream } = require('./services/response-decoder');
|
|
14
14
|
const { getEffectiveApiKey } = require('./services/gemini-channels');
|
|
15
15
|
const { persistProxyRequestSnapshot } = require('./services/request-logger');
|
|
16
|
+
const { publishUsageLog, publishFailureLog } = require('./services/proxy-log-helper');
|
|
16
17
|
|
|
17
18
|
let proxyServer = null;
|
|
18
19
|
let proxyApp = null;
|
|
@@ -186,6 +187,14 @@ async function startGeminiProxyServer(options = {}) {
|
|
|
186
187
|
const effectiveKey = getEffectiveApiKey(channel);
|
|
187
188
|
if (!effectiveKey) {
|
|
188
189
|
release();
|
|
190
|
+
publishFailureLog({
|
|
191
|
+
source: 'gemini',
|
|
192
|
+
channel: channel.name,
|
|
193
|
+
message: 'API key not configured or expired. Please update your channel key.',
|
|
194
|
+
statusCode: 401,
|
|
195
|
+
stage: 'preflight',
|
|
196
|
+
broadcastLog
|
|
197
|
+
});
|
|
189
198
|
return res.status(401).json({
|
|
190
199
|
error: {
|
|
191
200
|
message: 'API key not configured or expired. Please update your channel key.',
|
|
@@ -241,6 +250,20 @@ async function startGeminiProxyServer(options = {}) {
|
|
|
241
250
|
release();
|
|
242
251
|
if (err) {
|
|
243
252
|
recordFailure(channel.id, 'gemini', err);
|
|
253
|
+
const metadata = requestMetadata.get(req) || {
|
|
254
|
+
channel: channel.name,
|
|
255
|
+
channelId: channel.id,
|
|
256
|
+
startTime: Date.now()
|
|
257
|
+
};
|
|
258
|
+
publishFailureLog({
|
|
259
|
+
source: 'gemini',
|
|
260
|
+
metadata,
|
|
261
|
+
message: err.message,
|
|
262
|
+
error: err,
|
|
263
|
+
statusCode: 502,
|
|
264
|
+
stage: 'proxy_web',
|
|
265
|
+
broadcastLog
|
|
266
|
+
});
|
|
244
267
|
console.error('Gemini proxy error:', err);
|
|
245
268
|
if (res && !res.headersSent) {
|
|
246
269
|
res.status(502).json({
|
|
@@ -254,6 +277,13 @@ async function startGeminiProxyServer(options = {}) {
|
|
|
254
277
|
});
|
|
255
278
|
} catch (error) {
|
|
256
279
|
console.error('Gemini channel allocation error:', error);
|
|
280
|
+
publishFailureLog({
|
|
281
|
+
source: 'gemini',
|
|
282
|
+
message: error.message || 'No Gemini channel available',
|
|
283
|
+
statusCode: 503,
|
|
284
|
+
stage: 'allocate_channel',
|
|
285
|
+
broadcastLog
|
|
286
|
+
});
|
|
257
287
|
if (!res.headersSent) {
|
|
258
288
|
res.status(503).json({
|
|
259
289
|
error: {
|
|
@@ -306,8 +336,44 @@ async function startGeminiProxyServer(options = {}) {
|
|
|
306
336
|
totalTokens: 0,
|
|
307
337
|
model: ''
|
|
308
338
|
};
|
|
339
|
+
let usageRecorded = false;
|
|
309
340
|
const parsedStream = createDecodedStream(proxyRes);
|
|
310
341
|
|
|
342
|
+
function recordUsageIfReady() {
|
|
343
|
+
if (usageRecorded) {
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (!tokenData.model && metadata.modelFromUrl) {
|
|
348
|
+
tokenData.model = metadata.modelFromUrl;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const result = publishUsageLog({
|
|
352
|
+
source: 'gemini',
|
|
353
|
+
metadata,
|
|
354
|
+
model: tokenData.model,
|
|
355
|
+
tokens: {
|
|
356
|
+
input: tokenData.inputTokens,
|
|
357
|
+
output: tokenData.outputTokens,
|
|
358
|
+
cached: tokenData.cachedTokens,
|
|
359
|
+
reasoning: tokenData.reasoningTokens,
|
|
360
|
+
total: tokenData.totalTokens
|
|
361
|
+
},
|
|
362
|
+
calculateCost,
|
|
363
|
+
broadcastLog,
|
|
364
|
+
recordRequest: recordGeminiRequest,
|
|
365
|
+
recordSuccess,
|
|
366
|
+
allowBroadcast: !isResponseClosed
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
if (!result) {
|
|
370
|
+
return false;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
usageRecorded = true;
|
|
374
|
+
return true;
|
|
375
|
+
}
|
|
376
|
+
|
|
311
377
|
parsedStream.on('data', (chunk) => {
|
|
312
378
|
// 如果响应已关闭,停止处理
|
|
313
379
|
if (isResponseClosed) {
|
|
@@ -347,30 +413,27 @@ async function startGeminiProxyServer(options = {}) {
|
|
|
347
413
|
}
|
|
348
414
|
|
|
349
415
|
// 提取 usage 信息 (支持 OpenAI 和 Gemini 原生格式)
|
|
350
|
-
if (
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
if (parsed.usage.prompt_tokens_details) {
|
|
359
|
-
tokenData.cachedTokens = parsed.usage.prompt_tokens_details.cached_tokens || 0;
|
|
360
|
-
}
|
|
416
|
+
if (parsed.usage) {
|
|
417
|
+
tokenData.inputTokens = parsed.usage.prompt_tokens || parsed.usage.input_tokens || 0;
|
|
418
|
+
tokenData.outputTokens = parsed.usage.completion_tokens || parsed.usage.output_tokens || 0;
|
|
419
|
+
tokenData.totalTokens = parsed.usage.total_tokens || 0;
|
|
420
|
+
|
|
421
|
+
// Gemini 可能包含缓存信息
|
|
422
|
+
if (parsed.usage.prompt_tokens_details) {
|
|
423
|
+
tokenData.cachedTokens = parsed.usage.prompt_tokens_details.cached_tokens || 0;
|
|
361
424
|
}
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
tokenData.cachedTokens = parsed.usageMetadata.cachedContentTokenCount;
|
|
371
|
-
}
|
|
425
|
+
} else if (parsed.usageMetadata) {
|
|
426
|
+
tokenData.inputTokens = parsed.usageMetadata.promptTokenCount || 0;
|
|
427
|
+
tokenData.outputTokens = parsed.usageMetadata.candidatesTokenCount || 0;
|
|
428
|
+
tokenData.totalTokens = parsed.usageMetadata.totalTokenCount || 0;
|
|
429
|
+
|
|
430
|
+
// Gemini 缓存信息
|
|
431
|
+
if (parsed.usageMetadata.cachedContentTokenCount) {
|
|
432
|
+
tokenData.cachedTokens = parsed.usageMetadata.cachedContentTokenCount;
|
|
372
433
|
}
|
|
373
434
|
}
|
|
435
|
+
|
|
436
|
+
recordUsageIfReady();
|
|
374
437
|
} catch (err) {
|
|
375
438
|
// 忽略解析错误
|
|
376
439
|
}
|
|
@@ -412,73 +475,7 @@ async function startGeminiProxyServer(options = {}) {
|
|
|
412
475
|
}
|
|
413
476
|
}
|
|
414
477
|
|
|
415
|
-
|
|
416
|
-
if (!tokenData.model && metadata.modelFromUrl) {
|
|
417
|
-
tokenData.model = metadata.modelFromUrl;
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
// 记录日志和统计
|
|
421
|
-
const now = new Date();
|
|
422
|
-
const time = now.toLocaleTimeString('zh-CN', {
|
|
423
|
-
hour12: false,
|
|
424
|
-
hour: '2-digit',
|
|
425
|
-
minute: '2-digit',
|
|
426
|
-
second: '2-digit'
|
|
427
|
-
});
|
|
428
|
-
|
|
429
|
-
// 记录统计数据(先计算)
|
|
430
|
-
const tokens = {
|
|
431
|
-
input: tokenData.inputTokens,
|
|
432
|
-
output: tokenData.outputTokens,
|
|
433
|
-
total: tokenData.totalTokens || (tokenData.inputTokens + tokenData.outputTokens)
|
|
434
|
-
};
|
|
435
|
-
const cost = calculateCost(tokenData.model, tokens);
|
|
436
|
-
|
|
437
|
-
// 只有在有 token 数据时才广播日志和记录统计
|
|
438
|
-
if (tokenData.inputTokens > 0 || tokenData.outputTokens > 0 || tokenData.totalTokens > 0) {
|
|
439
|
-
// 广播日志(仅当响应仍然开放时)
|
|
440
|
-
if (!isResponseClosed) {
|
|
441
|
-
const logData = {
|
|
442
|
-
type: 'log',
|
|
443
|
-
id: metadata.id,
|
|
444
|
-
time: time,
|
|
445
|
-
channel: metadata.channel,
|
|
446
|
-
model: tokenData.model,
|
|
447
|
-
inputTokens: tokenData.inputTokens,
|
|
448
|
-
outputTokens: tokenData.outputTokens,
|
|
449
|
-
cachedTokens: tokenData.cachedTokens,
|
|
450
|
-
reasoningTokens: tokenData.reasoningTokens,
|
|
451
|
-
totalTokens: tokenData.totalTokens || (tokenData.inputTokens + tokenData.outputTokens),
|
|
452
|
-
cost: cost,
|
|
453
|
-
source: 'gemini'
|
|
454
|
-
};
|
|
455
|
-
|
|
456
|
-
broadcastLog(logData);
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
// 记录统计
|
|
460
|
-
const duration = Date.now() - metadata.startTime;
|
|
461
|
-
|
|
462
|
-
recordGeminiRequest({
|
|
463
|
-
id: metadata.id,
|
|
464
|
-
timestamp: new Date(metadata.startTime).toISOString(),
|
|
465
|
-
toolType: 'gemini',
|
|
466
|
-
channel: metadata.channel,
|
|
467
|
-
channelId: metadata.channelId,
|
|
468
|
-
model: tokenData.model,
|
|
469
|
-
tokens: {
|
|
470
|
-
input: tokenData.inputTokens,
|
|
471
|
-
output: tokenData.outputTokens,
|
|
472
|
-
cached: tokenData.cachedTokens,
|
|
473
|
-
total: tokens.total
|
|
474
|
-
},
|
|
475
|
-
duration: duration,
|
|
476
|
-
success: true,
|
|
477
|
-
cost: cost
|
|
478
|
-
});
|
|
479
|
-
|
|
480
|
-
recordSuccess(metadata.channelId, 'gemini');
|
|
481
|
-
}
|
|
478
|
+
recordUsageIfReady();
|
|
482
479
|
|
|
483
480
|
if (!isResponseClosed) {
|
|
484
481
|
requestMetadata.delete(req);
|
|
@@ -492,6 +489,15 @@ async function startGeminiProxyServer(options = {}) {
|
|
|
492
489
|
}
|
|
493
490
|
isResponseClosed = true;
|
|
494
491
|
recordFailure(metadata.channelId, 'gemini', err);
|
|
492
|
+
publishFailureLog({
|
|
493
|
+
source: 'gemini',
|
|
494
|
+
metadata,
|
|
495
|
+
message: err.message,
|
|
496
|
+
error: err,
|
|
497
|
+
statusCode: proxyRes.statusCode,
|
|
498
|
+
stage: 'response_stream',
|
|
499
|
+
broadcastLog
|
|
500
|
+
});
|
|
495
501
|
requestMetadata.delete(req);
|
|
496
502
|
});
|
|
497
503
|
});
|
|
@@ -504,6 +510,19 @@ async function startGeminiProxyServer(options = {}) {
|
|
|
504
510
|
releaseChannel(req.selectedChannel.id, 'gemini');
|
|
505
511
|
broadcastSchedulerState('gemini', getSchedulerState('gemini'));
|
|
506
512
|
}
|
|
513
|
+
publishFailureLog({
|
|
514
|
+
source: 'gemini',
|
|
515
|
+
metadata: (req && requestMetadata.get(req)) || {
|
|
516
|
+
channel: req?.selectedChannel?.name,
|
|
517
|
+
channelId: req?.selectedChannel?.id,
|
|
518
|
+
model: req?.body?.model
|
|
519
|
+
},
|
|
520
|
+
message: err.message,
|
|
521
|
+
error: err,
|
|
522
|
+
statusCode: 502,
|
|
523
|
+
stage: 'proxy',
|
|
524
|
+
broadcastLog
|
|
525
|
+
});
|
|
507
526
|
if (res && !res.headersSent) {
|
|
508
527
|
res.status(502).json({
|
|
509
528
|
error: {
|
package/src/server/index.js
CHANGED
|
@@ -200,6 +200,7 @@ async function startServer(port, host = '127.0.0.1', options = {}) {
|
|
|
200
200
|
app.use('/api/skills', require('./api/skills'));
|
|
201
201
|
const claudeHooks = require('./api/claude-hooks');
|
|
202
202
|
app.use('/api/claude/hooks', claudeHooks);
|
|
203
|
+
app.use('/api/hooks', require('./api/hooks'));
|
|
203
204
|
|
|
204
205
|
// 初始化 Claude hooks 默认配置(自动开启任务完成通知)
|
|
205
206
|
claudeHooks.initDefaultHooks();
|