@adversity/coding-tool-x 3.1.2 → 3.1.4

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 (59) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/web/assets/Analytics-Blo8_rhE.css +1 -0
  3. package/dist/web/assets/Analytics-DEIGaSFz.js +39 -0
  4. package/dist/web/assets/{ConfigTemplates-DvcbKKdS.js → ConfigTemplates-BZaehll1.js} +1 -1
  5. package/dist/web/assets/{Home-BJKPCBuk.css → Home-CyCIx4BA.css} +1 -1
  6. package/dist/web/assets/{Home-Cw-F_Wnu.js → Home-DIIH5bAk.js} +1 -1
  7. package/dist/web/assets/{PluginManager-jy_4GVxI.js → PluginManager-b4IlavFA.js} +1 -1
  8. package/dist/web/assets/{ProjectList-Df1-NcNr.js → ProjectList-QaqzjEea.js} +1 -1
  9. package/dist/web/assets/SessionList-CXUr6S7w.css +1 -0
  10. package/dist/web/assets/SessionList-Cz_hrGmL.js +1 -0
  11. package/dist/web/assets/{SkillManager-IRdseMKB.js → SkillManager-B-Rcb9xY.js} +1 -1
  12. package/dist/web/assets/{Terminal-BasTyDut.js → Terminal-C8CFJEkx.js} +1 -1
  13. package/dist/web/assets/{WorkspaceManager-D-D2kK1V.js → WorkspaceManager-XaQj8BRE.js} +1 -1
  14. package/dist/web/assets/icons-BxcwoY5F.js +1 -0
  15. package/dist/web/assets/index-B_kPXCbH.js +2 -0
  16. package/dist/web/assets/index-DUNAVDGb.css +1 -0
  17. package/dist/web/assets/naive-ui-BIXcURHZ.js +1 -0
  18. package/dist/web/assets/{vendors-CO3Upi1d.js → vendors-i5CBGnlm.js} +1 -1
  19. package/dist/web/assets/{vue-vendor-DqyWIXEb.js → vue-vendor-PKd8utv_.js} +1 -1
  20. package/dist/web/index.html +6 -6
  21. package/package.json +1 -1
  22. package/src/config/default.js +7 -29
  23. package/src/config/loader.js +6 -3
  24. package/src/config/model-metadata.js +102 -350
  25. package/src/config/model-metadata.json +125 -0
  26. package/src/server/api/channels.js +16 -39
  27. package/src/server/api/codex-channels.js +15 -43
  28. package/src/server/api/commands.js +0 -77
  29. package/src/server/api/config.js +4 -1
  30. package/src/server/api/gemini-channels.js +16 -40
  31. package/src/server/api/opencode-channels.js +25 -51
  32. package/src/server/api/opencode-proxy.js +1 -1
  33. package/src/server/api/opencode-sessions.js +0 -7
  34. package/src/server/api/sessions.js +11 -68
  35. package/src/server/api/settings.js +66 -39
  36. package/src/server/api/skills.js +0 -44
  37. package/src/server/api/statistics.js +115 -1
  38. package/src/server/codex-proxy-server.js +26 -55
  39. package/src/server/gemini-proxy-server.js +15 -14
  40. package/src/server/index.js +0 -3
  41. package/src/server/opencode-proxy-server.js +45 -121
  42. package/src/server/proxy-server.js +2 -4
  43. package/src/server/services/commands-service.js +0 -29
  44. package/src/server/services/config-templates-service.js +38 -28
  45. package/src/server/services/env-checker.js +73 -8
  46. package/src/server/services/plugins-service.js +37 -28
  47. package/src/server/services/pty-manager.js +22 -18
  48. package/src/server/services/skill-service.js +1 -49
  49. package/src/server/services/speed-test.js +40 -3
  50. package/src/server/services/statistics-service.js +238 -1
  51. package/src/server/utils/pricing.js +51 -60
  52. package/dist/web/assets/SessionList-BGJWyneI.css +0 -1
  53. package/dist/web/assets/SessionList-UWcZtC2r.js +0 -1
  54. package/dist/web/assets/icons-kcfLIMBB.js +0 -1
  55. package/dist/web/assets/index-CoB3zF0K.css +0 -1
  56. package/dist/web/assets/index-CryrSLv8.js +0 -2
  57. package/dist/web/assets/naive-ui-CSrLusZZ.js +0 -1
  58. package/src/server/api/convert.js +0 -260
  59. package/src/server/services/session-converter.js +0 -577
@@ -48,6 +48,14 @@ class PtyManager {
48
48
  }
49
49
  }
50
50
 
