coding-tool-x 3.5.4 → 3.5.6

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 (40) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +8 -4
  3. package/dist/web/assets/{Analytics-CmN09J9U.js → Analytics-CRNCHeui.js} +1 -1
  4. package/dist/web/assets/{ConfigTemplates-CeTAPmep.js → ConfigTemplates-C0erJdo2.js} +1 -1
  5. package/dist/web/assets/{Home-BYtCM3rK.js → Home-CL5z6Q4d.js} +1 -1
  6. package/dist/web/assets/{PluginManager-OAH1eMO0.js → PluginManager-hDx0XMO_.js} +1 -1
  7. package/dist/web/assets/{ProjectList-B0pIy1cv.js → ProjectList-BNsz96av.js} +1 -1
  8. package/dist/web/assets/{SessionList-DbB6ASiA.js → SessionList-CG1UhFo3.js} +1 -1
  9. package/dist/web/assets/{SkillManager-wp1dhL1z.js → SkillManager-D6Vwpajh.js} +1 -1
  10. package/dist/web/assets/{WorkspaceManager-Ce6wQoKb.js → WorkspaceManager-C3TjeOPy.js} +1 -1
  11. package/dist/web/assets/{icons-DlxD2wZJ.js → icons-CQuif85v.js} +1 -1
  12. package/dist/web/assets/index-GuER-BmS.js +2 -0
  13. package/dist/web/assets/{index-B02wDWNC.css → index-VGAxnLqi.css} +1 -1
  14. package/dist/web/index.html +3 -3
  15. package/package.json +1 -1
  16. package/src/commands/stats.js +41 -4
  17. package/src/index.js +1 -0
  18. package/src/server/api/codex-sessions.js +6 -3
  19. package/src/server/api/dashboard.js +25 -1
  20. package/src/server/api/gemini-sessions.js +6 -3
  21. package/src/server/api/hooks.js +17 -1
  22. package/src/server/api/opencode-sessions.js +6 -3
  23. package/src/server/api/plugins.js +24 -33
  24. package/src/server/api/sessions.js +6 -3
  25. package/src/server/codex-proxy-server.js +24 -59
  26. package/src/server/gemini-proxy-server.js +25 -66
  27. package/src/server/index.js +6 -4
  28. package/src/server/opencode-proxy-server.js +24 -59
  29. package/src/server/proxy-server.js +18 -30
  30. package/src/server/services/base/response-usage-parser.js +187 -0
  31. package/src/server/services/codex-sessions.js +107 -9
  32. package/src/server/services/network-access.js +14 -0
  33. package/src/server/services/notification-hooks.js +175 -16
  34. package/src/server/services/plugins-service.js +502 -44
  35. package/src/server/services/proxy-log-helper.js +21 -3
  36. package/src/server/services/session-launch-command.js +81 -0
  37. package/src/server/services/sessions.js +103 -33
  38. package/src/server/services/statistics-service.js +7 -0
  39. package/src/server/websocket-server.js +25 -1
  40. package/dist/web/assets/index-CHwVofQH.js +0 -2
@@ -15,6 +15,7 @@ const { getEffectiveApiKey } = require('./services/gemini-channels');
15
15
  const { persistProxyRequestSnapshot } = require('./services/request-logger');
16
16
  const { publishUsageLog, publishFailureLog } = require('./services/proxy-log-helper');
17
17
  const { redirectModel: redirectModelBase, resolveTargetUrl } = require('./services/base/proxy-utils');
18
+ const { parseSSEUsage, parseNonStreamingUsage, mergeUsageIntoTokenData, createTokenData } = require('./services/base/response-usage-parser');
18
19
  const { attachServerShutdownHandling, expediteServerShutdown } = require('./services/server-shutdown');
19
20
 
20
21
  let proxyServer = null;
