@roastcodes/ttdash 6.1.8 → 6.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,323 +1,24 @@
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
- }));
13
-
14
- const MODEL_COLORS = {
15
- 'Opus 4.6': 'rgb(175, 92, 224)',
16
- 'Opus 4.5': 'rgb(200, 66, 111)',
17
- 'Sonnet 4.6': 'rgb(71, 134, 221)',
18
- 'Sonnet 4.5': 'rgb(66, 161, 130)',
19
- 'Haiku 4.5': 'rgb(231, 146, 34)',
20
- 'GPT-5.4': 'rgb(230, 98, 56)',
21
- 'GPT-5': 'rgb(230, 98, 56)',
22
- 'Gemini 3 Flash Preview': 'rgb(237, 188, 8)',
23
- Gemini: 'rgb(237, 188, 8)',
24
- OpenCode: 'rgb(51, 181, 193)',
25
- };
3
+ const { truncateTopModelChartLabel } = require('./chart-labels');
4
+ const {
5
+ aggregateToDailyFormat,
6
+ computeMetrics,
7
+ computeMovingAverage,
8
+ filterByDateRange,
9
+ filterByModels,
10
+ filterByMonth,
11
+ filterByProviders,
12
+ getModelProvider,
13
+ normalizeModelName,
14
+ sortByDate,
15
+ } = require('../../shared/dashboard-domain');
16
+ const { getModelColorRgb } = require('../../shared/model-colors.js');
26
17
 
27
18
  const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
28
19
 
