@roastcodes/ttdash 6.1.5 → 6.1.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.
@@ -1,5 +1,15 @@
1
1
  const { version: APP_VERSION } = require('../../package.json');
2
2
  const { getLanguage, getLocale, translate } = require('./i18n');
3
+ const modelNormalizationSpec = require('../model-normalization.json');
4
+
5
+ const DISPLAY_ALIASES = modelNormalizationSpec.displayAliases.map((alias) => ({
6
+ ...alias,
7
+ matcher: new RegExp(alias.pattern, 'i'),
8
+ }));
9
+ const PROVIDER_MATCHERS = modelNormalizationSpec.providerMatchers.map((matcher) => ({
10
+ ...matcher,
11
+ matcher: new RegExp(matcher.pattern, 'i'),
12
+ }));
3
13
 
4
14
  const MODEL_COLORS = {
5
15
  'Opus 4.6': 'rgb(175, 92, 224)',
@@ -10,8 +20,8 @@ const MODEL_COLORS = {
10
20
  'GPT-5.4': 'rgb(230, 98, 56)',
11
21
  'GPT-5': 'rgb(230, 98, 56)',
12
22
  'Gemini 3 Flash Preview': 'rgb(237, 188, 8)',
13
- 'Gemini': 'rgb(237, 188, 8)',
14
- 'OpenCode': 'rgb(51, 181, 193)',
23
+ Gemini: 'rgb(237, 188, 8)',
24
+ OpenCode: 'rgb(51, 181, 193)',
15
25
  };
16
26
 
17
27
  const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
@@ -23,58 +33,158 @@ function titleCaseSegment(segment) {
23
33
  return segment.charAt(0).toUpperCase() + segment.slice(1);
24
34
  }
25
35
 
26
- function normalizeModelName(raw) {
27
- const lower = String(raw || '').toLowerCase().trim();
28
- if (lower.includes('gpt-5-4') || lower.includes('gpt-5.4')) return 'GPT-5.4';
29
- if (lower.includes('gpt-5')) return 'GPT-5';
30
- if (lower.includes('opus-4-6') || lower.includes('opus-4.6')) return 'Opus 4.6';
31
- if (lower.includes('opus-4-5') || lower.includes('opus-4.5')) return 'Opus 4.5';
32
- if (lower.includes('sonnet-4-6') || lower.includes('sonnet-4.6')) return 'Sonnet 4.6';
33
- if (lower.includes('sonnet-4-5') || lower.includes('sonnet-4.5')) return 'Sonnet 4.5';
34
- if (lower.includes('haiku-4-5') || lower.includes('haiku-4.5')) return 'Haiku 4.5';
35
- if (lower.includes('gemini-3-flash-preview')) return 'Gemini 3 Flash Preview';
36
- if (lower.includes('gemini')) return 'Gemini';
37
- if (lower.includes('opencode')) return 'OpenCode';
38
- if (lower.includes('haiku')) return 'Haiku';
39
-
40
- const stripped = String(raw || '')
36
+ function capitalize(segment) {
37
+ if (!segment) return '';
38
+ return segment.charAt(0).toUpperCase() + segment.slice(1);
39
+ }
40
+
41
+ function formatVersion(version) {
42
+ return version.replace(/-/g, '.');
43
+ }
44
+
45
+ function canonicalizeModelName(raw) {
46
+ const normalized = String(raw || '')
41
47
  .trim()
42
- .replace(/^(claude|anthropic|openai|google|vertex|models)\//i, '')
43
- .replace(/^(claude|anthropic|openai|google|vertex|models)-/i, '')
48
+ .toLowerCase()
44
49
  .replace(/^model[:/ -]*/i, '')
50
+ .replace(/^(anthropic|openai|google|vertex|models)[/-]/i, '')
51
+ .replace(/\./g, '-')
45
52
  .replace(/[_/]+/g, '-')
46
53
  .replace(/\s+/g, '-')
47
54
  .replace(/-{2,}/g, '-')
48
55
  .replace(/^-|-$/g, '');
49
56
 
50
- const familyMatch = stripped.match(/(gpt|opus|sonnet|haiku|gemini|o\d|oai|grok|llama|mistral|command|deepseek|qwen)[- ]?([a-z0-9.-]+)?/i);
57
+ const suffixStart = normalized.lastIndexOf('-');
58
+ if (suffixStart > 0) {
59
+ const suffix = normalized.slice(suffixStart + 1);
60
+ if (suffix.length === 8 && suffix.startsWith('20') && /^\d+$/.test(suffix)) {
61
+ return normalized.slice(0, suffixStart);
62
+ }
63
+ }
64
+
65
+ return normalized;
66
+ }
67
+
68
+ function parseClaudeName(rest) {
69
+ const parts = rest.split('-', 2);
70
+ if (parts.length < 2) {
71
+ return `Claude ${capitalize(rest)}`;
72
+ }
73
+
74
+ return `${capitalize(parts[0] || '')} ${formatVersion(parts[1] || '')}`.trim();
75
+ }
76
+
77
+ function parseGptName(rest) {
78
+ const parts = rest.split('-');
79
+ const variant = parts[0] || '';
80
+ const minor = parts[1] || '';
81
+
82
+ if (minor && minor.length <= 2 && /^\d+$/.test(minor)) {
83
+ const version = `${variant}.${minor}`;
84
+ if (parts.length > 2) {
85
+ const suffix = parts.slice(2).map(capitalize).join(' ');
86
+ return `GPT-${version}${suffix ? ` ${suffix}` : ''}`;
87
+ }
88
+ return `GPT-${version}`;
89
+ }
90
+
91
+ if (parts.length > 1) {
92
+ const suffix = parts.slice(1).map(capitalize).join(' ');
93
+ return `GPT-${variant}${suffix ? ` ${suffix}` : ''}`;
94
+ }
95
+
96
+ return `GPT-${rest}`;
97
+ }
98
+
99
+ function parseGeminiName(rest) {
100
+ const parts = rest.split('-');
101
+ if (parts.length < 2) {
102
+ return `Gemini ${rest}`;
103
+ }
104
+
105
+ const versionParts = [];
106
+ const tierParts = [];
107
+
108
+ for (const part of parts) {
109
+ if (/^\d+$/.test(part) && tierParts.length === 0) {
110
+ versionParts.push(part);
111
+ } else {
112
+ tierParts.push(capitalize(part));
113
+ }
114
+ }
115
+
116
+ const version = versionParts.join('.');
117
+ const tier = tierParts.join(' ');
118
+
119
+ return tier ? `Gemini ${version} ${tier}` : `Gemini ${version}`;
120
+ }
121
+
122
+ function parseCodexName(rest) {
123
+ const normalized = rest.replace(/-latest$/i, '');
124
+ if (!normalized) {
125
+ return 'Codex';
126
+ }
127
+ return `Codex ${normalized.split('-').map(capitalize).join(' ')}`;
128
+ }
129
+
130
+ function parseOSeries(name) {
131
+ const separatorIndex = name.indexOf('-');
132
+ if (separatorIndex === -1) {
133
+ return name;
134
+ }
135
+ return `${name.slice(0, separatorIndex)} ${capitalize(name.slice(separatorIndex + 1))}`;
136
+ }
137
+
138
+ function normalizeModelName(raw) {
139
+ const canonical = canonicalizeModelName(raw);
140
+
141
+ for (const alias of DISPLAY_ALIASES) {
142
+ if (alias.matcher.test(canonical)) return alias.name;
143
+ }
144
+
145
+ if (canonical.startsWith('claude-')) {
146
+ return parseClaudeName(canonical.slice('claude-'.length));
147
+ }
148
+
149
+ if (canonical.startsWith('gpt-')) {
150
+ return parseGptName(canonical.slice('gpt-'.length));
151
+ }
152
+
153
+ if (canonical.startsWith('gemini-')) {
154
+ return parseGeminiName(canonical.slice('gemini-'.length));
155
+ }
156
+
157
+ if (canonical.startsWith('codex-')) {
158
+ return parseCodexName(canonical.slice('codex-'.length));
159
+ }
160
+
161
+ if (/^o\d/i.test(canonical)) {
162
+ return parseOSeries(canonical);
163
+ }
164
+
165
+ const familyMatch = canonical.match(
166
+ /^(gpt|opus|sonnet|haiku|gemini|codex|o\d|oai|grok|llama|mistral|command|deepseek|qwen)(?:-([a-z0-9-]+))?$/i,
167
+ );
51
168
  if (familyMatch) {
52
169
  const family = familyMatch[1];
53
- const suffix = familyMatch[2] ? familyMatch[2].replace(/-/g, '.') : '';
170
+ if (/^codex$/i.test(family)) {
171
+ return parseCodexName(familyMatch[2] || '');
172
+ }
173
+ if (/^(o\d)$/i.test(family)) return parseOSeries(canonical);
174
+
175
+ const suffix = familyMatch[2] ? formatVersion(familyMatch[2]) : '';
54
176
  if (/^gpt$/i.test(family) && suffix) return `GPT-${suffix.toUpperCase()}`;
55
- if (/^(o\d)$/i.test(family)) return family.toUpperCase();
56
177
  return `${titleCaseSegment(family)}${suffix ? ` ${suffix}` : ''}`.trim();
57
178
  }
58
179
 
59
- return stripped
60
- .split('-')
61
- .filter(Boolean)
62
- .map(titleCaseSegment)
63
- .join(' ') || String(raw || '');
180
+ return canonical.split('-').filter(Boolean).map(titleCaseSegment).join(' ') || String(raw || '');
64
181
  }
65
182
 
66
183
  function getModelProvider(raw) {
67
- const lower = String(raw || '').toLowerCase();
68
- if (lower.includes('gpt') || lower.includes('openai') || lower.includes('/o1') || lower.includes('/o3') || /\bo\d\b/.test(lower)) return 'OpenAI';
69
- if (lower.includes('claude') || lower.includes('opus') || lower.includes('sonnet') || lower.includes('haiku')) return 'Anthropic';
70
- if (lower.includes('gemini')) return 'Google';
71
- if (lower.includes('grok') || lower.includes('xai')) return 'xAI';
72
- if (lower.includes('llama') || lower.includes('meta-llama') || lower.includes('meta/')) return 'Meta';
73
- if (lower.includes('command') || lower.includes('cohere')) return 'Cohere';
74
- if (lower.includes('mistral')) return 'Mistral';
75
- if (lower.includes('deepseek')) return 'DeepSeek';
76
- if (lower.includes('qwen') || lower.includes('alibaba')) return 'Alibaba';
77
- if (lower.includes('opencode')) return 'OpenCode';
184
+ const canonical = canonicalizeModelName(raw);
185
+ for (const matcher of PROVIDER_MATCHERS) {
186
+ if (matcher.matcher.test(canonical)) return matcher.provider;
187
+ }
78
188
  return 'Other';
79
189
  }
80
190
 
@@ -127,7 +237,8 @@ function recalculateDayFromBreakdowns(day, modelBreakdowns) {
127
237
  thinkingTokens,
128
238
  totalCost,
129
239
  requestCount,
130
- totalTokens: inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens + thinkingTokens,
240
+ totalTokens:
241
+ inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens + thinkingTokens,
131
242
  modelsUsed: modelBreakdowns.map((item) => item.modelName),
132
243
  modelBreakdowns,
133
244
  };
@@ -138,8 +249,12 @@ function filterByProviders(data, selectedProviders) {
138
249
  const selected = new Set(selectedProviders);
139
250
  return data
140
251
  .map((day) => {
141
- const filteredBreakdowns = day.modelBreakdowns.filter((entry) => selected.has(getModelProvider(entry.modelName)));
142
- return filteredBreakdowns.length > 0 ? recalculateDayFromBreakdowns(day, filteredBreakdowns) : null;
252
+ const filteredBreakdowns = day.modelBreakdowns.filter((entry) =>
253
+ selected.has(getModelProvider(entry.modelName)),
254
+ );
255
+ return filteredBreakdowns.length > 0
256
+ ? recalculateDayFromBreakdowns(day, filteredBreakdowns)
257
+ : null;
143
258
  })
144
259
  .filter(Boolean);
145
260
  }
@@ -149,17 +264,19 @@ function filterByModels(data, selectedModels) {
149
264
  const selected = new Set(selectedModels);
150
265
  return data
151
266
  .map((day) => {
152
- const filteredBreakdowns = day.modelBreakdowns.filter((entry) => selected.has(normalizeModelName(entry.modelName)));
153
- return filteredBreakdowns.length > 0 ? recalculateDayFromBreakdowns(day, filteredBreakdowns) : null;
267
+ const filteredBreakdowns = day.modelBreakdowns.filter((entry) =>
268
+ selected.has(normalizeModelName(entry.modelName)),
269
+ );
270
+ return filteredBreakdowns.length > 0
271
+ ? recalculateDayFromBreakdowns(day, filteredBreakdowns)
272
+ : null;
154
273
  })
155
274
  .filter(Boolean);
156
275
  }
157
276
 
158
277
  function aggregateToDailyFormat(data, viewMode) {
159
278
  if (viewMode === 'daily') return data;
160
- const groupKey = viewMode === 'monthly'
161
- ? (date) => date.slice(0, 7)
162
- : (date) => date.slice(0, 4);
279
+ const groupKey = viewMode === 'monthly' ? (date) => date.slice(0, 7) : (date) => date.slice(0, 4);
163
280
  const groups = new Map();
164
281
 
165
282
  for (const day of data) {
@@ -239,7 +356,8 @@ function toWeekdayData(data) {
239
356
  }
240
357
  return WEEKDAYS.map((label, index) => {
241
358
  const values = weekdayCosts[index];
242
- const average = values.length > 0 ? values.reduce((sum, value) => sum + value, 0) / values.length : 0;
359
+ const average =
360
+ values.length > 0 ? values.reduce((sum, value) => sum + value, 0) / values.length : 0;
243
361
  return { day: label, cost: average };
244
362
  });
245
363
  }
@@ -338,7 +456,8 @@ function computeMetrics(data) {
338
456
  totalCacheCreate += day.cacheCreationTokens;
339
457
  totalThinking += day.thinkingTokens;
340
458
  activeDays += day._aggregatedDays || 1;
341
- if (day.requestCount > 0 || day.modelBreakdowns.some((entry) => entry.requestCount > 0)) hasRequestData = true;
459
+ if (day.requestCount > 0 || day.modelBreakdowns.some((entry) => entry.requestCount > 0))
460
+ hasRequestData = true;
342
461
  if (day.totalCost > topDay.cost) topDay = { date: day.date, cost: day.totalCost };
343
462
  if (day.totalCost < cheapestDay.cost) cheapestDay = { date: day.date, cost: day.totalCost };
344
463
 
@@ -416,7 +535,12 @@ function computeModelRows(data) {
416
535
  _dates: new Set(),
417
536
  };
418
537
  current.cost += breakdown.cost;
419
- current.tokens += breakdown.inputTokens + breakdown.outputTokens + breakdown.cacheCreationTokens + breakdown.cacheReadTokens + breakdown.thinkingTokens;
538
+ current.tokens +=
539
+ breakdown.inputTokens +
540
+ breakdown.outputTokens +
541
+ breakdown.cacheCreationTokens +
542
+ breakdown.cacheReadTokens +
543
+ breakdown.thinkingTokens;
420
544
  current.requests += breakdown.requestCount;
421
545
  if (!current._dates.has(day.date)) {
422
546
  current._dates.add(day.date);
@@ -454,7 +578,12 @@ function computeProviderRows(data) {
454
578
  _dates: new Set(),
455
579
  };
456
580
  current.cost += breakdown.cost;
457
- current.tokens += breakdown.inputTokens + breakdown.outputTokens + breakdown.cacheCreationTokens + breakdown.cacheReadTokens + breakdown.thinkingTokens;
581
+ current.tokens +=
582
+ breakdown.inputTokens +
583
+ breakdown.outputTokens +
584
+ breakdown.cacheCreationTokens +
585
+ breakdown.cacheReadTokens +
586
+ breakdown.thinkingTokens;
458
587
  current.requests += breakdown.requestCount;
459
588
  if (!current._dates.has(day.date)) {
460
589
  current._dates.add(day.date);
@@ -464,7 +593,13 @@ function computeProviderRows(data) {
464
593
  }
465
594
  }
466
595
  return Array.from(rows.values())
467
- .map(({ _dates, ...entry }) => entry)
596
+ .map((entry) => ({
597
+ name: entry.name,
598
+ cost: entry.cost,
599
+ tokens: entry.tokens,
600
+ requests: entry.requests,
601
+ days: entry.days,
602
+ }))
468
603
  .sort((a, b) => b.cost - a.cost);
469
604
  }
470
605
 
@@ -491,7 +626,12 @@ function formatDate(dateStr, mode = 'short', language = 'de') {
491
626
  }
492
627
  const date = new Date(`${dateStr}T00:00:00`);
493
628
  if (mode === 'long') {
494
- return date.toLocaleDateString(locale, { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' });
629
+ return date.toLocaleDateString(locale, {
630
+ weekday: 'short',
631
+ day: '2-digit',
632
+ month: '2-digit',
633
+ year: 'numeric',
634
+ });
495
635
  }
496
636
  return date.toLocaleDateString(locale, { day: '2-digit', month: '2-digit' });
497
637
  }
@@ -504,7 +644,10 @@ function formatDateAxis(dateStr, language = 'de') {
504
644
  const date = new Date(Number(year), Number(month) - 1);
505
645
  return date.toLocaleDateString(locale, { month: 'short', year: '2-digit' });
506
646
  }
507
- return new Date(`${dateStr}T00:00:00`).toLocaleDateString(locale, { day: '2-digit', month: '2-digit' });
647
+ return new Date(`${dateStr}T00:00:00`).toLocaleDateString(locale, {
648
+ day: '2-digit',
649
+ month: '2-digit',
650
+ });
508
651
  }
509
652
 
510
653
  function formatFilterValue(value, language = 'de') {
@@ -575,10 +718,12 @@ function formatCompactAxis(value, language = 'de') {
575
718
  return formatCompactNumber(value, language);
576
719
  }
577
720
 
578
- function summarizeSelection(values, language, { emptyKey, maxVisible = 3, normalize = (value) => value } = {}) {
579
- const normalized = (values || [])
580
- .map(normalize)
581
- .filter(Boolean);
721
+ function summarizeSelection(
722
+ values,
723
+ language,
724
+ { emptyKey, maxVisible = 3, normalize = (value) => value } = {},
725
+ ) {
726
+ const normalized = (values || []).map(normalize).filter(Boolean);
582
727
 
583
728
  if (normalized.length === 0) {
584
729
  return translate(language, emptyKey);
@@ -586,9 +731,8 @@ function summarizeSelection(values, language, { emptyKey, maxVisible = 3, normal
586
731
 
587
732
  const visible = normalized.slice(0, maxVisible);
588
733
  const hidden = normalized.length - visible.length;
589
- const suffix = hidden > 0
590
- ? ` ${translate(language, 'report.filters.andMore', { count: hidden })}`
591
- : '';
734
+ const suffix =
735
+ hidden > 0 ? ` ${translate(language, 'report.filters.andMore', { count: hidden })}` : '';
592
736
 
593
737
  return `${visible.join(', ')}${suffix}`;
594
738
  }
@@ -657,7 +801,10 @@ function periodUnit(viewMode, language = 'de') {
657
801
 
658
802
  function applyReportFilters(allDailyData, filters) {
659
803
  const sorted = sortByDate(allDailyData);
660
- const preProvider = filterByMonth(filterByDateRange(sorted, filters.startDate, filters.endDate), filters.selectedMonth);
804
+ const preProvider = filterByMonth(
805
+ filterByDateRange(sorted, filters.startDate, filters.endDate),
806
+ filters.selectedMonth,
807
+ );
661
808
  const preModel = filterByProviders(preProvider, filters.selectedProviders || []);
662
809
  const filteredDaily = filterByModels(preModel, filters.selectedModels || []);
663
810
  const filtered = aggregateToDailyFormat(filteredDaily, filters.viewMode || 'daily');
@@ -692,32 +839,76 @@ function buildReportData(allDailyData, options = {}) {
692
839
  emptyKey: 'report.filters.all',
693
840
  normalize: normalizeModelName,
694
841
  });
695
- const monthLabel = formatFilterValue(filters.selectedMonth, language) || translate(language, 'report.filters.all');
696
- const startDateLabel = formatFilterValue(filters.startDate || null, language) || translate(language, 'report.filters.noFilter');
697
- const endDateLabel = formatFilterValue(filters.endDate || null, language) || translate(language, 'report.filters.noFilter');
698
- const peakPeriodLabel = metrics.topDay ? formatDate(metrics.topDay.date, 'long', language) : notAvailable;
842
+ const monthLabel =
843
+ formatFilterValue(filters.selectedMonth, language) || translate(language, 'report.filters.all');
844
+ const startDateLabel =
845
+ formatFilterValue(filters.startDate || null, language) ||
846
+ translate(language, 'report.filters.noFilter');
847
+ const endDateLabel =
848
+ formatFilterValue(filters.endDate || null, language) ||
849
+ translate(language, 'report.filters.noFilter');
850
+ const peakPeriodLabel = metrics.topDay
851
+ ? formatDate(metrics.topDay.date, 'long', language)
852
+ : notAvailable;
699
853
  const topModelValue = metrics.topModel ? metrics.topModel.name : notAvailable;
700
854
  const topProviderValue = metrics.topProvider ? metrics.topProvider.name : notAvailable;
701
855
  const insights = buildInsights(metrics, { filteredDaily, filtered, language });
702
856
  const avgPeriodCost = filtered.length > 0 ? metrics.totalCost / filtered.length : 0;
703
- const recentRows = sortByDate(filtered).slice(-12).reverse().map((entry) => ({
704
- period: entry.date,
705
- label: formatDate(entry.date, 'long', language),
706
- cost: entry.totalCost,
707
- costLabel: formatCurrency(entry.totalCost, language),
708
- tokens: entry.totalTokens,
709
- tokensLabel: formatCompact(entry.totalTokens, language),
710
- requests: entry.requestCount,
711
- requestsLabel: formatInteger(entry.requestCount, language),
712
- }));
857
+ const recentRows = sortByDate(filtered)
858
+ .slice(-12)
859
+ .reverse()
860
+ .map((entry) => ({
861
+ period: entry.date,
862
+ label: formatDate(entry.date, 'long', language),
863
+ cost: entry.totalCost,
864
+ costLabel: formatCurrency(entry.totalCost, language),
865
+ tokens: entry.totalTokens,
866
+ tokensLabel: formatCompact(entry.totalTokens, language),
867
+ requests: entry.requestCount,
868
+ requestsLabel: formatInteger(entry.requestCount, language),
869
+ }));
713
870
 
714
871
  const summaryCards = [
715
- { label: translate(language, 'common.costs'), value: formatCurrency(metrics.totalCost, language), note: metrics.topProvider ? `${metrics.topProvider.name} ${formatPercent(metrics.topProvider.share, language)}` : notAvailable, tone: 'accent' },
716
- { label: translate(language, 'common.tokens'), value: formatCompact(metrics.totalTokens, language), note: `CPM ${formatCurrency(metrics.costPerMillion, language)}`, tone: 'accent' },
717
- { label: translate(language, 'common.requests'), value: formatInteger(metrics.totalRequests, language), note: metrics.hasRequestData ? `${formatPercent(metrics.cacheHitRate, language)} Cache` : notAvailable, tone: 'good' },
718
- { label: `Ø ${translate(language, 'common.cost')} / ${periodLabel}`, value: formatCurrency(avgPeriodCost, language), note: `${reportDataLabel(filters.viewMode, language)}`, tone: 'accent' },
719
- { label: translate(language, 'common.model'), value: topModelValue, note: metrics.topModel ? formatPercent(metrics.topModelShare, language) : notAvailable, tone: 'warn' },
720
- { label: translate(language, 'report.summary.peakPeriod'), value: peakPeriodLabel, note: metrics.topDay ? formatCurrency(metrics.topDay.cost, language) : notAvailable, tone: 'warn' },
872
+ {
873
+ label: translate(language, 'common.costs'),
874
+ value: formatCurrency(metrics.totalCost, language),
875
+ note: metrics.topProvider
876
+ ? `${metrics.topProvider.name} ${formatPercent(metrics.topProvider.share, language)}`
877
+ : notAvailable,
878
+ tone: 'accent',
879
+ },
880
+ {
881
+ label: translate(language, 'common.tokens'),
882
+ value: formatCompact(metrics.totalTokens, language),
883
+ note: `CPM ${formatCurrency(metrics.costPerMillion, language)}`,
884
+ tone: 'accent',
885
+ },
886
+ {
887
+ label: translate(language, 'common.requests'),
888
+ value: formatInteger(metrics.totalRequests, language),
889
+ note: metrics.hasRequestData
890
+ ? `${formatPercent(metrics.cacheHitRate, language)} Cache`
891
+ : notAvailable,
892
+ tone: 'good',
893
+ },
894
+ {
895
+ label: `Ø ${translate(language, 'common.cost')} / ${periodLabel}`,
896
+ value: formatCurrency(avgPeriodCost, language),
897
+ note: `${reportDataLabel(filters.viewMode, language)}`,
898
+ tone: 'accent',
899
+ },
900
+ {
901
+ label: translate(language, 'common.model'),
902
+ value: topModelValue,
903
+ note: metrics.topModel ? formatPercent(metrics.topModelShare, language) : notAvailable,
904
+ tone: 'warn',
905
+ },
906
+ {
907
+ label: translate(language, 'report.summary.peakPeriod'),
908
+ value: peakPeriodLabel,
909
+ note: metrics.topDay ? formatCurrency(metrics.topDay.cost, language) : notAvailable,
910
+ tone: 'warn',
911
+ },
721
912
  ];
722
913
 
723
914
  const interpretationSummary = translate(language, 'report.interpretation.summary', {
@@ -785,10 +976,18 @@ function buildReportData(allDailyData, options = {}) {
785
976
  })),
786
977
  recentPeriods: recentRows,
787
978
  labels: {
788
- dateRangeText: dateRange ? `${formatDate(dateRange.start, 'long', language)} - ${formatDate(dateRange.end, 'long', language)}` : translate(language, 'common.noData'),
789
- topModel: metrics.topModel ? `${metrics.topModel.name} (${formatPercent(metrics.topModelShare, language)})` : notAvailable,
790
- topProvider: metrics.topProvider ? `${metrics.topProvider.name} (${formatPercent(metrics.topProvider.share, language)})` : notAvailable,
791
- topDay: metrics.topDay ? `${formatDate(metrics.topDay.date, 'long', language)} (${formatCurrency(metrics.topDay.cost, language)})` : notAvailable,
979
+ dateRangeText: dateRange
980
+ ? `${formatDate(dateRange.start, 'long', language)} - ${formatDate(dateRange.end, 'long', language)}`
981
+ : translate(language, 'common.noData'),
982
+ topModel: metrics.topModel
983
+ ? `${metrics.topModel.name} (${formatPercent(metrics.topModelShare, language)})`
984
+ : notAvailable,
985
+ topProvider: metrics.topProvider
986
+ ? `${metrics.topProvider.name} (${formatPercent(metrics.topProvider.share, language)})`
987
+ : notAvailable,
988
+ topDay: metrics.topDay
989
+ ? `${formatDate(metrics.topDay.date, 'long', language)} (${formatCurrency(metrics.topDay.cost, language)})`
990
+ : notAvailable,
792
991
  },
793
992
  interpretation: {
794
993
  summary: interpretationSummary,
@@ -836,7 +1035,10 @@ function buildReportData(allDailyData, options = {}) {
836
1035
  },
837
1036
  },
838
1037
  formatting: {
839
- axisDates: filtered.map((entry) => ({ date: entry.date, label: formatDateAxis(entry.date, language) })),
1038
+ axisDates: filtered.map((entry) => ({
1039
+ date: entry.date,
1040
+ label: formatDateAxis(entry.date, language),
1041
+ })),
840
1042
  },
841
1043
  };
842
1044
  }
@@ -862,4 +1064,8 @@ module.exports = {
862
1064
  formatDateAxis,
863
1065
  getModelColor,
864
1066
  truncateLabel,
1067
+ __test__: {
1068
+ getModelProvider,
1069
+ normalizeModelName,
1070
+ },
865
1071
  };