51
+ isDirectoryPath(candidate) {
52
+ try {
53
+ return fs.existsSync(candidate) && fs.statSync(candidate).isDirectory();
54
+ } catch (err) {
55
+ return false;
56
+ }
57
+ }
58
+
51
59
  resolveWorkingDirectory(cwd) {
52
60
  const fallback = os.homedir();
53
61
  if (typeof cwd !== 'string') {
@@ -69,28 +77,22 @@ class PtyManager {
69
77
  }
70
78
 
71
79
  // 先尝试直接使用(支持相对路径)
72
- try {
73
- if (fs.existsSync(normalized) && fs.statSync(normalized).isDirectory()) {
74
- return path.isAbsolute(normalized) ? normalized : path.resolve(process.cwd(), normalized);
75
- }
76
- } catch (err) {
77
- // 忽略错误,继续尝试其他候选路径
80
+ if (this.isDirectoryPath(normalized)) {
81
+ return path.isAbsolute(normalized) ? normalized : path.resolve(process.cwd(), normalized);
78
82
  }
79
83
 
80
84
  // 相对路径:优先按进程 cwd 解析,其次按用户 home 解析(用于 .codex 这类隐藏目录)
81
85
  if (!path.isAbsolute(normalized)) {
82
86
  const candidates = [
83
87
  path.resolve(process.cwd(), normalized),
88
+ path.resolve(process.cwd(), '..', normalized),
89
+ path.resolve(process.cwd(), '..', '..', normalized),
84
90
  path.resolve(os.homedir(), normalized)
85
91
  ];
86
92
 
87
93
  for (const candidate of candidates) {
88
- try {
89
- if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
90
- return candidate;
91
- }
92
- } catch (err) {
93
- // 忽略错误
94
+ if (this.isDirectoryPath(candidate)) {
95
+ return candidate;
94
96
  }
95
97
  }
96
98
  }
@@ -269,16 +271,18 @@ class PtyManager {
269
271
  console.log(`[PTY] Resolved cwd: ${originalCwd} -> ${cwd}`);
270
272
  }
271
273
 
272
- try {
273
- if (!fs.existsSync(cwd) || !fs.statSync(cwd).isDirectory()) {
274
+ if (!this.isDirectoryPath(cwd)) {
275
+ const fallbackCandidates = [process.cwd(), os.homedir()];
276
+ const fallbackCwd = fallbackCandidates.find((candidate) => this.isDirectoryPath(candidate));
277
+
278
+ if (fallbackCwd) {
279
+ console.warn(`[PTY] Working directory not found: ${cwd}, fallback to ${fallbackCwd}`);
280
+ cwd = fallbackCwd;
281
+ } else {
274
282
  const error = `Working directory not found: ${cwd}`;
275
283
  console.error('[PTY]', error);
276
284
  throw new Error(error);
277
285
  }
278
- } catch (err) {
279
- const error = `Working directory not found: ${cwd}`;
280
- console.error('[PTY]', error);
281
- throw new Error(error);
282
286
  }
283
287
 
284
288
  console.log(`[PTY] Creating terminal: shell=${shell}, cwd=${cwd}`);
@@ -15,9 +15,6 @@ const { pipeline } = require('stream/promises');
15
15
  const AdmZip = require('adm-zip');
16
16
  const {
17
17
  parseSkillContent,
18
- detectSkillFormat,
19
- convertSkillToCodex,
20
- convertSkillToClaude
21
18
  } = require('./format-converter');
22
19
  const { NATIVE_PATHS } = require('../../config/paths');
23
20
 
@@ -705,25 +702,6 @@ class SkillService {
705
702
  return this.validateOpenCodeSkillMetadata(metadata, directory);
706
703
  }
707
704
 
708
- /**
709
- * 转换技能格式
710
- * @param {string} content - 技能内容
711
- * @param {string} targetFormat - 目标格式 ('claude' | 'codex')
712
- */
713
- convertSkillFormat(content, targetFormat) {
714
- const sourceFormat = detectSkillFormat(content);
715
-
716
- if (sourceFormat === targetFormat) {
717
- return { content, warnings: [], format: targetFormat };
718
- }
719
-
720
- if (targetFormat === 'codex') {
721
- return convertSkillToCodex(content);
722
- } else {
723
- return convertSkillToClaude(content);
724
- }
725
- }
726
-
727
705
  /**
728
706
  * 检查技能是否已安装
729
707
  */
@@ -880,9 +858,7 @@ class SkillService {
880
858
  fs.mkdirSync(dest, { recursive: true });
881
859
  this.copyDirRecursive(sourceDir, dest);
882
860
 
883
- if (this.platform === 'codex') {
884
- this.convertInstalledSkillToCodex(dest);
885
- } else if (this.platform === 'opencode') {
861
+ if (this.platform === 'opencode') {
886
862
  const skillMdPath = path.join(dest, 'SKILL.md');
887
863
  if (fs.existsSync(skillMdPath)) {
888
864
  const validationError = this.validateOpenCodeSkillContent(
@@ -976,22 +952,6 @@ class SkillService {
976
952
  }
977
953
  }
978
954
 
979
- /**
980
- * 将安装后的 SKILL.md 转换为 Codex 兼容格式
981
- */
982
- convertInstalledSkillToCodex(skillDir) {
983
- const skillMdPath = path.join(skillDir, 'SKILL.md');
984
- if (!fs.existsSync(skillMdPath)) return;
985
-
986
- try {
987
- const content = fs.readFileSync(skillMdPath, 'utf-8');
988
- const converted = convertSkillToCodex(content);
989
- fs.writeFileSync(skillMdPath, converted.content, 'utf-8');
990
- } catch (err) {
991
- console.warn('[SkillService] Convert skill to codex format failed:', err.message);
992
- }
993
- }
994
-
995
955
  /**
996
956
  * 创建自定义技能
997
957
  */
@@ -1051,10 +1011,6 @@ ${content}
1051
1011
  // 写入文件
1052
1012
  fs.writeFileSync(path.join(dest, 'SKILL.md'), skillMdContent, 'utf-8');
1053
1013
 
1054
- if (this.platform === 'codex') {
1055
- this.convertInstalledSkillToCodex(dest);
1056
- }
1057
-
1058
1014
  // 清除缓存,让列表刷新
1059
1015
  this.skillsCache = null;
1060
1016
  this.cacheTime = 0;
@@ -1122,10 +1078,6 @@ ${content}
1122
1078
  }
1123
1079
  }
1124
1080
 
1125
- if (this.platform === 'codex') {
1126
- this.convertInstalledSkillToCodex(dest);
1127
- }
1128
-
1129
1081
  // 清除缓存
1130
1082
  this.skillsCache = null;
1131
1083
  this.cacheTime = 0;
@@ -12,6 +12,7 @@ const { getEffectiveApiKey: getClaudeEffectiveApiKey } = require('./channels');
12
12
  const { getEffectiveApiKey: getCodexEffectiveApiKey } = require('./codex-channels');
13
13
  const { getEffectiveApiKey: getGeminiEffectiveApiKey } = require('./gemini-channels');
14
14
  const { getEffectiveApiKey: getOpenCodeEffectiveApiKey } = require('./opencode-channels');
15
+ const { getDefaultSpeedTestModelByToolType } = require('../../config/model-metadata');
15
16
 
16
17
  // 测试结果缓存
17
18
  const testResultsCache = new Map();
@@ -87,6 +88,21 @@ function resolveExplicitModel(channel, model) {
87
88
  );
88
89
  }
89
90
 
91
+ function resolveToolTypeForSpeedTest(channelType, channel) {
92
+ if (channelType === 'claude' || channelType === 'codex' || channelType === 'gemini') {
93
+ return channelType;
94
+ }
95
+ const gatewaySourceType = normalizeNonEmptyString(channel?.gatewaySourceType);
96
+ if (gatewaySourceType === 'claude' || gatewaySourceType === 'codex' || gatewaySourceType === 'gemini') {
97
+ return gatewaySourceType;
98
+ }
99
+ return 'codex';
100
+ }
101
+
102
+ function getConfiguredDefaultSpeedTestModel(toolType) {
103
+ return normalizeNonEmptyString(getDefaultSpeedTestModelByToolType(toolType));
104
+ }
105
+
90
106
  function resolveEffectiveApiKey(channel, channelType) {
91
107
  switch (channelType) {
92
108
  case 'codex':
@@ -441,6 +457,21 @@ async function testAPIFunctionality(baseUrl, apiKey, timeout, channelType = 'cla
441
457
  };
442
458
  console.log(`[SpeedTest] Using explicit model: ${explicitModel}`);
443
459
  } else {
460
+ const defaultSpeedTestModel = getConfiguredDefaultSpeedTestModel(
461
+ resolveToolTypeForSpeedTest(channelType, channel)
462
+ );
463
+ if (defaultSpeedTestModel) {
464
+ modelProbe = {
465
+ preferredTestModel: defaultSpeedTestModel,
466
+ availableModels: [defaultSpeedTestModel],
467
+ cached: false,
468
+ method: 'default_config'
469
+ };
470
+ console.log(`[SpeedTest] Using default speedTestModel from config: ${defaultSpeedTestModel}`);
471
+ }
472
+ }
473
+
474
+ if (!modelProbe) {
444
475
  // Fall back to auto-detection
445
476
  try {
446
477
  modelProbe = await probeModelAvailability(channel, channelType, { stopOnFirstAvailable: true });
@@ -512,7 +543,9 @@ async function testAPIFunctionality(baseUrl, apiKey, timeout, channelType = 'cla
512
543
  }
513
544
  apiPath += '?beta=true';
514
545
 
515
- testModel = modelProbe?.preferredTestModel || normalizeNonEmptyString(model) || 'claude-sonnet-4-20250514';
546
+ testModel = modelProbe?.preferredTestModel
547
+ || normalizeNonEmptyString(model)
548
+ || getConfiguredDefaultSpeedTestModel('claude');
516
549
  const sessionId = Math.random().toString(36).substring(2, 15);
517
550
  primaryRequestConfig = {
518
551
  apiPath,
@@ -550,7 +583,9 @@ async function testAPIFunctionality(baseUrl, apiKey, timeout, channelType = 'cla
550
583
  };
551
584
  } else if (channelType === 'codex') {
552
585
  const apiPath = buildCodexResponsesPath(parsedUrl);
553
- testModel = modelProbe?.preferredTestModel || normalizeNonEmptyString(model) || 'gpt-5-codex';
586
+ testModel = modelProbe?.preferredTestModel
587
+ || normalizeNonEmptyString(model)
588
+ || getConfiguredDefaultSpeedTestModel('codex');
554
589
  const codexSessionId = `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
555
590
 
556
591
  const baseBody = {
@@ -588,7 +623,9 @@ async function testAPIFunctionality(baseUrl, apiKey, timeout, channelType = 'cla
588
623
  isStreamingResponse: true
589
624
  };
590
625
  } else if (channelType === 'gemini') {
591
- testModel = modelProbe?.preferredTestModel || normalizeNonEmptyString(model) || 'gemini-2.5-pro';
626
+ testModel = modelProbe?.preferredTestModel
627
+ || normalizeNonEmptyString(model)
628
+ || getConfiguredDefaultSpeedTestModel('gemini');
592
629
  const useCliFormat = shouldUseGeminiCliFormat(parsedUrl);
593
630
 
594
631
  const cliRequestConfig = {
@@ -378,9 +378,246 @@ function getTodayStatistics() {
378
378
  return loadDailyStats(today);
379
379
  }
380
380
 
381
+ /**
382
+ * 从统计对象中提取指定指标值
383
+ */
384
+ function extractMetric(stats, metric) {
385
+ if (!stats) return 0;
386
+ if (metric === 'tokens') return stats.tokens?.total || stats.tokens || 0;
387
+ if (metric === 'cost') return stats.cost || 0;
388
+ if (metric === 'requests') return stats.requests || 0;
389
+ return 0;
390
+ }
391
+
392
+ /**
393
+ * 从 JSONL 日志文件读取指定日期+小时的数据(按 model 或 channel 聚合)
394
+ */
395
+ function readJsonlForHour(year, month, day, hour, groupBy) {
396
+ const filePath = getRequestLogFilePath(year, month, day);
397
+ const result = {};
398
+
399
+ try {
400
+ if (!fs.existsSync(filePath)) return result;
401
+ const lines = fs.readFileSync(filePath, 'utf8').split('\n');
402
+
403
+ for (const line of lines) {
404
+ if (!line.trim()) continue;
405
+ let entry;
406
+ try { entry = JSON.parse(line); } catch { continue; }
407
+
408
+ const ts = new Date(entry.timestamp);
409
+ if (ts.getHours() !== hour) continue;
410
+
411
+ let key;
412
+ if (groupBy === 'model') key = entry.model || 'unknown';
413
+ else if (groupBy === 'channel') key = entry.channel || entry.channelId || 'unknown';
414
+ else continue;
415
+
416
+ if (!result[key]) result[key] = { tokens: { total: 0 }, cost: 0, requests: 0 };
417
+ result[key].tokens.total += entry.tokens?.total || 0;
418
+ result[key].cost += entry.cost || 0;
419
+ result[key].requests += 1;
420
+ }
421
+ } catch (err) {
422
+ console.error('Failed to read JSONL for hour:', err);
423
+ }
424
+
425
+ return result;
426
+ }
427
+
428
+ /**
429
+ * 获取趋势统计数据
430
+ * @param {Object} options
431
+ * @param {string} options.startDate - YYYY-MM-DD
432
+ * @param {string} options.endDate - YYYY-MM-DD
433
+ * @param {string} options.granularity - 'day' | 'hour'
434
+ * @param {string} options.groupBy - 'model' | 'channel' | 'toolType'
435
+ * @param {string} options.metric - 'tokens' | 'cost' | 'requests'
436
+ */
437
+
438
+ // 工具类型到 daily-stats 目录前缀的映射
439
+ const TOOL_PREFIXES = {
440
+ 'claude-code': '',
441
+ 'codex': 'codex-',
442
+ 'gemini': 'gemini-',
443
+ 'opencode': 'opencode-'
444
+ };
445
+
446
+ // 加载指定工具的某天统计(toolPrefix 为 '' | 'codex-' | 'gemini-' | 'opencode-')
447
+ function loadDailyStatsByTool(dateStr, toolPrefix) {
448
+ const dir = path.join(os.homedir(), '.cc-tool', `${toolPrefix}daily-stats`);
449
+ const filePath = path.join(dir, `${dateStr}.json`);
450
+ try {
451
+ if (fs.existsSync(filePath)) {
452
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
453
+ }
454
+ } catch (e) {}
455
+ return null;
456
+ }
457
+
458
+ // 合并所有工具的某天 daily-stats,groupBy 决定合并维度
459
+ // 对于 toolType 分组:key 就是工具名(claude-code/codex/gemini/opencode)
460
+ // 对于 model/channel 分组:合并各工具的 byModel/byChannel,key 可能重名(不同工具用同名模型)则加前缀
461
+ function mergeAllToolsDailyStats(dateStr, groupBy) {
462
+ const merged = {};
463
+
464
+ for (const [toolType, prefix] of Object.entries(TOOL_PREFIXES)) {
465
+ const stats = loadDailyStatsByTool(dateStr, prefix);
466
+ if (!stats) continue;
467
+
468
+ if (groupBy === 'toolType') {
469
+ // key = toolType,汇总整天 summary
470
+ if (!merged[toolType]) merged[toolType] = { requests: 0, tokens: { total: 0 }, cost: 0 };
471
+ const s = stats.summary || {};
472
+ merged[toolType].requests += s.requests || 0;
473
+ merged[toolType].tokens.total += (s.tokens && typeof s.tokens === 'object' ? s.tokens.total : s.tokens) || 0;
474
+ merged[toolType].cost += s.cost || 0;
475
+ } else if (groupBy === 'model') {
476
+ const byModel = stats.byModel || {};
477
+ for (const [model, mStats] of Object.entries(byModel)) {
478
+ if (!merged[model]) merged[model] = { requests: 0, tokens: { total: 0 }, cost: 0 };
479
+ merged[model].requests += mStats.requests || 0;
480
+ const t = mStats.tokens || {};
481
+ merged[model].tokens.total += (typeof t === 'object' ? t.total : t) || 0;
482
+ merged[model].cost += mStats.cost || 0;
483
+ }
484
+ } else if (groupBy === 'channel') {
485
+ const byChannel = stats.byChannel || {};
486
+ for (const [chId, chStats] of Object.entries(byChannel)) {
487
+ const key = chStats.name || chId;
488
+ if (!merged[key]) merged[key] = { requests: 0, tokens: { total: 0 }, cost: 0 };
489
+ merged[key].requests += chStats.requests || 0;
490
+ const t = chStats.tokens || {};
491
+ merged[key].tokens.total += (typeof t === 'object' ? t.total : t) || 0;
492
+ merged[key].cost += chStats.cost || 0;
493
+ }
494
+ }
495
+ }
496
+ return merged;
497
+ }
498
+
499
+ async function getTrendStatistics({ startDate, endDate, granularity = 'day', step = 1, groupBy = 'model', metric = 'tokens' }) {
500
+ step = parseInt(step) || 1;
501
+ const labels = [];
502
+ const seriesMap = {}; // { dimensionName: number[] }
503
+ const totals = {};
504
+
505
+ const start = new Date(startDate + 'T00:00:00');
506
+ const end = new Date(endDate + 'T00:00:00');
507
+
508
+ // Iterate each day
509
+ for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
510
+ const year = d.getFullYear();
511
+ const month = d.getMonth() + 1;
512
+ const day = d.getDate();
513
+ const dateStr = `${year}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`;
514
+
515
+ if (granularity === 'day') {
516
+ labels.push(dateStr);
517
+ const byDimension = mergeAllToolsDailyStats(dateStr, groupBy);
518
+
519
+ // Accumulate dimensions seen so far with 0 for this label position
520
+ const labelIdx = labels.length - 1;
521
+
522
+ // Fill existing series with 0 for this position first
523
+ for (const key of Object.keys(seriesMap)) {
524
+ seriesMap[key].push(0);
525
+ }
526
+
527
+ for (const [key, stats] of Object.entries(byDimension)) {
528
+ const val = extractMetric(stats, metric);
529
+ if (!seriesMap[key]) {
530
+ // New dimension: backfill with zeros for previous labels
531
+ seriesMap[key] = new Array(labelIdx).fill(0);
532
+ seriesMap[key].push(val);
533
+ } else {
534
+ // Already pushed 0 above, replace last element
535
+ seriesMap[key][labelIdx] = val;
536
+ }
537
+ totals[key] = (totals[key] || 0) + val;
538
+ }
539
+ } else {
540
+ // granularity === 'hour'
541
+ for (let h = 0; h < 24; h += step) {
542
+ const hourEnd = Math.min(h + step, 24);
543
+ const hourStr = h.toString().padStart(2, '0');
544
+ const label = step === 1
545
+ ? `${dateStr} ${hourStr}:00`
546
+ : `${dateStr} ${hourStr}:00-${String(hourEnd).padStart(2, '0')}:00`;
547
+ labels.push(label);
548
+ const labelIdx = labels.length - 1;
549
+
550
+ // Fill existing series with 0 for this label
551
+ for (const key of Object.keys(seriesMap)) {
552
+ seriesMap[key].push(0);
553
+ }
554
+
555
+ // Accumulate all hours in this step bucket
556
+ for (let hh = h; hh < hourEnd; hh++) {
557
+ const hhStr = hh.toString().padStart(2, '0');
558
+ let byDimension = {};
559
+
560
+ if (groupBy === 'toolType') {
561
+ // 合并所有工具的该小时数据
562
+ for (const [toolType, prefix] of Object.entries(TOOL_PREFIXES)) {
563
+ const ds = loadDailyStatsByTool(dateStr, prefix);
564
+ const hourData = ds?.hourly?.[hhStr];
565
+ const val = hourData ? extractMetric(hourData, metric) : 0;
566
+ if (val > 0) {
567
+ if (!byDimension[toolType]) byDimension[toolType] = { requests: 0, tokens: { total: 0 }, cost: 0 };
568
+ byDimension[toolType].requests += hourData.requests || 0;
569
+ byDimension[toolType].tokens.total += (hourData.tokens?.total || 0);
570
+ byDimension[toolType].cost += hourData.cost || 0;
571
+ }
572
+ }
573
+ } else {
574
+ byDimension = readJsonlForHour(year, month, day, hh, groupBy);
575
+ }
576
+
577
+ for (const [key, stats] of Object.entries(byDimension)) {
578
+ const val = extractMetric(stats, metric);
579
+ if (!seriesMap[key]) {
580
+ seriesMap[key] = new Array(labelIdx).fill(0);
581
+ seriesMap[key].push(val);
582
+ } else {
583
+ if (seriesMap[key].length <= labelIdx) seriesMap[key].push(0);
584
+ seriesMap[key][labelIdx] = (seriesMap[key][labelIdx] || 0) + val;
585
+ }
586
+ totals[key] = (totals[key] || 0) + val;
587
+ }
588
+ } // end hh loop
589
+ } // end h loop
590
+ } // end else (hour granularity)
591
+ } // end for day loop
592
+
593
+ // Sort series by total desc, keep top 10, merge rest into 'Other'
594
+ const sorted = Object.entries(totals).sort((a, b) => b[1] - a[1]);
595
+ const top10 = sorted.slice(0, 10);
596
+ const rest = sorted.slice(10);
597
+
598
+ const series = top10.map(([name]) => ({
599
+ name,
600
+ data: seriesMap[name] || []
601
+ }));
602
+
603
+ if (rest.length > 0) {
604
+ const otherData = labels.map((_, i) =>
605
+ rest.reduce((sum, [name]) => sum + (seriesMap[name]?.[i] || 0), 0)
606
+ );
607
+ const otherTotal = rest.reduce((sum, [, total]) => sum + total, 0);
608
+ series.push({ name: 'Other', data: otherData });
609
+ totals['Other'] = otherTotal;
610
+ // Remove merged keys from totals
611
+ for (const [name] of rest) delete totals[name];
612
+ }
613
+
614
+ return { labels, series, totals };
615
+ }
616
+
381
617
  module.exports = {
382
618
  recordRequest,
383
619
  getStatistics,
384
620
  getDailyStatistics,
385
- getTodayStatistics
621
+ getTodayStatistics,
622
+ getTrendStatistics
386
623
  };
@@ -1,79 +1,70 @@
1
1
  const { loadConfig } = require('../../config/loader');
2
- const DEFAULT_CONFIG = require('../../config/default');
3
- const { CLAUDE_MODEL_PRICING, CLAUDE_MODEL_ALIASES } = require('../../config/model-pricing');
2
+ const { resolveModelMetadata } = require('../../config/model-metadata');
4
3
 
5
- const RATE_KEYS = ['input', 'output', 'cacheCreation', 'cacheRead', 'cached', 'reasoning'];
6
-
7
- function getPricingConfig(toolKey) {
8
- try {
9
- const config = loadConfig();
10
- if (config.pricing && config.pricing[toolKey]) {
11
- return config.pricing[toolKey];
12
- }
13
- } catch (err) {
14
- console.error('[Pricing] Failed to load pricing config:', err);
15
- }
16
- return DEFAULT_CONFIG.pricing[toolKey];
4
+ function normalizeModelId(model) {
5
+ return String(model || '').trim().toLowerCase();
17
6
  }
18
7
 
19
- function resolvePricing(toolKey, modelPricing = {}, defaultPricing = {}) {
20
- const base = { ...defaultPricing, ...(modelPricing || {}) };
21
- const pricingConfig = getPricingConfig(toolKey);
8
+ function getModelMetadataOverride(overrides, model) {
9
+ if (!overrides || typeof overrides !== 'object') return null;
10
+ const modelId = normalizeModelId(model);
11
+ if (!modelId) return null;
22
12
 
23
- if (!pricingConfig) {
24
- return base;
13
+ let directMatch = null;
14
+ for (const [id, override] of Object.entries(overrides)) {
15
+ if (normalizeModelId(id) === modelId) {
16
+ directMatch = override;
17
+ break;
18
+ }
25
19
  }
20
+ if (directMatch) return directMatch;
26
21
 
27
- if (pricingConfig.mode === 'custom') {
28
- const result = { ...base };
29
- RATE_KEYS.forEach((key) => {
30
- if (typeof pricingConfig[key] === 'number' && Number.isFinite(pricingConfig[key])) {
31
- result[key] = pricingConfig[key];
22
+ let bestMatch = null;
23
+ let bestLen = 0;
24
+ for (const [id, override] of Object.entries(overrides)) {
25
+ const key = normalizeModelId(id);
26
+ if (!key) continue;
27
+ if (modelId.startsWith(key) || key.startsWith(modelId)) {
28
+ if (key.length > bestLen) {
29
+ bestLen = key.length;
30
+ bestMatch = override;
32
31
  }
33
- });
34
- return result;
32
+ }
35
33
  }
36
-
37
- return base;
34
+ return bestMatch;
38
35
  }
39
36
 
40
- function resolveModelPricing(toolKey, model, hardcodedPricing = {}, defaultPricing = {}) {
41
- const config = getPricingConfig(toolKey);
37
+ function resolveMetadataPricing(model) {
38
+ if (!model) return null;
42
39
 
43
- // 1. Check user custom config for specific model first
44
- const modelConfig = config?.models?.[model];
45
- if (modelConfig && modelConfig.mode === 'custom') {
46
- const result = { ...hardcodedPricing };
47
- RATE_KEYS.forEach((key) => {
48
- if (typeof modelConfig[key] === 'number' && Number.isFinite(modelConfig[key])) {
49
- result[key] = modelConfig[key];
50
- }
51
- });
52
- return result;
53
- }
40
+ const builtInPricing = resolveModelMetadata(model)?.pricing || null;
54
41
 
55
- // 2. Check user custom config for tool-level
56
- if (config && config.mode === 'custom') {
57
- const result = { ...hardcodedPricing };
58
- RATE_KEYS.forEach((key) => {
59
- if (typeof config[key] === 'number' && Number.isFinite(config[key])) {
60
- result[key] = config[key];
61
- }
62
- });
63
- return result;
42
+ try {
43
+ const config = loadConfig();
44
+ const override = getModelMetadataOverride(config.modelMetadataOverrides, model);
45
+ const overridePricing = override?.pricing || null;
46
+ if (!builtInPricing && !overridePricing) return null;
47
+ return {
48
+ ...(builtInPricing || {}),
49
+ ...(overridePricing || {})
50
+ };
51
+ } catch (err) {
52
+ console.error('[Pricing] Failed to load model metadata overrides:', err);
53
+ return builtInPricing;
64
54
  }
55
+ }
65
56
 
66
- // 3. Use centralized hardcoded pricing for known models (mode: 'auto')
67
- // Normalize model name using aliases
68
- const normalizedModel = CLAUDE_MODEL_ALIASES[model] || model;
69
- const centralizedPricing = CLAUDE_MODEL_PRICING[normalizedModel];
70
-
71
- if (centralizedPricing) {
72
- return { ...defaultPricing, ...centralizedPricing };
73
- }
57
+ function resolvePricing(_toolKey, modelPricing = {}, defaultPricing = {}) {
58
+ return { ...defaultPricing, ...(modelPricing || {}) };
59
+ }
74
60
 
75
- // 4. Fall back to base pricing for unknown models
76
- return { ...defaultPricing, ...hardcodedPricing };
61
+ function resolveModelPricing(_toolKey, model, fallbackPricing = {}, defaultPricing = {}) {
62
+ const pricingFromMetadata = resolveMetadataPricing(model);
63
+ return {
64
+ ...defaultPricing,
65
+ ...(fallbackPricing || {}),
66
+ ...(pricingFromMetadata || {})
67
+ };
77
68
  }
78
69
 
79
70
  module.exports = {
@@ -1 +0,0 @@
1
- .session-list-container[data-v-5b86eea5]{width:100%;height:100%;display:flex;flex-direction:column;box-sizing:border-box}.header[data-v-5b86eea5]{flex-shrink:0;padding:24px 24px 16px;background:var(--bg-primary);border-bottom:1px solid var(--border-primary)}.content[data-v-5b86eea5]{flex:1;overflow-y:auto;padding:16px 24px 24px}.back-button[data-v-5b86eea5]{flex-shrink:0;margin-right:12px}.title-bar[data-v-5b86eea5]{display:flex;align-items:center;gap:16px}.title-section[data-v-5b86eea5]{flex:1;min-width:0}.title-with-count[data-v-5b86eea5]{display:flex;align-items:baseline;gap:8px;margin-bottom:2px}.title-section h2[data-v-5b86eea5]{margin:0;font-size:20px}.session-count[data-v-5b86eea5]{font-size:14px;color:#666}.total-size-tag[data-v-5b86eea5]{margin-left:8px}.project-path[data-v-5b86eea5]{font-size:13px;display:block;color:#666;margin-bottom:2px}.search-input[data-v-5b86eea5]{width:320px;flex-shrink:0}.loading-container[data-v-5b86eea5]{display:flex;justify-content:center;align-items:center;min-height:400px}.session-item[data-v-5b86eea5]{display:flex;align-items:center;gap:12px;padding:16px;background:var(--bg-primary);border:1px solid var(--border-primary);border-radius:8px;margin-bottom:8px;transition:all .2s;cursor:pointer}.session-item[data-v-5b86eea5]:hover{border-color:#18a058;box-shadow:0 2px 8px #18a0581a}.drag-handle[data-v-5b86eea5]{cursor:move;width:24px;height:24px;padding:4px;opacity:.4;transition:all .2s;flex-shrink:0;display:flex;align-items:center;justify-content:center}.session-item:hover .drag-handle[data-v-5b86eea5]{opacity:1;background-color:#18a0581a;border-radius:4px}.session-left[data-v-5b86eea5]{flex:1;display:flex;align-items:center;gap:16px;min-width:0}.session-icon[data-v-5b86eea5]{flex-shrink:0}.session-info[data-v-5b86eea5]{flex:1;min-width:0}.session-header[data-v-5b86eea5]{display:flex;align-items:center;margin-bottom:6px}.session-title-row[data-v-5b86eea5]{display:flex;align-items:center;gap:8px}.session-title[data-v-5b86eea5]{font-size:15px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex-shrink:1;min-width:0}.session-meta[data-v-5b86eea5]{display:flex;align-items:center;gap:8px;margin-bottom:6px;font-size:13px}.session-message[data-v-5b86eea5]{display:block;max-width:600px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px}.session-message-empty[data-v-5b86eea5]{font-style:italic;opacity:.5}.session-right[data-v-5b86eea5]{display:flex;flex-direction:column;justify-content:flex-start;align-items:flex-end;min-width:280px;flex-shrink:0;gap:12px}.session-tags-area[data-v-5b86eea5]{min-height:24px;display:flex;align-items:flex-start;justify-content:flex-end}.session-actions[data-v-5b86eea5]{display:flex;align-items:center;margin-top:auto}.ghost[data-v-5b86eea5]{opacity:.4}.chosen[data-v-5b86eea5]{box-shadow:0 4px 16px #00000026}.search-result-item[data-v-5b86eea5]{margin-bottom:16px;padding:12px;border:1px solid var(--border-primary);border-radius:6px;background:var(--bg-elevated)}.search-result-header[data-v-5b86eea5]{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}.search-result-title[data-v-5b86eea5]{display:flex;align-items:center;gap:8px}.search-match[data-v-5b86eea5]{display:flex;align-items:flex-start;gap:8px;margin-top:6px;padding:6px;background:var(--bg-secondary);border-radius:4px}.search-match-text[data-v-5b86eea5]{flex:1;line-height:1.6}