29
- function titleCaseSegment(segment) {
30
- if (!segment) return segment;
31
- if (/^\d+([.-]\d+)*$/.test(segment)) return segment.replace(/-/g, '.');
32
- if (/^[a-z]{1,4}\d+$/i.test(segment)) return segment.toUpperCase();
33
- return segment.charAt(0).toUpperCase() + segment.slice(1);
34
- }
35
-
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 || '')
47
- .trim()
48
- .toLowerCase()
49
- .replace(/^model[:/ -]*/i, '')
50
- .replace(/^(anthropic|openai|google|vertex|models)[/-]/i, '')
51
- .replace(/\./g, '-')
52
- .replace(/[_/]+/g, '-')
53
- .replace(/\s+/g, '-')
54
- .replace(/-{2,}/g, '-')
55
- .replace(/^-|-$/g, '');
56
-
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
- );
168
- if (familyMatch) {
169
- const family = familyMatch[1];
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]) : '';
176
- if (/^gpt$/i.test(family) && suffix) return `GPT-${suffix.toUpperCase()}`;
177
- return `${titleCaseSegment(family)}${suffix ? ` ${suffix}` : ''}`.trim();
178
- }
179
-
180
- return canonical.split('-').filter(Boolean).map(titleCaseSegment).join(' ') || String(raw || '');
181
- }
182
-
183
- function getModelProvider(raw) {
184
- const canonical = canonicalizeModelName(raw);
185
- for (const matcher of PROVIDER_MATCHERS) {
186
- if (matcher.matcher.test(canonical)) return matcher.provider;
187
- }
188
- return 'Other';
189
- }
190
-
191
20
  function getModelColor(name) {
192
- return MODEL_COLORS[name] || 'rgb(113, 128, 150)';
193
- }
194
-
195
- function sortByDate(data) {
196
- return [...data].sort((a, b) => a.date.localeCompare(b.date));
197
- }
198
-
199
- function filterByDateRange(data, start, end) {
200
- return data.filter((day) => {
201
- if (start && day.date < start) return false;
202
- if (end && day.date > end) return false;
203
- return true;
204
- });
205
- }
206
-
207
- function filterByMonth(data, month) {
208
- if (!month) return data;
209
- return data.filter((day) => day.date.startsWith(month));
210
- }
211
-
212
- function recalculateDayFromBreakdowns(day, modelBreakdowns) {
213
- let inputTokens = 0;
214
- let outputTokens = 0;
215
- let cacheCreationTokens = 0;
216
- let cacheReadTokens = 0;
217
- let thinkingTokens = 0;
218
- let totalCost = 0;
219
- let requestCount = 0;
220
-
221
- for (const breakdown of modelBreakdowns) {
222
- inputTokens += breakdown.inputTokens;
223
- outputTokens += breakdown.outputTokens;
224
- cacheCreationTokens += breakdown.cacheCreationTokens;
225
- cacheReadTokens += breakdown.cacheReadTokens;
226
- thinkingTokens += breakdown.thinkingTokens;
227
- totalCost += breakdown.cost;
228
- requestCount += breakdown.requestCount;
229
- }
230
-
231
- return {
232
- ...day,
233
- inputTokens,
234
- outputTokens,
235
- cacheCreationTokens,
236
- cacheReadTokens,
237
- thinkingTokens,
238
- totalCost,
239
- requestCount,
240
- totalTokens:
241
- inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens + thinkingTokens,
242
- modelsUsed: modelBreakdowns.map((item) => item.modelName),
243
- modelBreakdowns,
244
- };
245
- }
246
-
247
- function filterByProviders(data, selectedProviders) {
248
- if (!selectedProviders || selectedProviders.length === 0) return data;
249
- const selected = new Set(selectedProviders);
250
- return data
251
- .map((day) => {
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;
258
- })
259
- .filter(Boolean);
260
- }
261
-
262
- function filterByModels(data, selectedModels) {
263
- if (!selectedModels || selectedModels.length === 0) return data;
264
- const selected = new Set(selectedModels);
265
- return data
266
- .map((day) => {
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;
273
- })
274
- .filter(Boolean);
275
- }
276
-
277
- function aggregateToDailyFormat(data, viewMode) {
278
- if (viewMode === 'daily') return data;
279
- const groupKey = viewMode === 'monthly' ? (date) => date.slice(0, 7) : (date) => date.slice(0, 4);
280
- const groups = new Map();
281
-
282
- for (const day of data) {
283
- const key = groupKey(day.date);
284
- const existing = groups.get(key);
285
- const days = day._aggregatedDays || 1;
286
-
287
- if (!existing) {
288
- groups.set(key, {
289
- ...day,
290
- date: key,
291
- _aggregatedDays: days,
292
- });
293
- continue;
294
- }
295
-
296
- existing.totalCost += day.totalCost;
297
- existing.totalTokens += day.totalTokens;
298
- existing.inputTokens += day.inputTokens;
299
- existing.outputTokens += day.outputTokens;
300
- existing.cacheCreationTokens += day.cacheCreationTokens;
301
- existing.cacheReadTokens += day.cacheReadTokens;
302
- existing.thinkingTokens += day.thinkingTokens;
303
- existing.requestCount += day.requestCount;
304
- existing._aggregatedDays += days;
305
- existing.modelBreakdowns = existing.modelBreakdowns.concat(day.modelBreakdowns);
306
- existing.modelsUsed = Array.from(new Set(existing.modelsUsed.concat(day.modelsUsed)));
307
- }
308
-
309
- return Array.from(groups.values()).sort((a, b) => a.date.localeCompare(b.date));
310
- }
311
-
312
- function computeMovingAverage(values, window = 7) {
313
- const result = new Array(values.length);
314
- let sum = 0;
315
- for (let index = 0; index < values.length; index += 1) {
316
- sum += values[index];
317
- if (index >= window) sum -= values[index - window];
318
- result[index] = index < window - 1 ? null : sum / window;
319
- }
320
- return result;
21
+ return getModelColorRgb(name, { theme: 'light' });
321
22
  }
322
23
 
323
24
  function toCostChartData(data) {
@@ -362,163 +63,6 @@ function toWeekdayData(data) {
362
63
  });
363
64
  }
364
65
 
