coding-tool-x 3.3.8 → 3.3.9

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.
Files changed (75) hide show
  1. package/CHANGELOG.md +17 -2
  2. package/README.md +253 -326
  3. package/dist/web/assets/{Analytics-DLpoDZ2M.js → Analytics-D6LzK9hk.js} +1 -1
  4. package/dist/web/assets/{ConfigTemplates-D_hRb55W.js → ConfigTemplates-BUDYuxRi.js} +1 -1
  5. package/dist/web/assets/Home-BQxQ1LhR.css +1 -0
  6. package/dist/web/assets/Home-D7KX7iF8.js +1 -0
  7. package/dist/web/assets/{PluginManager-JXsyym1s.js → PluginManager-DTgQ--vB.js} +1 -1
  8. package/dist/web/assets/{ProjectList-DZWSeb-q.js → ProjectList-DMCiGmCT.js} +1 -1
  9. package/dist/web/assets/{SessionList-Cs624DR3.js → SessionList-CRBsdVRe.js} +1 -1
  10. package/dist/web/assets/{SkillManager-bEliz7qz.js → SkillManager-DMwx2Q4k.js} +1 -1
  11. package/dist/web/assets/{WorkspaceManager-J3RecFGn.js → WorkspaceManager-DapB4ljL.js} +1 -1
  12. package/dist/web/assets/{icons-Cuc23WS7.js → icons-B5Pl4lrD.js} +1 -1
  13. package/dist/web/assets/index-CL-qpoJ_.js +2 -0
  14. package/dist/web/assets/index-D_5dRFOL.css +1 -0
  15. package/dist/web/assets/{markdown-C9MYpaSi.js → markdown-DyTJGI4N.js} +1 -1
  16. package/dist/web/assets/{naive-ui-CxpuzdjU.js → naive-ui-Bdxp09n2.js} +1 -1
  17. package/dist/web/assets/{vendors-DMjSfzlv.js → vendors-CKPV1OAU.js} +2 -2
  18. package/dist/web/assets/{vue-vendor-DET08QYg.js → vue-vendor-3bf-fPGP.js} +1 -1
  19. package/dist/web/index.html +7 -7
  20. package/docs/home.png +0 -0
  21. package/package.json +13 -5
  22. package/src/commands/daemon.js +3 -2
  23. package/src/commands/security.js +1 -2
  24. package/src/config/paths.js +638 -93
  25. package/src/server/api/agents.js +1 -1
  26. package/src/server/api/claude-hooks.js +13 -8
  27. package/src/server/api/codex-proxy.js +5 -4
  28. package/src/server/api/hooks.js +45 -0
  29. package/src/server/api/plugins.js +0 -1
  30. package/src/server/api/ui-config.js +5 -0
  31. package/src/server/codex-proxy-server.js +89 -59
  32. package/src/server/gemini-proxy-server.js +107 -88
  33. package/src/server/index.js +1 -0
  34. package/src/server/opencode-proxy-server.js +381 -225
  35. package/src/server/proxy-server.js +86 -60
  36. package/src/server/services/alias.js +3 -3
  37. package/src/server/services/channels.js +3 -2
  38. package/src/server/services/codex-channels.js +41 -87
  39. package/src/server/services/codex-env-manager.js +423 -0
  40. package/src/server/services/codex-settings-manager.js +15 -15
  41. package/src/server/services/codex-statistics-service.js +3 -27
  42. package/src/server/services/config-export-service.js +20 -7
  43. package/src/server/services/config-registry-service.js +3 -2
  44. package/src/server/services/config-sync-manager.js +1 -1
  45. package/src/server/services/favorites.js +4 -3
  46. package/src/server/services/gemini-channels.js +3 -3
  47. package/src/server/services/gemini-statistics-service.js +3 -25
  48. package/src/server/services/mcp-service.js +2 -3
  49. package/src/server/services/model-detector.js +4 -3
  50. package/src/server/services/native-oauth-adapters.js +2 -1
  51. package/src/server/services/network-access.js +39 -1
  52. package/src/server/services/notification-hooks.js +951 -0
  53. package/src/server/services/opencode-channels.js +6 -6
  54. package/src/server/services/opencode-sessions.js +2 -2
  55. package/src/server/services/opencode-statistics-service.js +3 -27
  56. package/src/server/services/plugins-service.js +110 -31
  57. package/src/server/services/prompts-service.js +2 -3
  58. package/src/server/services/proxy-log-helper.js +242 -0
  59. package/src/server/services/proxy-runtime.js +6 -4
  60. package/src/server/services/repo-scanner-base.js +12 -4
  61. package/src/server/services/request-logger.js +7 -7
  62. package/src/server/services/security-config.js +4 -4
  63. package/src/server/services/session-cache.js +2 -2
  64. package/src/server/services/sessions.js +2 -2
  65. package/src/server/services/skill-service.js +174 -55
  66. package/src/server/services/statistics-service.js +5 -5
  67. package/src/server/services/ui-config.js +4 -3
  68. package/src/server/services/workspace-service.js +1 -1
  69. package/src/server/websocket-server.js +5 -4
  70. package/dist/web/assets/Home-BMoFdAwy.css +0 -1
  71. package/dist/web/assets/Home-DNwp-0J-.js +0 -1
  72. package/dist/web/assets/index-BXeSvAwU.js +0 -2
  73. package/dist/web/assets/index-DWAC3Tdv.css +0 -1
  74. package/docs/bannel.png +0 -0
  75. package/docs/model-redirection.md +0 -251
