token-studio 4.8.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.
Files changed (139) hide show
  1. package/.nvmrc +1 -0
  2. package/CHANGELOG.md +89 -0
  3. package/Dockerfile +17 -0
  4. package/LICENSE +22 -0
  5. package/NOTICE.md +21 -0
  6. package/PRIVACY.md +68 -0
  7. package/README.en.md +220 -0
  8. package/README.md +220 -0
  9. package/config/collectors.json +54 -0
  10. package/data/.gitkeep +1 -0
  11. package/docker-compose.yml +17 -0
  12. package/docs/assets/.gitkeep +1 -0
  13. package/docs/assets/token-studio-v44-dashboard.png +0 -0
  14. package/docs/assets/token-studio-v44-live.png +0 -0
  15. package/docs/assets/token-studio-v44-review-mobile.png +0 -0
  16. package/docs/assets/token-studio-v44-review.png +0 -0
  17. package/docs/assets/token-studio-v45-dashboard.png +0 -0
  18. package/docs/assets/token-studio-v45-live.png +0 -0
  19. package/docs/assets/token-studio-v45-review-mobile.png +0 -0
  20. package/docs/assets/token-studio-v45-review.png +0 -0
  21. package/docs/blog-case-study.md +34 -0
  22. package/docs/collector-support-matrix.md +65 -0
  23. package/docs/competitive-notes.md +87 -0
  24. package/docs/demo-data/README.md +12 -0
  25. package/docs/demo-data/token-studio-v2-demo.json +146 -0
  26. package/docs/demo-flow.md +39 -0
  27. package/docs/first-run.md +95 -0
  28. package/docs/local-collectors.md +49 -0
  29. package/docs/public-launch-checklist.md +45 -0
  30. package/docs/resume-bullets.md +7 -0
  31. package/docs/statusline.md +52 -0
  32. package/index.html +16 -0
  33. package/package.json +36 -0
  34. package/render.yaml +17 -0
  35. package/src/auto-attribution.mjs +396 -0
  36. package/src/ccusage-bridge.mjs +74 -0
  37. package/src/ccusage-import.mjs +415 -0
  38. package/src/cli.mjs +643 -0
  39. package/src/client/dashboard/App.jsx +1734 -0
  40. package/src/client/dashboard/annotation-presets.js +138 -0
  41. package/src/client/dashboard/attribution.js +328 -0
  42. package/src/client/dashboard/components-charts.jsx +622 -0
  43. package/src/client/dashboard/components-tables.jsx +1531 -0
  44. package/src/client/dashboard/components-top.jsx +307 -0
  45. package/src/client/dashboard/import-budget.js +41 -0
  46. package/src/client/dashboard/model-usage.js +108 -0
  47. package/src/client/dashboard/onboarding.js +80 -0
  48. package/src/client/dashboard/styles.css +2606 -0
  49. package/src/client/live/LiveApp.jsx +226 -0
  50. package/src/client/live/styles.css +446 -0
  51. package/src/client/main.jsx +20 -0
  52. package/src/client/review/ReviewApp.jsx +507 -0
  53. package/src/client/review/closure-progress.js +165 -0
  54. package/src/client/review/markdown-report.js +401 -0
  55. package/src/client/review/model-strategy.js +273 -0
  56. package/src/client/review/roi-advisor.js +255 -0
  57. package/src/client/review/roi-evidence.js +78 -0
  58. package/src/client/review/savings-simulator.js +252 -0
  59. package/src/client/review/sections-1.jsx +277 -0
  60. package/src/client/review/sections-2.jsx +927 -0
  61. package/src/client/review/styles.css +2321 -0
  62. package/src/client/review/utils.js +345 -0
  63. package/src/client/shared/utils.js +236 -0
  64. package/src/closure-check.mjs +537 -0
  65. package/src/closure-import.mjs +646 -0
  66. package/src/collect.mjs +247 -0
  67. package/src/collector-config.mjs +82 -0
  68. package/src/collector-registry.mjs +333 -0
  69. package/src/collectors/claude-code.mjs +355 -0
  70. package/src/collectors/codex.mjs +418 -0
  71. package/src/collectors/copilot.mjs +19 -0
  72. package/src/collectors/cursor.mjs +23 -0
  73. package/src/collectors/gemini.mjs +530 -0
  74. package/src/collectors/goose.mjs +15 -0
  75. package/src/collectors/hermes.mjs +206 -0
  76. package/src/collectors/kimi.mjs +15 -0
  77. package/src/collectors/openclaw.mjs +400 -0
  78. package/src/collectors/opencode.mjs +349 -0
  79. package/src/collectors/qwen.mjs +15 -0
  80. package/src/collectors/structured-usage.mjs +437 -0
  81. package/src/collectors/utils.mjs +93 -0
  82. package/src/db.mjs +1397 -0
  83. package/src/demo-seed.mjs +39 -0
  84. package/src/dev.mjs +43 -0
  85. package/src/live.mjs +428 -0
  86. package/src/model-policy.mjs +147 -0
  87. package/src/pricing.mjs +434 -0
  88. package/src/privacy-check.mjs +126 -0
  89. package/src/server.mjs +1240 -0
  90. package/src/source-health.mjs +195 -0
  91. package/src/statusline.mjs +156 -0
  92. package/src/terminal-report.mjs +245 -0
  93. package/src/update-pricing.mjs +8 -0
  94. package/test/annotation-presets.test.mjs +137 -0
  95. package/test/api-annotations.test.mjs +202 -0
  96. package/test/api-auto-attribution.test.mjs +169 -0
  97. package/test/api-source-health.test.mjs +109 -0
  98. package/test/api-v2.test.mjs +278 -0
  99. package/test/api-v43.test.mjs +151 -0
  100. package/test/api-v44.test.mjs +128 -0
  101. package/test/attribution-summary.test.mjs +164 -0
  102. package/test/auto-attribution.test.mjs +116 -0
  103. package/test/ccusage-bridge.test.mjs +36 -0
  104. package/test/ccusage-import.test.mjs +93 -0
  105. package/test/cli-v43.test.mjs +64 -0
  106. package/test/cli-v45.test.mjs +34 -0
  107. package/test/cli-v46.test.mjs +129 -0
  108. package/test/cli-v47.test.mjs +98 -0
  109. package/test/closure-check.test.mjs +202 -0
  110. package/test/closure-import.test.mjs +263 -0
  111. package/test/collector-config.test.mjs +25 -0
  112. package/test/collector-registry.test.mjs +56 -0
  113. package/test/csv.test.mjs +19 -0
  114. package/test/db-annotations.test.mjs +186 -0
  115. package/test/db-v2.test.mjs +200 -0
  116. package/test/db-v4.test.mjs +178 -0
  117. package/test/experimental-collectors.test.mjs +103 -0
  118. package/test/fixtures/collectors/copilot/usage.jsonl +2 -0
  119. package/test/fixtures/collectors/cursor/usage.jsonl +2 -0
  120. package/test/fixtures/collectors/goose/usage.jsonl +2 -0
  121. package/test/fixtures/collectors/kimi/usage.jsonl +2 -0
  122. package/test/fixtures/collectors/qwen/usage.jsonl +2 -0
  123. package/test/import-budget.test.mjs +40 -0
  124. package/test/live.test.mjs +256 -0
  125. package/test/markdown-report.test.mjs +193 -0
  126. package/test/model-policy.test.mjs +34 -0
  127. package/test/model-strategy.test.mjs +116 -0
  128. package/test/model-usage.test.mjs +99 -0
  129. package/test/official-pricing.test.mjs +70 -0
  130. package/test/onboarding.test.mjs +55 -0
  131. package/test/privacy-check.test.mjs +33 -0
  132. package/test/review-closure-progress.test.mjs +99 -0
  133. package/test/roi-advisor.test.mjs +188 -0
  134. package/test/roi-evidence.test.mjs +48 -0
  135. package/test/roi-summary.test.mjs +101 -0
  136. package/test/savings-simulator.test.mjs +141 -0
  137. package/test/source-health.test.mjs +62 -0
  138. package/test/statusline.test.mjs +148 -0
  139. package/vite.config.js +23 -0