365
- function stdDev(values) {
366
- if (!values.length) return 0;
367
- const mean = values.reduce((sum, value) => sum + value, 0) / values.length;
368
- const variance = values.reduce((sum, value) => sum + (value - mean) ** 2, 0) / values.length;
369
- return Math.sqrt(variance);
370
- }
371
-
372
- function computeWeekOverWeekChange(data) {
373
- if (data.some((entry) => !/^\d{4}-\d{2}-\d{2}$/.test(entry.date))) return null;
374
- if (data.length < 14) return null;
375
- const sorted = sortByDate(data);
376
- const last7 = sorted.slice(-7);
377
- const prev7 = sorted.slice(-14, -7);
378
- const lastSum = last7.reduce((sum, day) => sum + day.totalCost, 0);
379
- const prevSum = prev7.reduce((sum, day) => sum + day.totalCost, 0);
380
- if (prevSum === 0) return null;
381
- return ((lastSum - prevSum) / prevSum) * 100;
382
- }
383
-
384
- function computeBusiestWeek(data) {
385
- const sorted = sortByDate(data).filter((entry) => /^\d{4}-\d{2}-\d{2}$/.test(entry.date));
386
- if (sorted.length < 3) return null;
387
- let best = null;
388
- for (let start = 0; start < sorted.length; start += 1) {
389
- const startDate = new Date(`${sorted[start].date}T00:00:00`);
390
- const endLimit = new Date(startDate);
391
- endLimit.setDate(endLimit.getDate() + 6);
392
- let cost = 0;
393
- let end = start;
394
- while (end < sorted.length && new Date(`${sorted[end].date}T00:00:00`) <= endLimit) {
395
- cost += sorted[end].totalCost;
396
- end += 1;
397
- }
398
- if (!best || cost > best.cost) {
399
- best = { start: sorted[start].date, end: sorted[end - 1].date, cost };
400
- }
401
- }
402
- return best;
403
- }
404
-
405
- function computeMetrics(data) {
406
- if (data.length === 0) {
407
- return {
408
- totalCost: 0,
409
- totalTokens: 0,
410
- activeDays: 0,
411
- totalRequests: 0,
412
- hasRequestData: false,
413
- avgDailyCost: 0,
414
- avgRequestsPerDay: 0,
415
- avgTokensPerRequest: 0,
416
- avgCostPerRequest: 0,
417
- cacheHitRate: 0,
418
- costPerMillion: 0,
419
- topModel: null,
420
- topModelShare: 0,
421
- topProvider: null,
422
- topDay: null,
423
- cheapestDay: null,
424
- busiestWeek: null,
425
- weekendCostShare: null,
426
- weekOverWeekChange: null,
427
- requestVolatility: 0,
428
- providerCount: 0,
429
- };
430
- }
431
-
432
- const modelCosts = new Map();
433
- const providerCosts = new Map();
434
- let totalCost = 0;
435
- let totalTokens = 0;
436
- let totalRequests = 0;
437
- let totalInput = 0;
438
- let totalOutput = 0;
439
- let totalCacheRead = 0;
440
- let totalCacheCreate = 0;
441
- let totalThinking = 0;
442
- let activeDays = 0;
443
- let hasRequestData = false;
444
- let weekendCost = 0;
445
- let weekendEligible = 0;
446
- let topDay = { date: data[0].date, cost: data[0].totalCost };
447
- let cheapestDay = { date: data[0].date, cost: data[0].totalCost };
448
-
449
- for (const day of data) {
450
- totalCost += day.totalCost;
451
- totalTokens += day.totalTokens;
452
- totalRequests += day.requestCount;
453
- totalInput += day.inputTokens;
454
- totalOutput += day.outputTokens;
455
- totalCacheRead += day.cacheReadTokens;
456
- totalCacheCreate += day.cacheCreationTokens;
457
- totalThinking += day.thinkingTokens;
458
- activeDays += day._aggregatedDays || 1;
459
- if (day.requestCount > 0 || day.modelBreakdowns.some((entry) => entry.requestCount > 0))
460
- hasRequestData = true;
461
- if (day.totalCost > topDay.cost) topDay = { date: day.date, cost: day.totalCost };
462
- if (day.totalCost < cheapestDay.cost) cheapestDay = { date: day.date, cost: day.totalCost };
463
-
464
- if (/^\d{4}-\d{2}-\d{2}$/.test(day.date)) {
465
- const weekday = new Date(`${day.date}T00:00:00`).getDay();
466
- if (weekday === 0 || weekday === 6) weekendCost += day.totalCost;
467
- weekendEligible += day.totalCost;
468
- }
469
-
470
- for (const breakdown of day.modelBreakdowns) {
471
- const model = normalizeModelName(breakdown.modelName);
472
- const provider = getModelProvider(breakdown.modelName);
473
- modelCosts.set(model, (modelCosts.get(model) || 0) + breakdown.cost);
474
- providerCosts.set(provider, (providerCosts.get(provider) || 0) + breakdown.cost);
475
- }
476
- }
477
-
478
- let topModel = null;
479
- for (const [name, cost] of modelCosts) {
480
- if (!topModel || cost > topModel.cost) topModel = { name, cost };
481
- }
482
-
483
- let topProvider = null;
484
- for (const [name, cost] of providerCosts) {
485
- if (!topProvider || cost > topProvider.cost) {
486
- topProvider = { name, cost, share: totalCost > 0 ? (cost / totalCost) * 100 : 0 };
487
- }
488
- }
489
-
490
- const cacheBase = totalCacheRead + totalCacheCreate + totalInput + totalOutput + totalThinking;
491
-
492
- return {
493
- totalCost,
494
- totalTokens,
495
- activeDays,
496
- totalRequests,
497
- totalInput,
498
- totalOutput,
499
- totalCacheRead,
500
- totalCacheCreate,
501
- totalThinking,
502
- hasRequestData,
503
- avgDailyCost: activeDays > 0 ? totalCost / activeDays : 0,
504
- avgRequestsPerDay: hasRequestData && activeDays > 0 ? totalRequests / activeDays : 0,
505
- avgTokensPerRequest: hasRequestData && totalRequests > 0 ? totalTokens / totalRequests : 0,
506
- avgCostPerRequest: hasRequestData && totalRequests > 0 ? totalCost / totalRequests : 0,
507
- cacheHitRate: cacheBase > 0 ? (totalCacheRead / cacheBase) * 100 : 0,
508
- costPerMillion: totalTokens > 0 ? totalCost / (totalTokens / 1000000) : 0,
509
- topModel,
510
- topModelShare: topModel && totalCost > 0 ? (topModel.cost / totalCost) * 100 : 0,
511
- topProvider,
512
- topDay,
513
- cheapestDay,
514
- busiestWeek: computeBusiestWeek(data),
515
- weekendCostShare: weekendEligible > 0 ? (weekendCost / weekendEligible) * 100 : null,
516
- weekOverWeekChange: computeWeekOverWeekChange(data),
517
- requestVolatility: stdDev(data.map((item) => item.requestCount)),
518
- providerCount: providerCosts.size,
519
- };
520
- }
521
-
522
66
  function computeModelRows(data) {
523
67
  const rows = new Map();
524
68
  for (const day of data) {
@@ -677,6 +221,16 @@ function formatPercent(value, language = 'de') {
677
221
  })}%`;
678
222
  }
679
223
 
224
+ function findPeakEntry(data, getValue) {
225
+ let best = null;
226
+ for (const entry of data) {
227
+ if (!best || getValue(entry) > getValue(best)) {
228
+ best = entry;
229
+ }
230
+ }
231
+ return best;
232
+ }
233
+
680
234
  function formatCompactNumber(value, language = 'de') {
681
235
  if (!Number.isFinite(value)) return '0';
682
236
 
@@ -737,12 +291,6 @@ function summarizeSelection(
737
291
  return `${visible.join(', ')}${suffix}`;
738
292
  }
739
293
 
740
- function truncateLabel(value, maxLength = 28) {
741
- const stringValue = String(value || '');
742
- if (stringValue.length <= maxLength) return stringValue;
743
- return `${stringValue.slice(0, Math.max(1, maxLength - 1)).trimEnd()}…`;
744
- }
745
-
746
294
  function buildInsights(metrics, { filteredDaily, filtered, language }) {
747
295
  const insights = [];
748
296
 
@@ -854,6 +402,9 @@ function buildReportData(allDailyData, options = {}) {
854
402
  const topProviderValue = metrics.topProvider ? metrics.topProvider.name : notAvailable;
855
403
  const insights = buildInsights(metrics, { filteredDaily, filtered, language });
856
404
  const avgPeriodCost = filtered.length > 0 ? metrics.totalCost / filtered.length : 0;
405
+ const latestPeriod = filtered[filtered.length - 1] || null;
406
+ const peakCostPeriod = findPeakEntry(filtered, (entry) => entry.totalCost);
407
+ const peakTokenPeriod = findPeakEntry(filtered, (entry) => entry.totalTokens);
857
408
  const recentRows = sortByDate(filtered)
858
409
  .slice(-12)
859
410
  .reverse()
@@ -911,6 +462,34 @@ function buildReportData(allDailyData, options = {}) {
911
462
  },
912
463
  ];
913
464
 
465
+ const topChartModels = modelRows.slice(0, 8);
466
+ const truncatedTopModelNames = topChartModels
467
+ .filter((entry) => truncateTopModelChartLabel(entry.name) !== entry.name)
468
+ .map((entry) => entry.name);
469
+ const topModelSummary = metrics.topModel
470
+ ? translate(language, 'report.charts.topModelsSummary', {
471
+ model: metrics.topModel.name,
472
+ cost: formatCurrency(metrics.topModel.cost, language),
473
+ share: formatPercent(metrics.topModelShare, language),
474
+ })
475
+ : translate(language, 'report.charts.noDataSummary');
476
+ const costTrendSummary =
477
+ latestPeriod && peakCostPeriod
478
+ ? translate(language, 'report.charts.costTrendSummary', {
479
+ latest: formatCurrency(latestPeriod.totalCost, language),
480
+ peak: formatCurrency(peakCostPeriod.totalCost, language),
481
+ date: formatDate(peakCostPeriod.date, 'long', language),
482
+ })
483
+ : translate(language, 'report.charts.noDataSummary');
484
+ const tokenTrendSummary =
485
+ peakTokenPeriod && metrics.totalTokens > 0
486
+ ? translate(language, 'report.charts.tokenTrendSummary', {
487
+ total: formatCompact(metrics.totalTokens, language),
488
+ peak: formatCompact(peakTokenPeriod.totalTokens, language),
489
+ date: formatDate(peakTokenPeriod.date, 'long', language),
490
+ })
491
+ : translate(language, 'report.charts.noDataSummary');
492
+
914
493
  const interpretationSummary = translate(language, 'report.interpretation.summary', {
915
494
  days: formatInteger(filteredDaily.length, language),
916
495
  periods: formatInteger(filtered.length, language),
@@ -975,6 +554,26 @@ function buildReportData(allDailyData, options = {}) {
975
554
  tokensLabel: formatCompact(entry.tokens, language),
976
555
  })),
977
556
  recentPeriods: recentRows,
557
+ chartDescriptions: {
558
+ costTrend: {
559
+ alt: translate(language, 'report.charts.costTrendAlt'),
560
+ summary: costTrendSummary,
561
+ },
562
+ topModels: {
563
+ alt: translate(language, 'report.charts.topModelsAlt'),
564
+ summary: topModelSummary,
565
+ fullNamesNote:
566
+ truncatedTopModelNames.length > 0
567
+ ? translate(language, 'report.charts.topModelsFullNames', {
568
+ names: truncatedTopModelNames.join(', '),
569
+ })
570
+ : null,
571
+ },
572
+ tokenTrend: {
573
+ alt: translate(language, 'report.charts.tokenTrendAlt'),
574
+ summary: tokenTrendSummary,
575
+ },
576
+ },
978
577
  labels: {
979
578
  dateRangeText: dateRange
980
579
  ? `${formatDate(dateRange.start, 'long', language)} - ${formatDate(dateRange.end, 'long', language)}`
@@ -1063,7 +662,6 @@ module.exports = {
1063
662
  formatDate,
1064
663
  formatDateAxis,
1065
664
  getModelColor,
1066
- truncateLabel,
1067
665
  __test__: {
1068
666
  getModelProvider,
1069
667
  normalizeModelName,
@@ -0,0 +1,78 @@
1
+ function isLoopbackHost(host) {
2
+ const normalized = String(host || '')
3
+ .trim()
4
+ .toLowerCase()
5
+ .replace(/^\[|\]$/g, '');
6
+ const ipv4Loopback = /^127(?:\.\d{1,3}){3}$/.test(normalized);
7
+ const ipv4MappedLoopback = /^::ffff:127(?:\.\d{1,3}){3}$/.test(normalized);
8
+ return ipv4Loopback || normalized === 'localhost' || normalized === '::1' || ipv4MappedLoopback;
9
+ }
10
+
11
+ function ensureBindHostAllowed(bindHost, allowRemoteBind) {
12
+ if (isLoopbackHost(bindHost) || allowRemoteBind) {
13
+ return;
14
+ }
15
+
16
+ const error = new Error(
17
+ `Refusing to bind TTDash to non-loopback host "${bindHost}" without TTDASH_ALLOW_REMOTE=1.`,
18
+ );
19
+ error.code = 'REMOTE_BIND_REQUIRES_OPT_IN';
20
+ throw error;
21
+ }
22
+
23
+ function createNoFreePortError(rangeStartPort, maxPort) {
24
+ return new Error(`No free port found (${rangeStartPort}-${maxPort})`);
25
+ }
26
+
27
+ async function listenOnAvailablePort(
28
+ serverInstance,
29
+ port,
30
+ maxPort,
31
+ bindHost,
32
+ log = console.log,
33
+ rangeStartPort = port,
34
+ ) {
35
+ if (port > maxPort) {
36
+ throw createNoFreePortError(rangeStartPort, maxPort);
37
+ }
38
+
39
+ for (let currentPort = port; currentPort <= maxPort; currentPort += 1) {
40
+ try {
41
+ await new Promise((resolve, reject) => {
42
+ const onError = (error) => {
43
+ serverInstance.off('listening', onListening);
44
+ reject(error);
45
+ };
46
+
47
+ const onListening = () => {
48
+ serverInstance.off('error', onError);
49
+ resolve();
50
+ };
51
+
52
+ serverInstance.once('error', onError);
53
+ serverInstance.once('listening', onListening);
54
+ serverInstance.listen(currentPort, bindHost);
55
+ });
56
+
57
+ return currentPort;
58
+ } catch (error) {
59
+ if (error && error.code === 'EADDRINUSE') {
60
+ if (currentPort >= maxPort) {
61
+ throw createNoFreePortError(rangeStartPort, maxPort);
62
+ }
63
+ log(`Port ${currentPort} is in use, trying ${currentPort + 1}...`);
64
+ continue;
65
+ }
66
+ throw error;
67
+ }
68
+ }
69
+
70
+ throw createNoFreePortError(rangeStartPort, maxPort);
71
+ }
72
+
73
+ module.exports = {
74
+ createNoFreePortError,
75
+ ensureBindHostAllowed,
76
+ isLoopbackHost,
77
+ listenOnAvailablePort,
78
+ };