@@ -105,7 +105,7 @@ function getAllowedProjectRoots() {
105
105
 
106
106
  // 从工作区配置中扩展允许目录,避免误拦截外部磁盘/自定义根目录项目
107
107
  try {
108
- const workspaceConfigPath = path.join(PATHS.base, 'workspaces.json');
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 = path.join(HOME_DIR, '.claude', 'settings.json');
21
+ const CLAUDE_SETTINGS_PATH = NATIVE_PATHS.claude.settings;
17
22
 
18
23
  // UI 配置路径(记录用户是否主动关闭过、飞书配置等)
19
- const UI_CONFIG_PATH = path.join(HOME_DIR, '.cc-tool', 'ui-config.json');
24
+ const UI_CONFIG_PATH = PATHS.uiConfig;
20
25
 
21
26
  // 通知脚本路径(用于飞书通知)
22
- const NOTIFY_SCRIPT_PATH = path.join(HOME_DIR, '.cc-tool', 'notify-hook.js');
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
- // 只有首次注入环境变量时才提示用户执行 source 命令
178
- if (configResult.envInjected && configResult.isFirstTime) {
177
+ if (configResult.envInjected && configResult.reloadRequired) {
179
178
  envHint = {
180
- command: configResult.sourceCommand,
181
- message: `首次启用需在 Codex 终端执行: ${configResult.sourceCommand}`
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({
@@ -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 {
@@ -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
- // 只有当有 token 数据时才记录
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 (tokenData.inputTokens === 0) {
351
- // OpenAI 格式
352
- if (parsed.usage) {
353
- tokenData.inputTokens = parsed.usage.prompt_tokens || parsed.usage.input_tokens || 0;
354
- tokenData.outputTokens = parsed.usage.completion_tokens || parsed.usage.output_tokens || 0;
355
- tokenData.totalTokens = parsed.usage.total_tokens || 0;
356
-
357
- // Gemini 可能包含缓存信息
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
- // Gemini 原生格式
363
- else if (parsed.usageMetadata) {
364
- tokenData.inputTokens = parsed.usageMetadata.promptTokenCount || 0;
365
- tokenData.outputTokens = parsed.usageMetadata.candidatesTokenCount || 0;
366
- tokenData.totalTokens = parsed.usageMetadata.totalTokenCount || 0;
367
-
368
- // Gemini 缓存信息
369
- if (parsed.usageMetadata.cachedContentTokenCount) {
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
- // 如果没有从响应中提取到模型,使用 URL 中的模型
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: {
@@ -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();