@@ -0,0 +1,437 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readdir, readFile, stat } from 'node:fs/promises';
3
+ import { basename, extname, join } from 'node:path';
4
+ import { calculateCost } from '../pricing.mjs';
5
+ import { localDateFromTimestamp, normalizeModelForGrouping } from './utils.mjs';
6
+
7
+ const MAX_DEPTH = 4;
8
+ const MAX_FILE_BYTES = 2 * 1024 * 1024;
9
+ const ACCEPTED_EXTENSIONS = new Set(['.json', '.jsonl']);
10
+
11
+ export async function collectStructuredUsage({
12
+ clientKey,
13
+ roots,
14
+ pricingData = null
15
+ }) {
16
+ const files = await listUsageFiles(roots);
17
+ const events = [];
18
+
19
+ for (const filePath of files) {
20
+ const parsed = await parseUsageFile(filePath);
21
+ events.push(...parsed.map(event => ({ ...event, client: clientKey })));
22
+ }
23
+
24
+ return buildOutput(clientKey, events, pricingData);
25
+ }
26
+
27
+ export async function listUsageFiles(roots) {
28
+ const files = [];
29
+ for (const root of roots.filter(Boolean)) {
30
+ await walk(root, 0, files);
31
+ }
32
+ return files;
33
+ }
34
+
35
+ export async function auditStructuredUsage({ roots }) {
36
+ const files = await listUsageFiles(roots);
37
+ const summary = {
38
+ candidateFiles: files.length,
39
+ usableTokenRecords: 0,
40
+ skippedNoTokenRecords: 0,
41
+ skippedConversationLikeRecords: 0,
42
+ skippedOversizedFiles: 0,
43
+ parseErrors: 0
44
+ };
45
+
46
+ for (const filePath of files) {
47
+ const result = await auditUsageFile(filePath);
48
+ summary.usableTokenRecords += result.usableTokenRecords;
49
+ summary.skippedNoTokenRecords += result.skippedNoTokenRecords;
50
+ summary.skippedConversationLikeRecords += result.skippedConversationLikeRecords;
51
+ summary.skippedOversizedFiles += result.skippedOversizedFiles;
52
+ summary.parseErrors += result.parseErrors;
53
+ }
54
+
55
+ return summary;
56
+ }
57
+
58
+ async function walk(dir, depth, files) {
59
+ if (depth > MAX_DEPTH) return;
60
+ let entries;
61
+ try {
62
+ entries = await readdir(dir, { withFileTypes: true });
63
+ } catch {
64
+ return;
65
+ }
66
+
67
+ for (const entry of entries) {
68
+ const fullPath = join(dir, entry.name);
69
+ if (entry.isDirectory()) {
70
+ await walk(fullPath, depth + 1, files);
71
+ continue;
72
+ }
73
+ if (!entry.isFile()) continue;
74
+ const ext = extname(entry.name).toLowerCase();
75
+ if (!ACCEPTED_EXTENSIONS.has(ext)) continue;
76
+ files.push(fullPath);
77
+ }
78
+ }
79
+
80
+ async function parseUsageFile(filePath) {
81
+ let info;
82
+ try {
83
+ info = await stat(filePath);
84
+ } catch {
85
+ return [];
86
+ }
87
+ if (info.size > MAX_FILE_BYTES) return [];
88
+
89
+ let text;
90
+ try {
91
+ text = await readFile(filePath, 'utf8');
92
+ } catch {
93
+ return [];
94
+ }
95
+
96
+ const fallback = {
97
+ sessionId: basename(filePath, extname(filePath)),
98
+ timestamp: new Date(info.mtimeMs).toISOString()
99
+ };
100
+ if (extname(filePath).toLowerCase() === '.jsonl') {
101
+ return text.split(/\r?\n/)
102
+ .map(line => line.trim())
103
+ .filter(Boolean)
104
+ .flatMap((line, index) => parseJsonLine(line, { ...fallback, index }));
105
+ }
106
+
107
+ try {
108
+ const json = JSON.parse(text);
109
+ const rows = Array.isArray(json)
110
+ ? json
111
+ : Array.isArray(json.events) ? json.events
112
+ : Array.isArray(json.usage) ? json.usage
113
+ : Array.isArray(json.records) ? json.records
114
+ : [json];
115
+ return rows.flatMap((row, index) => normalizeUsageRecord(row, { ...fallback, index }));
116
+ } catch {
117
+ return [];
118
+ }
119
+ }
120
+
121
+ async function auditUsageFile(filePath) {
122
+ const summary = {
123
+ usableTokenRecords: 0,
124
+ skippedNoTokenRecords: 0,
125
+ skippedConversationLikeRecords: 0,
126
+ skippedOversizedFiles: 0,
127
+ parseErrors: 0
128
+ };
129
+
130
+ let info;
131
+ try {
132
+ info = await stat(filePath);
133
+ } catch {
134
+ summary.parseErrors += 1;
135
+ return summary;
136
+ }
137
+ if (info.size > MAX_FILE_BYTES) {
138
+ summary.skippedOversizedFiles += 1;
139
+ return summary;
140
+ }
141
+
142
+ let text;
143
+ try {
144
+ text = await readFile(filePath, 'utf8');
145
+ } catch {
146
+ summary.parseErrors += 1;
147
+ return summary;
148
+ }
149
+
150
+ if (extname(filePath).toLowerCase() === '.jsonl') {
151
+ for (const line of text.split(/\r?\n/).map(item => item.trim()).filter(Boolean)) {
152
+ auditJsonLine(line, summary);
153
+ }
154
+ return summary;
155
+ }
156
+
157
+ if (hasUnsafeConversationText(text)) {
158
+ summary.skippedConversationLikeRecords += 1;
159
+ return summary;
160
+ }
161
+
162
+ try {
163
+ const json = JSON.parse(text);
164
+ const rows = Array.isArray(json)
165
+ ? json
166
+ : Array.isArray(json.events) ? json.events
167
+ : Array.isArray(json.usage) ? json.usage
168
+ : Array.isArray(json.records) ? json.records
169
+ : [json];
170
+ for (const row of rows) {
171
+ auditRecord(row, summary);
172
+ }
173
+ } catch {
174
+ summary.parseErrors += 1;
175
+ }
176
+
177
+ return summary;
178
+ }
179
+
180
+ function auditJsonLine(line, summary) {
181
+ if (hasUnsafeConversationText(line)) {
182
+ summary.skippedConversationLikeRecords += 1;
183
+ return;
184
+ }
185
+ try {
186
+ auditRecord(JSON.parse(line), summary);
187
+ } catch {
188
+ summary.parseErrors += 1;
189
+ }
190
+ }
191
+
192
+ function auditRecord(row, summary) {
193
+ if (!row || typeof row !== 'object') {
194
+ summary.skippedNoTokenRecords += 1;
195
+ return;
196
+ }
197
+ if (looksLikeConversation(row)) {
198
+ summary.skippedConversationLikeRecords += 1;
199
+ return;
200
+ }
201
+ const tokens = normalizeTokens(row);
202
+ if (!hasReliableTokens(tokens)) {
203
+ summary.skippedNoTokenRecords += 1;
204
+ return;
205
+ }
206
+ summary.usableTokenRecords += 1;
207
+ }
208
+
209
+ function parseJsonLine(line, fallback) {
210
+ try {
211
+ return normalizeUsageRecord(JSON.parse(line), fallback);
212
+ } catch {
213
+ return [];
214
+ }
215
+ }
216
+
217
+ export function normalizeUsageRecord(row, fallback = {}) {
218
+ if (!row || typeof row !== 'object') return [];
219
+ if (looksLikeConversation(row)) return [];
220
+
221
+ const tokens = normalizeTokens(row);
222
+ if (!hasReliableTokens(tokens)) return [];
223
+
224
+ const model = normalizeModelForGrouping(firstString(
225
+ row.model,
226
+ row.modelId,
227
+ row.model_id,
228
+ row.modelName,
229
+ row.model_name,
230
+ row.request?.model,
231
+ row.response?.model
232
+ ) || 'unknown');
233
+ const timestamp = normalizeTimestamp(firstString(
234
+ row.timestamp,
235
+ row.createdAt,
236
+ row.created_at,
237
+ row.time,
238
+ row.date,
239
+ fallback.timestamp
240
+ ));
241
+ const sessionId = firstString(
242
+ row.sessionId,
243
+ row.session_id,
244
+ row.conversationId,
245
+ row.conversation_id,
246
+ row.threadId,
247
+ row.thread_id,
248
+ row.id,
249
+ fallback.sessionId
250
+ ) || 'unknown-session';
251
+ const projectLabel = projectLabelFrom(row);
252
+ const eventId = firstString(row.eventId, row.event_id)
253
+ || stableEventId({ sessionId, timestamp, model, tokens, index: fallback.index });
254
+
255
+ return [{
256
+ eventId,
257
+ sessionId,
258
+ timestamp,
259
+ date: localDateFromTimestamp(timestamp),
260
+ model,
261
+ projectLabel,
262
+ tokens,
263
+ toolCategory: sanitizeSmallText(firstString(row.toolCategory, row.tool_category, row.tool, row.kind), 80),
264
+ fileExtension: sanitizeExtension(firstString(row.fileExtension, row.file_extension, row.ext)),
265
+ repoPathHash: hashPath(firstString(row.repoPath, row.repo_path, row.workspacePath, row.workspace_path, row.cwd))
266
+ }];
267
+ }
268
+
269
+ function normalizeTokens(row) {
270
+ const tokenSource = row.tokens && typeof row.tokens === 'object' ? row.tokens : row.usage && typeof row.usage === 'object' ? row.usage : row;
271
+ return {
272
+ input: positive(tokenSource.inputTokens ?? tokenSource.input_tokens ?? tokenSource.promptTokens ?? tokenSource.prompt_tokens ?? tokenSource.input ?? tokenSource.prompt),
273
+ output: positive(tokenSource.outputTokens ?? tokenSource.output_tokens ?? tokenSource.completionTokens ?? tokenSource.completion_tokens ?? tokenSource.output ?? tokenSource.completion),
274
+ cacheRead: positive(tokenSource.cacheReadTokens ?? tokenSource.cache_read_tokens ?? tokenSource.cacheRead ?? tokenSource.cache_read ?? tokenSource.cachedTokens ?? tokenSource.cached_tokens),
275
+ cacheWrite: positive(tokenSource.cacheCreationTokens ?? tokenSource.cache_creation_tokens ?? tokenSource.cacheWriteTokens ?? tokenSource.cache_write_tokens ?? tokenSource.cacheWrite ?? tokenSource.cache_write),
276
+ reasoning: positive(tokenSource.reasoningTokens ?? tokenSource.reasoning_tokens ?? tokenSource.thoughtsTokens ?? tokenSource.thoughts_tokens ?? tokenSource.reasoning)
277
+ };
278
+ }
279
+
280
+ function hasReliableTokens(tokens) {
281
+ return tokens.input > 0 || tokens.output > 0 || tokens.cacheRead > 0 || tokens.cacheWrite > 0 || tokens.reasoning > 0;
282
+ }
283
+
284
+ function buildOutput(clientKey, events, pricingData) {
285
+ const dailyMap = new Map();
286
+ const workspaceMap = new Map();
287
+
288
+ for (const event of events) {
289
+ const dailyKey = `${event.date}::${event.model}`;
290
+ if (!dailyMap.has(dailyKey)) dailyMap.set(dailyKey, { date: event.date, model: event.model, tokens: zero() });
291
+ add(dailyMap.get(dailyKey).tokens, event.tokens);
292
+
293
+ const workspace = event.projectLabel || event.sessionId;
294
+ const workspaceKey = `${workspace}::${event.model}`;
295
+ if (!workspaceMap.has(workspaceKey)) {
296
+ workspaceMap.set(workspaceKey, {
297
+ workspace,
298
+ workspaceLabel: workspace,
299
+ model: event.model,
300
+ tokens: zero()
301
+ });
302
+ }
303
+ add(workspaceMap.get(workspaceKey).tokens, event.tokens);
304
+ }
305
+
306
+ const byDate = new Map();
307
+ for (const row of dailyMap.values()) {
308
+ if (!byDate.has(row.date)) byDate.set(row.date, []);
309
+ byDate.get(row.date).push(row);
310
+ }
311
+
312
+ const contributions = [...byDate.entries()]
313
+ .sort(([a], [b]) => a.localeCompare(b))
314
+ .map(([date, rows]) => ({
315
+ date,
316
+ clients: rows.map(row => ({
317
+ client: clientKey,
318
+ modelId: row.model,
319
+ tokens: toTokenPayload(row.tokens),
320
+ cost: calculateCost(row.model, toTokenPayload(row.tokens), pricingData)
321
+ }))
322
+ }));
323
+
324
+ const entries = [...workspaceMap.values()].map(row => ({
325
+ client: clientKey,
326
+ workspaceKey: row.workspace,
327
+ workspaceLabel: row.workspaceLabel,
328
+ model: row.model,
329
+ ...toTokenPayload(row.tokens),
330
+ cost: calculateCost(row.model, toTokenPayload(row.tokens), pricingData)
331
+ }));
332
+
333
+ const tokenEvents = events.map(event => ({
334
+ eventId: `${clientKey}:${event.eventId}`,
335
+ source: clientKey,
336
+ sessionId: event.sessionId,
337
+ timestamp: event.timestamp,
338
+ model: event.model,
339
+ inputTokens: event.tokens.input,
340
+ outputTokens: event.tokens.output,
341
+ cacheReadTokens: event.tokens.cacheRead,
342
+ cacheCreationTokens: event.tokens.cacheWrite,
343
+ reasoningTokens: event.tokens.reasoning,
344
+ toolCategory: event.toolCategory,
345
+ fileExtension: event.fileExtension,
346
+ repoPathHash: event.repoPathHash,
347
+ privacyLevel: event.repoPathHash ? 'hashed' : 'safe'
348
+ }));
349
+
350
+ return { graphJson: { contributions }, modelsJson: { entries }, tokenEvents };
351
+ }
352
+
353
+ function looksLikeConversation(row) {
354
+ return typeof row.prompt === 'string'
355
+ || typeof row.response === 'string'
356
+ || typeof row.content === 'string'
357
+ || typeof row.diff === 'string'
358
+ || typeof row.transcript === 'string'
359
+ || Array.isArray(row.messages);
360
+ }
361
+
362
+ function hasUnsafeConversationText(text) {
363
+ return /"(prompt|response|content|diff|transcript|messages)"\s*:/i.test(String(text || ''));
364
+ }
365
+
366
+ function projectLabelFrom(row) {
367
+ const direct = firstString(row.projectAlias, row.project_alias, row.project, row.workspace, row.repo, row.repository);
368
+ if (direct) return sanitizeSmallText(direct, 120);
369
+ const pathValue = firstString(row.projectPath, row.project_path, row.workspacePath, row.workspace_path, row.repoPath, row.repo_path, row.cwd);
370
+ return pathValue ? sanitizeSmallText(basename(String(pathValue).replace(/\\/g, '/')), 120) : null;
371
+ }
372
+
373
+ function firstString(...values) {
374
+ for (const value of values) {
375
+ if (typeof value === 'string' && value.trim()) return value.trim();
376
+ if (typeof value === 'number' && Number.isFinite(value)) return String(value);
377
+ }
378
+ return null;
379
+ }
380
+
381
+ function normalizeTimestamp(value) {
382
+ if (!value) return new Date().toISOString();
383
+ const number = Number(value);
384
+ const ms = Number.isFinite(number) && /^\d+(\.\d+)?$/.test(String(value))
385
+ ? (number > 1e12 ? number : number * 1000)
386
+ : new Date(value).getTime();
387
+ if (!Number.isFinite(ms)) return new Date().toISOString();
388
+ return new Date(ms).toISOString();
389
+ }
390
+
391
+ function positive(value) {
392
+ const number = Number(value ?? 0);
393
+ return Number.isFinite(number) && number > 0 ? Math.floor(number) : 0;
394
+ }
395
+
396
+ function zero() {
397
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 };
398
+ }
399
+
400
+ function add(target, tokens) {
401
+ target.input += tokens.input;
402
+ target.output += tokens.output;
403
+ target.cacheRead += tokens.cacheRead;
404
+ target.cacheWrite += tokens.cacheWrite;
405
+ target.reasoning += tokens.reasoning;
406
+ }
407
+
408
+ function toTokenPayload(tokens) {
409
+ return {
410
+ input: tokens.input,
411
+ output: tokens.output,
412
+ cacheRead: tokens.cacheRead,
413
+ cacheWrite: tokens.cacheWrite,
414
+ reasoning: tokens.reasoning
415
+ };
416
+ }
417
+
418
+ function stableEventId(payload) {
419
+ return createHash('sha256').update(JSON.stringify(payload)).digest('hex').slice(0, 32);
420
+ }
421
+
422
+ function hashPath(value) {
423
+ if (!value) return null;
424
+ return createHash('sha256').update(String(value)).digest('hex').slice(0, 32);
425
+ }
426
+
427
+ function sanitizeSmallText(value, maxLength) {
428
+ if (!value) return null;
429
+ return String(value).trim().replace(/\s+/g, ' ').slice(0, maxLength) || null;
430
+ }
431
+
432
+ function sanitizeExtension(value) {
433
+ if (!value) return null;
434
+ const text = String(value).trim().toLowerCase();
435
+ const match = text.match(/^\.[a-z0-9]{1,12}$/);
436
+ return match ? match[0] : null;
437
+ }
@@ -0,0 +1,93 @@
1
+ const REASONING_TIERS = new Set(['minimal', 'low', 'medium', 'high', 'xhigh', 'auto', 'none']);
2
+
3
+ function pad2(value) {
4
+ return String(value).padStart(2, '0');
5
+ }
6
+
7
+ export function localDateFromTimestamp(value, fallback = 'unknown') {
8
+ if (value == null || value === '') return fallback;
9
+
10
+ let ms;
11
+ if (typeof value === 'number') {
12
+ ms = value > 1e12 ? value : value * 1000;
13
+ } else {
14
+ ms = new Date(value).getTime();
15
+ }
16
+
17
+ if (!Number.isFinite(ms)) return fallback;
18
+ const date = new Date(ms);
19
+ if (Number.isNaN(date.getTime())) return fallback;
20
+
21
+ return [
22
+ date.getFullYear(),
23
+ pad2(date.getMonth() + 1),
24
+ pad2(date.getDate())
25
+ ].join('-');
26
+ }
27
+
28
+ export function normalizeModelForGrouping(modelId) {
29
+ let name = String(modelId || 'unknown').trim().toLowerCase();
30
+ if (!name) return 'unknown';
31
+
32
+ if (name.endsWith(')')) {
33
+ const openIndex = name.lastIndexOf('(');
34
+ if (openIndex > 0) {
35
+ const base = name.slice(0, openIndex);
36
+ const tier = name.slice(openIndex + 1, -1);
37
+ if (base.trim() === base && REASONING_TIERS.has(tier)) {
38
+ name = base;
39
+ }
40
+ }
41
+ }
42
+
43
+ if (name.length > 9) {
44
+ const suffix = name.slice(-8);
45
+ if (/^\d{8}$/.test(suffix) && name.at(-9) === '-') {
46
+ name = name.slice(0, -9);
47
+ }
48
+ }
49
+
50
+ if (name.includes('claude')) {
51
+ name = name.replace(/(?<=\d)\.(?=\d)/g, '-');
52
+ }
53
+
54
+ return name;
55
+ }
56
+
57
+ export function canonicalProvider(raw) {
58
+ if (typeof raw !== 'string') return null;
59
+ const parts = raw.trim().replace(/-/g, '_').split(/[/.]/);
60
+ for (const part of parts) {
61
+ const value = part.trim().toLowerCase();
62
+ if (!value || value === 'unknown') continue;
63
+ if (value === 'x_ai' || value === 'xai') return 'xai';
64
+ if (value === 'z_ai' || value === 'zai') return 'zai';
65
+ if (value === 'moonshot' || value === 'moonshotai') return 'moonshotai';
66
+ if (value === 'meta' || value === 'meta_llama') return 'meta_llama';
67
+ if (value === 'azure' || value === 'azure_ai') return 'azure_ai';
68
+ if (value === 'anthropic' || value === 'vertex' || value === 'vertex_ai') return 'anthropic';
69
+ if (value === 'together' || value === 'together_ai') return 'together_ai';
70
+ if (value === 'fireworks' || value === 'fireworks_ai') return 'fireworks_ai';
71
+ if (value === 'google' || value === 'gemini') return 'google';
72
+ if (value === 'openai' || value === 'openai_codex') return 'openai';
73
+ if (value === 'mistral' || value === 'mistralai') return 'mistralai';
74
+ if (value === 'ai21') return 'ai21';
75
+ if (!/\d/.test(value)) return value;
76
+ }
77
+ return null;
78
+ }
79
+
80
+ export function inferProviderFromModel(model) {
81
+ const lower = String(model || '').toLowerCase();
82
+ if (lower.includes('claude') || lower.includes('anthropic') || /\b(opus|sonnet|haiku)\b/.test(lower)) return 'anthropic';
83
+ if (lower.includes('gpt') || lower.includes('openai') || /\b(o1|o3|o4)\b/.test(lower)) return 'openai';
84
+ if (lower.includes('gemini') || lower.includes('google')) return 'google';
85
+ if (lower.includes('grok')) return 'xai';
86
+ if (lower.includes('deepseek')) return 'deepseek';
87
+ if (lower.includes('mimo') || lower.includes('xiaomi')) return 'xiaomi';
88
+ if (lower.includes('mistral') || lower.includes('mixtral')) return 'mistral';
89
+ if (lower.includes('llama') || /\bmeta\b/.test(lower)) return 'meta';
90
+ if (lower.includes('qwen')) return 'qwen';
91
+ if (lower.includes('glm')) return 'zai';
92
+ return null;
93
+ }