@@ -207,6 +208,14 @@ async function startGeminiProxyServer(options = {}) {
207
208
  // 替换 URL 中的模型名称
208
209
  req.url = req.url.replace(`/models/${originalModel}`, `/models/${redirectedModel}`);
209
210
 
211
+ // 将原始模型和重定向模型存入 metadata,用于日志记录
212
+ const meta = requestMetadata.get(req);
213
+ if (meta) {
214
+ meta.originalModel = originalModel;
215
+ meta.redirectedModel = redirectedModel;
216
+ meta.modelFromUrl = redirectedModel;
217
+ }
218
+
210
219
  // 只在重定向规则变化时打印日志(避免每次请求都打印)
211
220
  const cachedRedirects = printedGeminiRedirectCache.get(channel.id) || {};
212
221
  if (cachedRedirects[originalModel] !== redirectedModel) {
@@ -306,14 +315,7 @@ async function startGeminiProxyServer(options = {}) {
306
315
  });
307
316
 
308
317
  let buffer = '';
309
- let tokenData = {
310
- inputTokens: 0,
311
- outputTokens: 0,
312
- cachedTokens: 0,
313
- reasoningTokens: 0,
314
- totalTokens: 0,
315
- model: ''
316
- };
318
+ let tokenData = createTokenData();
317
319
  let usageRecorded = false;
318
320
  const parsedStream = createDecodedStream(proxyRes);
319
321
 
@@ -333,6 +335,8 @@ async function startGeminiProxyServer(options = {}) {
333
335
  tokens: {
334
336
  input: tokenData.inputTokens,
335
337
  output: tokenData.outputTokens,
338
+ cacheCreation: tokenData.cacheCreation,
339
+ cacheRead: tokenData.cacheRead,
336
340
  cached: tokenData.cachedTokens,
337
341
  reasoning: tokenData.reasoningTokens,
338
342
  total: tokenData.totalTokens
@@ -341,7 +345,7 @@ async function startGeminiProxyServer(options = {}) {
341
345
  broadcastLog,
342
346
  recordRequest: recordGeminiRequest,
343
347
  recordSuccess,
344
- allowBroadcast: !isResponseClosed
348
+ allowBroadcast: true
345
349
  });
346
350
 
347
351
  if (!result) {
@@ -353,7 +357,6 @@ async function startGeminiProxyServer(options = {}) {
353
357
  }
354
358
 
355
359
  parsedStream.on('data', (chunk) => {
356
- // 如果响应已关闭,停止处理
357
360
  if (isResponseClosed) {
358
361
  return;
359
362
  }
@@ -362,56 +365,34 @@ async function startGeminiProxyServer(options = {}) {
362
365
 
363
366
  // 检查是否是 SSE 流
364
367
  if (proxyRes.headers['content-type']?.includes('text/event-stream')) {
365
- // 处理 SSE 事件
366
368
  const events = buffer.split('\n\n');
367
369
  buffer = events.pop() || '';
368
370
 
369
- events.forEach((eventText, index) => {
371
+ events.forEach((eventText) => {
370
372
  if (!eventText.trim()) return;
371
373
 
372
374
  try {
373
375
  const lines = eventText.split('\n');
376
+ let eventType = '';
374
377
  let data = '';
375
378
 
376
379
  lines.forEach(line => {
377
- if (line.startsWith('data:')) {
380
+ if (line.startsWith('event:')) {
381
+ eventType = line.substring(6).trim();
382
+ } else if (line.startsWith('data:')) {
378
383
  data = line.substring(5).trim();
379
384
  }
380
385
  });
381
386
 
382
- if (!data) return;
383
-
384
- if (data === '[DONE]') return;
387
+ if (!data || data === '[DONE]') return;
385
388
 
386
389
  const parsed = JSON.parse(data);
390
+ const usage = parseSSEUsage(parsed, eventType);
391
+ mergeUsageIntoTokenData(tokenData, usage);
387
392
 
388
- // 提取模型信息
389
- if (parsed.model && !tokenData.model) {
390
- tokenData.model = parsed.model;
391
- }
392
-
393
- // 提取 usage 信息 (支持 OpenAI 和 Gemini 原生格式)
394
- if (parsed.usage) {
395
- tokenData.inputTokens = parsed.usage.prompt_tokens || parsed.usage.input_tokens || 0;
396
- tokenData.outputTokens = parsed.usage.completion_tokens || parsed.usage.output_tokens || 0;
397
- tokenData.totalTokens = parsed.usage.total_tokens || 0;
398
-
399
- // Gemini 可能包含缓存信息
400
- if (parsed.usage.prompt_tokens_details) {
401
- tokenData.cachedTokens = parsed.usage.prompt_tokens_details.cached_tokens || 0;
402
- }
403
- } else if (parsed.usageMetadata) {
404
- tokenData.inputTokens = parsed.usageMetadata.promptTokenCount || 0;
405
- tokenData.outputTokens = parsed.usageMetadata.candidatesTokenCount || 0;
406
- tokenData.totalTokens = parsed.usageMetadata.totalTokenCount || 0;
407
-
408
- // Gemini 缓存信息
409
- if (parsed.usageMetadata.cachedContentTokenCount) {
410
- tokenData.cachedTokens = parsed.usageMetadata.cachedContentTokenCount;
411
- }
393
+ if (usage.isDone) {
394
+ recordUsageIfReady();
412
395
  }
413
-
414
- recordUsageIfReady();
415
396
  } catch (err) {
416
397
  // 忽略解析错误
417
398
  }
@@ -424,30 +405,8 @@ async function startGeminiProxyServer(options = {}) {
424
405
  if (!proxyRes.headers['content-type']?.includes('text/event-stream')) {
425
406
  try {
426
407
  const parsed = JSON.parse(buffer);
427
- if (parsed.model) {
428
- tokenData.model = parsed.model;
429
- }
430
-
431
- // OpenAI 格式
432
- if (parsed.usage) {
433
- tokenData.inputTokens = parsed.usage.prompt_tokens || parsed.usage.input_tokens || 0;
434
- tokenData.outputTokens = parsed.usage.completion_tokens || parsed.usage.output_tokens || 0;
435
- tokenData.totalTokens = parsed.usage.total_tokens || 0;
436
-
437
- if (parsed.usage.prompt_tokens_details) {
438
- tokenData.cachedTokens = parsed.usage.prompt_tokens_details.cached_tokens || 0;
439
- }
440
- }
441
- // Gemini 原生格式
442
- else if (parsed.usageMetadata) {
443
- tokenData.inputTokens = parsed.usageMetadata.promptTokenCount || 0;
444
- tokenData.outputTokens = parsed.usageMetadata.candidatesTokenCount || 0;
445
- tokenData.totalTokens = parsed.usageMetadata.totalTokenCount || 0;
446
-
447
- if (parsed.usageMetadata.cachedContentTokenCount) {
448
- tokenData.cachedTokens = parsed.usageMetadata.cachedContentTokenCount;
449
- }
450
- }
408
+ const usage = parseNonStreamingUsage(parsed);
409
+ mergeUsageIntoTokenData(tokenData, usage);
451
410
  } catch (err) {
452
411
  // 忽略解析错误
453
412
  }
@@ -21,7 +21,7 @@ const { startProxyServer } = require('./proxy-server');
21
21
  const { startCodexProxyServer } = require('./codex-proxy-server');
22
22
  const { startGeminiProxyServer } = require('./gemini-proxy-server');
23
23
  const { startOpenCodeProxyServer, collectProxyModelList } = require('./opencode-proxy-server');
24
- const { createRemoteMutationGuard } = require('./services/network-access');
24
+ const { createRemoteMutationGuard, isRemoteMutationAllowed } = require('./services/network-access');
25
25
  const { createApiRequestLogger } = require('./services/request-logger');
26
26
 
27
27
  function getInquirer() {
@@ -132,7 +132,9 @@ async function startServer(port, host = '127.0.0.1', options = {}) {
132
132
 
133
133
  const app = express();
134
134
  const lanMode = host === '0.0.0.0';
135
- const allowRemoteMutation = process.env.CC_TOOL_ALLOW_REMOTE_WRITE === 'true';
135
+ const allowRemoteMutation = lanMode
136
+ ? isRemoteMutationAllowed(process.env.CC_TOOL_ALLOW_REMOTE_WRITE)
137
+ : true;
136
138
 
137
139
  // Middleware
138
140
  app.use(express.json({ limit: '100mb' }));
@@ -156,7 +158,7 @@ async function startServer(port, host = '127.0.0.1', options = {}) {
156
158
  app.use('/api', createRemoteMutationGuard({
157
159
  enabled: true,
158
160
  allowRemoteMutation,
159
- message: '出于安全考虑,LAN 模式默认仅允许本机执行写操作。可设置 CC_TOOL_ALLOW_REMOTE_WRITE=true 覆盖。'
161
+ message: '当前已禁用 LAN 远程写操作,可设置 CC_TOOL_ALLOW_REMOTE_WRITE=true 重新开启。'
160
162
  }));
161
163
 
162
164
  }
@@ -283,7 +285,7 @@ async function startServer(port, host = '127.0.0.1', options = {}) {
283
285
  console.log(` ws://localhost:${port}/ws\n`);
284
286
 
285
287
  if (host === '0.0.0.0' && !allowRemoteMutation) {
286
- console.log(chalk.yellow(' [LOCK] 已启用 LAN 安全保护:远程写操作默认禁用'));
288
+ console.log(chalk.yellow(' [LOCK] 已禁用 LAN 远程写操作 (CC_TOOL_ALLOW_REMOTE_WRITE=false)'));
287
289
  }
288
290
  // 自动恢复代理状态
289
291
  autoRestoreProxies();
@@ -22,6 +22,7 @@ const { persistProxyRequestSnapshot, loadClaudeRequestTemplate } = require('./se
22
22
  const { probeModelAvailability, fetchModelsFromProvider } = require('./services/model-detector');
23
23
  const { publishUsageLog, publishFailureLog } = require('./services/proxy-log-helper');
24
24
  const { redirectModel, resolveTargetUrl } = require('./services/base/proxy-utils');
25
+ const { parseSSEUsage, parseNonStreamingUsage, mergeUsageIntoTokenData, createTokenData } = require('./services/base/response-usage-parser');
25
26
  const { attachServerShutdownHandling, expediteServerShutdown } = require('./services/server-shutdown');
26
27
 
27
28
  let proxyServer = null;
@@ -4322,6 +4323,13 @@ async function startOpenCodeProxyServer(options = {}) {
4322
4323
  // 更新 rawBody 以匹配修改后的 body
4323
4324
  req.rawBody = Buffer.from(JSON.stringify(req.body));
4324
4325
 
4326
+ // 将原始模型和重定向模型存入 metadata,用于日志记录
4327
+ const meta = requestMetadata.get(req);
4328
+ if (meta) {
4329
+ meta.originalModel = originalModel;
4330
+ meta.redirectedModel = redirectedModel;
4331
+ }
4332
+
4325
4333
  // 只在重定向规则变化时打印日志(避免每次请求都打印)
4326
4334
  const cachedRedirects = printedRedirectCache.get(channel.id) || {};
4327
4335
  if (cachedRedirects[originalModel] !== redirectedModel) {
@@ -4456,14 +4464,7 @@ async function startOpenCodeProxyServer(options = {}) {
4456
4464
  });
4457
4465
 
4458
4466
  let buffer = '';
4459
- let tokenData = {
4460
- inputTokens: 0,
4461
- outputTokens: 0,
4462
- cachedTokens: 0,
4463
- reasoningTokens: 0,
4464
- totalTokens: 0,
4465
- model: ''
4466
- };
4467
+ let tokenData = createTokenData();
4467
4468
  let usageRecorded = false;
4468
4469
 
4469
4470
  function recordUsageIfReady() {
@@ -4478,6 +4479,8 @@ async function startOpenCodeProxyServer(options = {}) {
4478
4479
  tokens: {
4479
4480
  input: tokenData.inputTokens,
4480
4481
  output: tokenData.outputTokens,
4482
+ cacheCreation: tokenData.cacheCreation,
4483
+ cacheRead: tokenData.cacheRead,
4481
4484
  cached: tokenData.cachedTokens,
4482
4485
  reasoning: tokenData.reasoningTokens,
4483
4486
  total: tokenData.totalTokens
@@ -4486,7 +4489,7 @@ async function startOpenCodeProxyServer(options = {}) {
4486
4489
  broadcastLog,
4487
4490
  recordRequest: recordOpenCodeRequest,
4488
4491
  recordSuccess,
4489
- allowBroadcast: !isResponseClosed
4492
+ allowBroadcast: true
4490
4493
  });
4491
4494
 
4492
4495
  if (!result) {
@@ -4498,7 +4501,6 @@ async function startOpenCodeProxyServer(options = {}) {
4498
4501
  }
4499
4502
 
4500
4503
  proxyRes.on('data', (chunk) => {
4501
- // 如果响应已关闭,停止处理
4502
4504
  if (isResponseClosed) {
4503
4505
  return;
4504
4506
  }
@@ -4507,64 +4509,34 @@ async function startOpenCodeProxyServer(options = {}) {
4507
4509
 
4508
4510
  // 检查是否是 SSE 流
4509
4511
  if (proxyRes.headers['content-type']?.includes('text/event-stream')) {
4510
- // 处理 SSE 事件
4511
4512
  const events = buffer.split('\n\n');
4512
4513
  buffer = events.pop() || '';
4513
4514
 
4514
- events.forEach((eventText, index) => {
4515
+ events.forEach((eventText) => {
4515
4516
  if (!eventText.trim()) return;
4516
4517
 
4517
4518
  try {
4518
4519
  const lines = eventText.split('\n');
4520
+ let eventType = '';
4519
4521
  let data = '';
4520
4522
 
4521
4523
  lines.forEach(line => {
4522
- if (line.startsWith('data:')) {
4524
+ if (line.startsWith('event:')) {
4525
+ eventType = line.substring(6).trim();
4526
+ } else if (line.startsWith('data:')) {
4523
4527
  data = line.substring(5).trim();
4524
4528
  }
4525
4529
  });
4526
4530
 
4527
- if (!data) return;
4528
-
4529
- if (data === '[DONE]') return;
4531
+ if (!data || data === '[DONE]') return;
4530
4532
 
4531
4533
  const parsed = JSON.parse(data);
4534
+ const usage = parseSSEUsage(parsed, eventType);
4535
+ mergeUsageIntoTokenData(tokenData, usage);
4532
4536
 
4533
- // OpenAI Responses API: 在 response.completed 事件中获取 usage
4534
- if (parsed.type === 'response.completed' && parsed.response) {
4535
- // 从 response 对象中提取模型和 usage
4536
- if (parsed.response.model) {
4537
- tokenData.model = parsed.response.model;
4538
- }
4539
-
4540
- if (parsed.response.usage) {
4541
- tokenData.inputTokens = parsed.response.usage.input_tokens || 0;
4542
- tokenData.outputTokens = parsed.response.usage.output_tokens || 0;
4543
- tokenData.totalTokens = parsed.response.usage.total_tokens || 0;
4544
-
4545
- // 提取详细信息
4546
- if (parsed.response.usage.input_tokens_details) {
4547
- tokenData.cachedTokens = parsed.response.usage.input_tokens_details.cached_tokens || 0;
4548
- }
4549
- if (parsed.response.usage.output_tokens_details) {
4550
- tokenData.reasoningTokens = parsed.response.usage.output_tokens_details.reasoning_tokens || 0;
4551
- }
4552
- }
4553
- }
4554
-
4555
- // 兼容其他格式:直接在顶层的 model 和 usage
4556
- if (parsed.model && !tokenData.model) {
4557
- tokenData.model = parsed.model;
4558
- }
4559
-
4560
- if (parsed.usage && tokenData.inputTokens === 0) {
4561
- // 兼容 Responses API 和 Chat Completions API
4562
- tokenData.inputTokens = parsed.usage.input_tokens || parsed.usage.prompt_tokens || 0;
4563
- tokenData.outputTokens = parsed.usage.output_tokens || parsed.usage.completion_tokens || 0;
4564
- tokenData.totalTokens = parsed.usage.total_tokens || (tokenData.inputTokens + tokenData.outputTokens);
4537
+ if (usage.isDone) {
4538
+ recordUsageIfReady();
4565
4539
  }
4566
-
4567
- recordUsageIfReady();
4568
4540
  } catch (err) {
4569
4541
  // 忽略解析错误
4570
4542
  }
@@ -4577,15 +4549,8 @@ async function startOpenCodeProxyServer(options = {}) {
4577
4549
  if (!proxyRes.headers['content-type']?.includes('text/event-stream')) {
4578
4550
  try {
4579
4551
  const parsed = JSON.parse(buffer);
4580
- if (parsed.model) {
4581
- tokenData.model = parsed.model;
4582
- }
4583
- if (parsed.usage) {
4584
- // 兼容两种格式
4585
- tokenData.inputTokens = parsed.usage.input_tokens || parsed.usage.prompt_tokens || 0;
4586
- tokenData.outputTokens = parsed.usage.output_tokens || parsed.usage.completion_tokens || 0;
4587
- tokenData.totalTokens = parsed.usage.total_tokens || (tokenData.inputTokens + tokenData.outputTokens);
4588
- }
4552
+ const usage = parseNonStreamingUsage(parsed);
4553
+ mergeUsageIntoTokenData(tokenData, usage);
4589
4554
  } catch (err) {
4590
4555
  // 忽略解析错误
4591
4556
  }
@@ -20,6 +20,7 @@ const { getEffectiveApiKey } = require('./services/channels');
20
20
  const { persistProxyRequestSnapshot, persistClaudeRequestTemplate } = require('./services/request-logger');
21
21
  const { publishUsageLog, publishFailureLog } = require('./services/proxy-log-helper');
22
22
  const { redirectModel } = require('./services/base/proxy-utils');
23
+ const { parseSSEUsage, mergeUsageIntoTokenData, createTokenData } = require('./services/base/response-usage-parser');
23
24
  const { attachServerShutdownHandling, expediteServerShutdown } = require('./services/server-shutdown');
24
25
 
25
26
  let proxyServer = null;
@@ -294,6 +295,13 @@ async function startProxyServer(options = {}) {
294
295
  // 更新 rawBody 以匹配修改后的 body
295
296
  req.rawBody = Buffer.from(JSON.stringify(req.body));
296
297
 
298
+ // 将原始模型和重定向模型存入 metadata,用于日志记录
299
+ const meta = requestMetadata.get(req);
300
+ if (meta) {
301
+ meta.originalModel = originalModel;
302
+ meta.redirectedModel = redirectedModel;
303
+ }
304
+
297
305
  // 只在重定向规则变化时打印日志(避免每次请求都打印)
298
306
  const cachedRedirects = printedRedirectCache.get(channel.id) || {};
299
307
  if (cachedRedirects[originalModel] !== redirectedModel) {
@@ -387,13 +395,7 @@ async function startProxyServer(options = {}) {
387
395
  });
388
396
 
389
397
  let buffer = '';
390
- let tokenData = {
391
- inputTokens: 0,
392
- outputTokens: 0,
393
- cacheCreation: 0,
394
- cacheRead: 0,
395
- model: ''
396
- };
398
+ let tokenData = createTokenData();
397
399
  let usageRecorded = false;
398
400
  const parsedStream = createDecodedStream(proxyRes);
399
401
 
@@ -408,13 +410,16 @@ async function startProxyServer(options = {}) {
408
410
  input: tokenData.inputTokens,
409
411
  output: tokenData.outputTokens,
410
412
  cacheCreation: tokenData.cacheCreation,
411
- cacheRead: tokenData.cacheRead
413
+ cacheRead: tokenData.cacheRead,
414
+ cached: tokenData.cachedTokens,
415
+ reasoning: tokenData.reasoningTokens,
416
+ total: tokenData.totalTokens
412
417
  },
413
418
  calculateCost,
414
419
  broadcastLog,
415
420
  recordRequest,
416
421
  recordSuccess,
417
- allowBroadcast: !isResponseClosed
422
+ allowBroadcast: true
418
423
  });
419
424
 
420
425
  if (!result) return false;
@@ -446,30 +451,13 @@ async function startProxyServer(options = {}) {
446
451
  }
447
452
  });
448
453
 
449
- if (!data) return;
454
+ if (!data || data === '[DONE]') return;
450
455
 
451
456
  const parsed = JSON.parse(data);
457
+ const usage = parseSSEUsage(parsed, eventType);
458
+ mergeUsageIntoTokenData(tokenData, usage);
452
459
 
453
- if (eventType === 'message_start' && parsed.message && parsed.message.model) {
454
- tokenData.model = parsed.message.model;
455
- }
456
-
457
- if (parsed.usage) {
458
- if (parsed.usage.input_tokens !== undefined) {
459
- tokenData.inputTokens = parsed.usage.input_tokens;
460
- }
461
- if (parsed.usage.output_tokens !== undefined) {
462
- tokenData.outputTokens = parsed.usage.output_tokens;
463
- }
464
- if (parsed.usage.cache_creation_input_tokens !== undefined) {
465
- tokenData.cacheCreation = parsed.usage.cache_creation_input_tokens;
466
- }
467
- if (parsed.usage.cache_read_input_tokens !== undefined) {
468
- tokenData.cacheRead = parsed.usage.cache_read_input_tokens;
469
- }
470
- }
471
-
472
- if (eventType === 'message_stop') {
460
+ if (usage.isDone) {
473
461
  recordUsageIfReady();
474
462
  }
475
463
  } catch (err) {
@@ -0,0 +1,187 @@
1
+ /**
2
+ * response-usage-parser.js - 统一响应解析器
3
+ *
4
+ * 从各种 AI 提供商(Claude / OpenAI / Gemini)的 SSE 事件和
5
+ * 非流式 JSON 响应中提取模型名称和 token 用量信息。
6
+ *
7
+ * 所有 proxy server 共用此模块,避免重复代码,
8
+ * 并确保模型重定向后仍能正确解析不同格式的响应。
9
+ */
10
+
11
+ /**
12
+ * 从单个 SSE 事件的 parsed JSON 中提取 model 和 token 信息。
13
+ * 自动检测 Claude / OpenAI / Gemini 格式。
14
+ *
15
+ * @param {object} parsed - JSON.parse 后的事件数据
16
+ * @param {string} [eventType=''] - SSE event: 行的值(如 'message_start')
17
+ * @returns {{ model: string|null, tokens: object|null, isDone: boolean }}
18
+ */
19
+ function parseSSEUsage(parsed, eventType) {
20
+ if (!parsed || typeof parsed !== 'object') {
21
+ return { model: null, tokens: null, isDone: false };
22
+ }
23
+
24
+ let model = null;
25
+ let tokens = null;
26
+ let isDone = false;
27
+
28
+ // === Claude SSE 格式 ===
29
+ // event: message_start → parsed.message.model
30
+ // event: message_delta / message_stop → parsed.usage
31
+ if (eventType === 'message_start' && parsed.message && parsed.message.model) {
32
+ model = parsed.message.model;
33
+ }
34
+ if (eventType === 'message_stop') {
35
+ isDone = true;
36
+ }
37
+
38
+ // === OpenAI Responses API 格式 ===
39
+ // data: {"type": "response.completed", "response": {"model", "usage": {...}}}
40
+ if (parsed.type === 'response.completed' && parsed.response) {
41
+ if (parsed.response.model) {
42
+ model = parsed.response.model;
43
+ }
44
+ if (parsed.response.usage) {
45
+ tokens = {
46
+ input: parsed.response.usage.input_tokens || 0,
47
+ output: parsed.response.usage.output_tokens || 0,
48
+ total: parsed.response.usage.total_tokens || 0,
49
+ };
50
+ if (parsed.response.usage.input_tokens_details &&
51
+ parsed.response.usage.input_tokens_details.cached_tokens !== undefined) {
52
+ tokens.cached = parsed.response.usage.input_tokens_details.cached_tokens;
53
+ }
54
+ if (parsed.response.usage.output_tokens_details &&
55
+ parsed.response.usage.output_tokens_details.reasoning_tokens !== undefined) {
56
+ tokens.reasoning = parsed.response.usage.output_tokens_details.reasoning_tokens;
57
+ }
58
+ }
59
+ isDone = true;
60
+ }
61
+
62
+ // === parsed.usage(Claude 原生 + OpenAI Chat Completions 共用) ===
63
+ if (!tokens && parsed.usage) {
64
+ const t = {};
65
+
66
+ // Claude 格式字段
67
+ if (parsed.usage.input_tokens !== undefined) {
68
+ t.input = parsed.usage.input_tokens;
69
+ }
70
+ if (parsed.usage.output_tokens !== undefined) {
71
+ t.output = parsed.usage.output_tokens;
72
+ }
73
+ if (parsed.usage.cache_creation_input_tokens !== undefined) {
74
+ t.cacheCreation = parsed.usage.cache_creation_input_tokens;
75
+ }
76
+ if (parsed.usage.cache_read_input_tokens !== undefined) {
77
+ t.cacheRead = parsed.usage.cache_read_input_tokens;
78
+ }
79
+
80
+ // OpenAI Chat Completions 格式字段(fallback)
81
+ if (t.input === undefined && parsed.usage.prompt_tokens !== undefined) {
82
+ t.input = parsed.usage.prompt_tokens;
83
+ }
84
+ if (t.output === undefined && parsed.usage.completion_tokens !== undefined) {
85
+ t.output = parsed.usage.completion_tokens;
86
+ }
87
+ if (parsed.usage.total_tokens !== undefined) {
88
+ t.total = parsed.usage.total_tokens;
89
+ }
90
+
91
+ // OpenAI detailed breakdowns
92
+ if (parsed.usage.input_tokens_details &&
93
+ parsed.usage.input_tokens_details.cached_tokens !== undefined) {
94
+ t.cached = parsed.usage.input_tokens_details.cached_tokens;
95
+ }
96
+ if (parsed.usage.output_tokens_details &&
97
+ parsed.usage.output_tokens_details.reasoning_tokens !== undefined) {
98
+ t.reasoning = parsed.usage.output_tokens_details.reasoning_tokens;
99
+ }
100
+
101
+ // Gemini cache in OpenAI compat mode
102
+ if (parsed.usage.prompt_tokens_details &&
103
+ parsed.usage.prompt_tokens_details.cached_tokens !== undefined) {
104
+ t.cached = parsed.usage.prompt_tokens_details.cached_tokens;
105
+ }
106
+
107
+ if (Object.keys(t).length > 0) {
108
+ tokens = t;
109
+ }
110
+ }
111
+
112
+ // === Gemini Native 格式 ===
113
+ // parsed.usageMetadata.{promptTokenCount, candidatesTokenCount, ...}
114
+ if (!tokens && parsed.usageMetadata) {
115
+ tokens = {
116
+ input: parsed.usageMetadata.promptTokenCount || 0,
117
+ output: parsed.usageMetadata.candidatesTokenCount || 0,
118
+ total: parsed.usageMetadata.totalTokenCount || 0,
119
+ };
120
+ if (parsed.usageMetadata.cachedContentTokenCount) {
121
+ tokens.cached = parsed.usageMetadata.cachedContentTokenCount;
122
+ }
123
+ }
124
+
125
+ // === 通用 model fallback ===
126
+ if (!model && parsed.model) {
127
+ model = parsed.model;
128
+ }
129
+
130
+ return { model, tokens, isDone };
131
+ }
132
+
133
+ /**
134
+ * 从完整的非流式 JSON 响应中提取 model 和 token 信息。
135
+ *
136
+ * @param {object} parsed - JSON.parse 后的完整响应
137
+ * @returns {{ model: string|null, tokens: object|null, isDone: boolean }}
138
+ */
139
+ function parseNonStreamingUsage(parsed) {
140
+ return parseSSEUsage(parsed, '');
141
+ }
142
+
143
+ /**
144
+ * 将解析结果合并到 tokenData 对象。
145
+ *
146
+ * @param {object} tokenData - 各 proxy 的 tokenData 累积对象
147
+ * @param {{ model: string|null, tokens: object|null, isDone: boolean }} usage - parseSSEUsage 的返回值
148
+ */
149
+ function mergeUsageIntoTokenData(tokenData, usage) {
150
+ if (usage.model) {
151
+ tokenData.model = usage.model;
152
+ }
153
+ if (usage.tokens) {
154
+ if (usage.tokens.input !== undefined) tokenData.inputTokens = usage.tokens.input;
155
+ if (usage.tokens.output !== undefined) tokenData.outputTokens = usage.tokens.output;
156
+ if (usage.tokens.cacheCreation !== undefined) tokenData.cacheCreation = usage.tokens.cacheCreation;
157
+ if (usage.tokens.cacheRead !== undefined) tokenData.cacheRead = usage.tokens.cacheRead;
158
+ if (usage.tokens.cached !== undefined) tokenData.cachedTokens = usage.tokens.cached;
159
+ if (usage.tokens.reasoning !== undefined) tokenData.reasoningTokens = usage.tokens.reasoning;
160
+ if (usage.tokens.total !== undefined) tokenData.totalTokens = usage.tokens.total;
161
+ }
162
+ }
163
+
164
+ /**
165
+ * 创建统一的 tokenData 初始结构。
166
+ *
167
+ * @returns {object}
168
+ */
169
+ function createTokenData() {
170
+ return {
171
+ inputTokens: 0,
172
+ outputTokens: 0,
173
+ cacheCreation: 0,
174
+ cacheRead: 0,
175
+ cachedTokens: 0,
176
+ reasoningTokens: 0,
177
+ totalTokens: 0,
178
+ model: ''
179
+ };
180
+ }
181
+
182
+ module.exports = {
183
+ parseSSEUsage,
184
+ parseNonStreamingUsage,
185
+ mergeUsageIntoTokenData,
186
+ createTokenData
187
+ };