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.
- package/.nvmrc +1 -0
- package/CHANGELOG.md +89 -0
- package/Dockerfile +17 -0
- package/LICENSE +22 -0
- package/NOTICE.md +21 -0
- package/PRIVACY.md +68 -0
- package/README.en.md +220 -0
- package/README.md +220 -0
- package/config/collectors.json +54 -0
- package/data/.gitkeep +1 -0
- package/docker-compose.yml +17 -0
- package/docs/assets/.gitkeep +1 -0
- package/docs/assets/token-studio-v44-dashboard.png +0 -0
- package/docs/assets/token-studio-v44-live.png +0 -0
- package/docs/assets/token-studio-v44-review-mobile.png +0 -0
- package/docs/assets/token-studio-v44-review.png +0 -0
- package/docs/assets/token-studio-v45-dashboard.png +0 -0
- package/docs/assets/token-studio-v45-live.png +0 -0
- package/docs/assets/token-studio-v45-review-mobile.png +0 -0
- package/docs/assets/token-studio-v45-review.png +0 -0
- package/docs/blog-case-study.md +34 -0
- package/docs/collector-support-matrix.md +65 -0
- package/docs/competitive-notes.md +87 -0
- package/docs/demo-data/README.md +12 -0
- package/docs/demo-data/token-studio-v2-demo.json +146 -0
- package/docs/demo-flow.md +39 -0
- package/docs/first-run.md +95 -0
- package/docs/local-collectors.md +49 -0
- package/docs/public-launch-checklist.md +45 -0
- package/docs/resume-bullets.md +7 -0
- package/docs/statusline.md +52 -0
- package/index.html +16 -0
- package/package.json +36 -0
- package/render.yaml +17 -0
- package/src/auto-attribution.mjs +396 -0
- package/src/ccusage-bridge.mjs +74 -0
- package/src/ccusage-import.mjs +415 -0
- package/src/cli.mjs +643 -0
- package/src/client/dashboard/App.jsx +1734 -0
- package/src/client/dashboard/annotation-presets.js +138 -0
- package/src/client/dashboard/attribution.js +328 -0
- package/src/client/dashboard/components-charts.jsx +622 -0
- package/src/client/dashboard/components-tables.jsx +1531 -0
- package/src/client/dashboard/components-top.jsx +307 -0
- package/src/client/dashboard/import-budget.js +41 -0
- package/src/client/dashboard/model-usage.js +108 -0
- package/src/client/dashboard/onboarding.js +80 -0
- package/src/client/dashboard/styles.css +2606 -0
- package/src/client/live/LiveApp.jsx +226 -0
- package/src/client/live/styles.css +446 -0
- package/src/client/main.jsx +20 -0
- package/src/client/review/ReviewApp.jsx +507 -0
- package/src/client/review/closure-progress.js +165 -0
- package/src/client/review/markdown-report.js +401 -0
- package/src/client/review/model-strategy.js +273 -0
- package/src/client/review/roi-advisor.js +255 -0
- package/src/client/review/roi-evidence.js +78 -0
- package/src/client/review/savings-simulator.js +252 -0
- package/src/client/review/sections-1.jsx +277 -0
- package/src/client/review/sections-2.jsx +927 -0
- package/src/client/review/styles.css +2321 -0
- package/src/client/review/utils.js +345 -0
- package/src/client/shared/utils.js +236 -0
- package/src/closure-check.mjs +537 -0
- package/src/closure-import.mjs +646 -0
- package/src/collect.mjs +247 -0
- package/src/collector-config.mjs +82 -0
- package/src/collector-registry.mjs +333 -0
- package/src/collectors/claude-code.mjs +355 -0
- package/src/collectors/codex.mjs +418 -0
- package/src/collectors/copilot.mjs +19 -0
- package/src/collectors/cursor.mjs +23 -0
- package/src/collectors/gemini.mjs +530 -0
- package/src/collectors/goose.mjs +15 -0
- package/src/collectors/hermes.mjs +206 -0
- package/src/collectors/kimi.mjs +15 -0
- package/src/collectors/openclaw.mjs +400 -0
- package/src/collectors/opencode.mjs +349 -0
- package/src/collectors/qwen.mjs +15 -0
- package/src/collectors/structured-usage.mjs +437 -0
- package/src/collectors/utils.mjs +93 -0
- package/src/db.mjs +1397 -0
- package/src/demo-seed.mjs +39 -0
- package/src/dev.mjs +43 -0
- package/src/live.mjs +428 -0
- package/src/model-policy.mjs +147 -0
- package/src/pricing.mjs +434 -0
- package/src/privacy-check.mjs +126 -0
- package/src/server.mjs +1240 -0
- package/src/source-health.mjs +195 -0
- package/src/statusline.mjs +156 -0
- package/src/terminal-report.mjs +245 -0
- package/src/update-pricing.mjs +8 -0
- package/test/annotation-presets.test.mjs +137 -0
- package/test/api-annotations.test.mjs +202 -0
- package/test/api-auto-attribution.test.mjs +169 -0
- package/test/api-source-health.test.mjs +109 -0
- package/test/api-v2.test.mjs +278 -0
- package/test/api-v43.test.mjs +151 -0
- package/test/api-v44.test.mjs +128 -0
- package/test/attribution-summary.test.mjs +164 -0
- package/test/auto-attribution.test.mjs +116 -0
- package/test/ccusage-bridge.test.mjs +36 -0
- package/test/ccusage-import.test.mjs +93 -0
- package/test/cli-v43.test.mjs +64 -0
- package/test/cli-v45.test.mjs +34 -0
- package/test/cli-v46.test.mjs +129 -0
- package/test/cli-v47.test.mjs +98 -0
- package/test/closure-check.test.mjs +202 -0
- package/test/closure-import.test.mjs +263 -0
- package/test/collector-config.test.mjs +25 -0
- package/test/collector-registry.test.mjs +56 -0
- package/test/csv.test.mjs +19 -0
- package/test/db-annotations.test.mjs +186 -0
- package/test/db-v2.test.mjs +200 -0
- package/test/db-v4.test.mjs +178 -0
- package/test/experimental-collectors.test.mjs +103 -0
- package/test/fixtures/collectors/copilot/usage.jsonl +2 -0
- package/test/fixtures/collectors/cursor/usage.jsonl +2 -0
- package/test/fixtures/collectors/goose/usage.jsonl +2 -0
- package/test/fixtures/collectors/kimi/usage.jsonl +2 -0
- package/test/fixtures/collectors/qwen/usage.jsonl +2 -0
- package/test/import-budget.test.mjs +40 -0
- package/test/live.test.mjs +256 -0
- package/test/markdown-report.test.mjs +193 -0
- package/test/model-policy.test.mjs +34 -0
- package/test/model-strategy.test.mjs +116 -0
- package/test/model-usage.test.mjs +99 -0
- package/test/official-pricing.test.mjs +70 -0
- package/test/onboarding.test.mjs +55 -0
- package/test/privacy-check.test.mjs +33 -0
- package/test/review-closure-progress.test.mjs +99 -0
- package/test/roi-advisor.test.mjs +188 -0
- package/test/roi-evidence.test.mjs +48 -0
- package/test/roi-summary.test.mjs +101 -0
- package/test/savings-simulator.test.mjs +141 -0
- package/test/source-health.test.mjs +62 -0
- package/test/statusline.test.mjs +148 -0
- package/vite.config.js +23 -0
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure-JavaScript Claude Code data collector.
|
|
3
|
+
*
|
|
4
|
+
* Reads JSONL session files from the Claude Code projects directory and
|
|
5
|
+
* returns data in the common collector shape consumed by collect.mjs.
|
|
6
|
+
*
|
|
7
|
+
* Supported platforms: macOS, Linux, Windows — no native binaries required.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
import { join, relative } from 'node:path';
|
|
13
|
+
import { configuredBool, configuredPath, configuredPaths, envPathList } from '../collector-config.mjs';
|
|
14
|
+
import { calculateCost } from '../pricing.mjs';
|
|
15
|
+
import { localDateFromTimestamp, normalizeModelForGrouping } from './utils.mjs';
|
|
16
|
+
|
|
17
|
+
export const CLIENT_KEY = 'claude';
|
|
18
|
+
export const SOURCE_LABEL = 'Claude Code';
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Path resolution
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Return Claude Code data roots. Claude Code has used both ~/.claude and
|
|
26
|
+
* ~/.config/claude layouts; CLAUDE_CONFIG_DIR may contain comma-separated
|
|
27
|
+
* custom roots. Each root is expected to contain a projects/ directory.
|
|
28
|
+
*/
|
|
29
|
+
export function getClaudeRoots() {
|
|
30
|
+
const envRoots = envPathList(process.env.CLAUDE_CONFIG_DIR);
|
|
31
|
+
if (envRoots.length) return envRoots;
|
|
32
|
+
|
|
33
|
+
return configuredPaths('claude', 'roots');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function getScanRoots() {
|
|
37
|
+
const envRoots = envPathList(process.env.CLAUDE_CONFIG_DIR);
|
|
38
|
+
const includeDesktopLocalAgent = configuredBool('claude', 'includeDesktopLocalAgent', true);
|
|
39
|
+
const roots = envRoots.length
|
|
40
|
+
? envRoots
|
|
41
|
+
: [
|
|
42
|
+
...getClaudeRoots(),
|
|
43
|
+
...(includeDesktopLocalAgent ? await getClaudeDesktopLocalAgentRoots() : [])
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
return unique(roots).flatMap((root) => [
|
|
47
|
+
{ type: 'projects', path: join(root, 'projects') },
|
|
48
|
+
{ type: 'transcripts', path: join(root, 'transcripts') }
|
|
49
|
+
]);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function collectJsonlFiles(dir) {
|
|
53
|
+
const results = [];
|
|
54
|
+
const entries = await safeReaddir(dir);
|
|
55
|
+
for (const entry of entries) {
|
|
56
|
+
const fullPath = join(dir, entry.name);
|
|
57
|
+
if (entry.isDirectory()) {
|
|
58
|
+
results.push(...await collectJsonlFiles(fullPath));
|
|
59
|
+
} else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
|
|
60
|
+
results.push(fullPath);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return results;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function getClaudeDesktopLocalAgentRoots() {
|
|
67
|
+
if (process.platform !== 'darwin') return [];
|
|
68
|
+
|
|
69
|
+
const base = configuredPath(
|
|
70
|
+
'claude',
|
|
71
|
+
'desktopLocalAgentBase',
|
|
72
|
+
`${homedir()}/Library/Application Support/Claude/local-agent-mode-sessions`
|
|
73
|
+
);
|
|
74
|
+
if (!base) return [];
|
|
75
|
+
const sessionDirs = await collectClaudeDirs(base);
|
|
76
|
+
return sessionDirs.filter((dir) => /[/\\]local_[^/\\]+[/\\]\.claude$/.test(dir));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function collectClaudeDirs(dir) {
|
|
80
|
+
const results = [];
|
|
81
|
+
const entries = await safeReaddir(dir);
|
|
82
|
+
for (const entry of entries) {
|
|
83
|
+
const fullPath = join(dir, entry.name);
|
|
84
|
+
if (!entry.isDirectory()) continue;
|
|
85
|
+
|
|
86
|
+
if (entry.name === '.claude') {
|
|
87
|
+
results.push(fullPath);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
results.push(...await collectClaudeDirs(fullPath));
|
|
92
|
+
}
|
|
93
|
+
return results;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function unique(values) {
|
|
97
|
+
return [...new Set(values)];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Attempt to decode a project directory name into a human-readable path.
|
|
102
|
+
* Claude Code URL-encodes the absolute project path as the directory name,
|
|
103
|
+
* e.g. "%2FUsers%2Fjohn%2Fmy-project". Fall back to the raw name when
|
|
104
|
+
* decoding fails (older or unknown formats).
|
|
105
|
+
*/
|
|
106
|
+
function decodeWorkspaceLabel(dirName) {
|
|
107
|
+
try {
|
|
108
|
+
const decoded = decodeURIComponent(dirName);
|
|
109
|
+
// Only use decoded form when it looks like an absolute path
|
|
110
|
+
if (decoded.startsWith('/') || /^[A-Za-z]:\\/.test(decoded)) {
|
|
111
|
+
return decoded;
|
|
112
|
+
}
|
|
113
|
+
} catch {
|
|
114
|
+
// ignore
|
|
115
|
+
}
|
|
116
|
+
return dirName;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// JSONL parsing
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Read one session JSONL file and return an array of assistant-turn records.
|
|
125
|
+
* Each record carries { timestamp, model, usage, costUSD }.
|
|
126
|
+
*
|
|
127
|
+
* Claude Code can write multiple assistant usage snapshots for the same
|
|
128
|
+
* streamed response. Collapse message.id+requestId duplicates, fall back to
|
|
129
|
+
* message.id when requestId is absent, and keep the largest token value seen
|
|
130
|
+
* for each field.
|
|
131
|
+
*/
|
|
132
|
+
async function parseSessionFile(filePath) {
|
|
133
|
+
let text;
|
|
134
|
+
try {
|
|
135
|
+
text = await readFile(filePath, 'utf8');
|
|
136
|
+
} catch {
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const records = [];
|
|
141
|
+
const dedupIndex = new Map();
|
|
142
|
+
for (const line of text.split('\n')) {
|
|
143
|
+
const trimmed = line.trim();
|
|
144
|
+
if (!trimmed) continue;
|
|
145
|
+
|
|
146
|
+
let obj;
|
|
147
|
+
try {
|
|
148
|
+
obj = JSON.parse(trimmed);
|
|
149
|
+
} catch {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Only assistant turns carry usage information
|
|
154
|
+
if (obj.type !== 'assistant' || !obj.message?.usage) continue;
|
|
155
|
+
|
|
156
|
+
const record = {
|
|
157
|
+
timestamp: typeof obj.timestamp === 'string' ? obj.timestamp : null,
|
|
158
|
+
model: obj.message.model || obj.model || 'unknown',
|
|
159
|
+
usage: obj.message.usage,
|
|
160
|
+
costUSD: typeof obj.costUSD === 'number' ? obj.costUSD : 0,
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const dedupKey = dedupKeyForAssistant(obj);
|
|
164
|
+
|
|
165
|
+
if (dedupKey && dedupIndex.has(dedupKey)) {
|
|
166
|
+
const existing = records[dedupIndex.get(dedupKey)];
|
|
167
|
+
mergeUsageMax(existing.usage, record.usage);
|
|
168
|
+
existing.costUSD = Math.max(existing.costUSD || 0, record.costUSD || 0);
|
|
169
|
+
if (!existing.timestamp && record.timestamp) existing.timestamp = record.timestamp;
|
|
170
|
+
if (existing.model === 'unknown' && record.model !== 'unknown') existing.model = record.model;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (dedupKey) dedupIndex.set(dedupKey, records.length);
|
|
175
|
+
records.push(record);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return records;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function dedupKeyForAssistant(obj) {
|
|
182
|
+
const messageId = obj.message?.id;
|
|
183
|
+
if (!messageId) return null;
|
|
184
|
+
return obj.requestId ? `${messageId}:${obj.requestId}` : `message:${messageId}`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function mergeUsageMax(target, source) {
|
|
188
|
+
for (const key of [
|
|
189
|
+
'input_tokens',
|
|
190
|
+
'output_tokens',
|
|
191
|
+
'cache_read_input_tokens',
|
|
192
|
+
'cache_creation_input_tokens',
|
|
193
|
+
'reasoning_tokens',
|
|
194
|
+
'thinking_tokens'
|
|
195
|
+
]) {
|
|
196
|
+
target[key] = Math.max(Number(target[key] || 0), Number(source[key] || 0));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
// Safe directory helpers
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
async function safeReaddir(dirPath) {
|
|
205
|
+
try {
|
|
206
|
+
return await readdir(dirPath, { withFileTypes: true });
|
|
207
|
+
} catch {
|
|
208
|
+
return [];
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
// Aggregation helpers
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
function zeroTokens() {
|
|
217
|
+
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function extractTokens(usage) {
|
|
221
|
+
return {
|
|
222
|
+
input: usage.input_tokens || 0,
|
|
223
|
+
output: usage.output_tokens || 0,
|
|
224
|
+
cacheRead: usage.cache_read_input_tokens || 0,
|
|
225
|
+
cacheWrite: usage.cache_creation_input_tokens || 0,
|
|
226
|
+
// Newer models expose reasoning/thinking tokens
|
|
227
|
+
reasoning: usage.reasoning_tokens || usage.thinking_tokens || 0
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function addInto(target, tokens) {
|
|
232
|
+
target.input += tokens.input;
|
|
233
|
+
target.output += tokens.output;
|
|
234
|
+
target.cacheRead += tokens.cacheRead;
|
|
235
|
+
target.cacheWrite += tokens.cacheWrite;
|
|
236
|
+
target.reasoning += tokens.reasoning;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
// Main collector
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Scan the Claude Code projects directory and return the common daily and
|
|
245
|
+
* workspace/model objects consumed by collect.mjs.
|
|
246
|
+
*
|
|
247
|
+
* @returns {{ graphJson: object, modelsJson: object }}
|
|
248
|
+
*/
|
|
249
|
+
export async function collect(pricingData = null) {
|
|
250
|
+
// dailyKey ("YYYY-MM-DD::model") -> aggregated token counts
|
|
251
|
+
const dailyMap = new Map();
|
|
252
|
+
// workspaceModelKey ("workspaceDir::model") -> aggregated token counts
|
|
253
|
+
const wmMap = new Map();
|
|
254
|
+
|
|
255
|
+
for (const root of await getScanRoots()) {
|
|
256
|
+
const filePaths = await collectJsonlFiles(root.path);
|
|
257
|
+
for (const filePath of filePaths) {
|
|
258
|
+
const workspaceKey = workspaceKeyFromPath(root, filePath);
|
|
259
|
+
const workspaceLabel = decodeWorkspaceLabel(workspaceKey);
|
|
260
|
+
const records = await parseSessionFile(filePath);
|
|
261
|
+
|
|
262
|
+
for (const record of records) {
|
|
263
|
+
const tokens = extractTokens(record.usage);
|
|
264
|
+
aggregateRecord({ ...record, tokens, workspaceKey, workspaceLabel }, dailyMap, wmMap, pricingData);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// -----------------------------------------------------------------------
|
|
270
|
+
// Convert to common daily JSON
|
|
271
|
+
// -----------------------------------------------------------------------
|
|
272
|
+
const byDate = new Map();
|
|
273
|
+
for (const row of dailyMap.values()) {
|
|
274
|
+
if (!byDate.has(row.date)) byDate.set(row.date, []);
|
|
275
|
+
byDate.get(row.date).push(row);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const contributions = [...byDate.entries()]
|
|
279
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
280
|
+
.map(([date, rows]) => ({
|
|
281
|
+
date,
|
|
282
|
+
clients: rows.map((row) => ({
|
|
283
|
+
client: CLIENT_KEY,
|
|
284
|
+
modelId: row.model,
|
|
285
|
+
tokens: {
|
|
286
|
+
input: row.input,
|
|
287
|
+
output: row.output,
|
|
288
|
+
cacheRead: row.cacheRead,
|
|
289
|
+
cacheWrite: row.cacheWrite,
|
|
290
|
+
reasoning: row.reasoning
|
|
291
|
+
},
|
|
292
|
+
cost: row.cost
|
|
293
|
+
}))
|
|
294
|
+
}));
|
|
295
|
+
|
|
296
|
+
const graphJson = { contributions };
|
|
297
|
+
|
|
298
|
+
// -----------------------------------------------------------------------
|
|
299
|
+
// Convert to common workspace/model JSON
|
|
300
|
+
// -----------------------------------------------------------------------
|
|
301
|
+
const entries = [...wmMap.values()].map((wm) => ({
|
|
302
|
+
client: CLIENT_KEY,
|
|
303
|
+
workspaceKey: wm.workspace,
|
|
304
|
+
workspaceLabel: wm.workspaceLabel,
|
|
305
|
+
model: wm.model,
|
|
306
|
+
input: wm.input,
|
|
307
|
+
output: wm.output,
|
|
308
|
+
cacheRead: wm.cacheRead,
|
|
309
|
+
cacheWrite: wm.cacheWrite,
|
|
310
|
+
reasoning: wm.reasoning,
|
|
311
|
+
cost: wm.cost
|
|
312
|
+
}));
|
|
313
|
+
|
|
314
|
+
const modelsJson = { entries };
|
|
315
|
+
|
|
316
|
+
return { graphJson, modelsJson };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function workspaceKeyFromPath(root, filePath) {
|
|
320
|
+
const rel = relative(root.path, filePath);
|
|
321
|
+
const firstSegment = rel.split(/[\\/]/).find(Boolean);
|
|
322
|
+
if (root.type === 'projects' && firstSegment) return firstSegment;
|
|
323
|
+
return `transcripts:${firstSegment || filePath}`;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function aggregateRecord(record, dailyMap, wmMap, pricingData) {
|
|
327
|
+
const date = localDateFromTimestamp(record.timestamp);
|
|
328
|
+
const model = normalizeModelForGrouping(record.model);
|
|
329
|
+
const tokens = record.tokens || extractTokens(record.usage);
|
|
330
|
+
const costUSD = calculateCost(model, tokens, pricingData);
|
|
331
|
+
|
|
332
|
+
// --- daily ---
|
|
333
|
+
const dk = `${date}::${model}`;
|
|
334
|
+
if (!dailyMap.has(dk)) {
|
|
335
|
+
dailyMap.set(dk, { date, model, ...zeroTokens(), cost: 0 });
|
|
336
|
+
}
|
|
337
|
+
const dayAgg = dailyMap.get(dk);
|
|
338
|
+
addInto(dayAgg, tokens);
|
|
339
|
+
dayAgg.cost += costUSD;
|
|
340
|
+
|
|
341
|
+
// --- workspace+model ---
|
|
342
|
+
const wmk = `${record.workspaceKey}::${model}`;
|
|
343
|
+
if (!wmMap.has(wmk)) {
|
|
344
|
+
wmMap.set(wmk, {
|
|
345
|
+
workspace: record.workspaceKey,
|
|
346
|
+
workspaceLabel: record.workspaceLabel,
|
|
347
|
+
model,
|
|
348
|
+
...zeroTokens(),
|
|
349
|
+
cost: 0
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
const wmAgg = wmMap.get(wmk);
|
|
353
|
+
addInto(wmAgg, tokens);
|
|
354
|
+
wmAgg.cost += costUSD;
|
|
355
|
+
}
|