@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.
- package/README.md +14 -5
- package/dist/assets/AutoImportModal-C8gA0_mL.js +3 -0
- package/dist/assets/CustomTooltip-CdIOw3Ep.js +1 -0
- package/dist/assets/DrillDownModal-d6hcut-I.js +1 -0
- package/dist/assets/button-B26tLVFw.js +1 -0
- package/dist/assets/dialog-CA-ZSHjK.js +1 -0
- package/dist/assets/{icons-vendor-DFoaijFJ.js → icons-vendor-z59La6A4.js} +1 -1
- package/dist/assets/index-BkGSNAne.css +2 -0
- package/dist/assets/index-CMtAn7c8.js +4 -0
- package/dist/index.html +6 -6
- package/package.json +3 -1
- package/server/http-utils.js +165 -0
- package/server/report/chart-labels.js +12 -0
- package/server/report/charts.js +4 -8
- package/server/report/index.js +73 -16
- package/server/report/utils.js +76 -478
- package/server/runtime.js +78 -0
- package/server.js +280 -165
- package/shared/dashboard-domain.d.ts +19 -0
- package/shared/dashboard-domain.js +615 -0
- package/shared/dashboard-preferences.json +43 -0
- package/shared/dashboard-types.d.ts +62 -0
- package/shared/model-colors.d.ts +17 -0
- package/shared/model-colors.js +241 -0
- package/src/locales/de/common.json +198 -124
- package/src/locales/en/common.json +78 -4
- package/dist/assets/AutoImportModal-Dqbl8H04.js +0 -2
- package/dist/assets/CustomTooltip-BxopDd3O.js +0 -1
- package/dist/assets/DrillDownModal-B7ZU15xQ.js +0 -1
- package/dist/assets/button-D7Ib8H7t.js +0 -1
- package/dist/assets/dialog-Cn1m7WhC.js +0 -1
- package/dist/assets/index-DDw3UUhU.js +0 -4
- package/dist/assets/index-g2F-z39N.css +0 -2
package/server/report/utils.js
CHANGED
|
@@ -1,323 +1,24 @@
|
|
|
1
1
|
const { version: APP_VERSION } = require('../../package.json');
|
|
2
2
|
const { getLanguage, getLocale, translate } = require('./i18n');
|
|
3
|
-
const
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
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
|
+
};
|