@roastcodes/ttdash 6.1.8 → 6.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,15 +1,17 @@
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
+ const {
4
+ aggregateToDailyFormat,
5
+ computeMetrics,
6
+ computeMovingAverage,
7
+ filterByDateRange,
8
+ filterByModels,
9
+ filterByMonth,
10
+ filterByProviders,
11
+ getModelProvider,
12
+ normalizeModelName,
13
+ sortByDate,
14
+ } = require('../../shared/dashboard-domain');
13
15
 
14
16
  const MODEL_COLORS = {
15
17
  'Opus 4.6': 'rgb(175, 92, 224)',
@@ -26,300 +28,10 @@ const MODEL_COLORS = {
26
28
 
27
29
  const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
28
30
 
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
31
  function getModelColor(name) {
192
32
  return MODEL_COLORS[name] || 'rgb(113, 128, 150)';
193
33
  }
194
34
 
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;
321
- }
322
-
323
35
  function toCostChartData(data) {
324
36
  const sorted = sortByDate(data);
325
37
  const ma7 = computeMovingAverage(sorted.map((day) => day.totalCost));
@@ -362,163 +74,6 @@ function toWeekdayData(data) {
362
74
  });
363
75
  }
364
76
 
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
77
  function computeModelRows(data) {
523
78
  const rows = new Map();
524
79
  for (const day of data) {
@@ -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
+ };