agent-usage-report 0.1.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.
@@ -0,0 +1,1417 @@
1
+ import { existsSync, readdirSync } from "node:fs";
2
+ import { readFile, readdir, writeFile } from "node:fs/promises";
3
+ import { homedir, platform as osPlatform, release as osRelease } from "node:os";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+ const MILLION = 1_000_000;
8
+ const REPORT_SCHEMA_VERSION = 2;
9
+ const DEFAULT_TIMEZONE = "America/Mexico_City";
10
+ const DEFAULT_PROVIDER_ID = "codex";
11
+ const DEFAULT_PROVIDER_LABEL = "Codex CLI";
12
+ const DEFAULT_PROVIDER_SHORT_LABEL = "Codex";
13
+ const CLAUDE_PROVIDER_ID = "claude";
14
+ const CLAUDE_PROVIDER_LABEL = "Claude Code";
15
+ const CLAUDE_PROVIDER_SHORT_LABEL = "Claude";
16
+ const OPENCODE_PROVIDER_ID = "opencode";
17
+ const OPENCODE_PROVIDER_LABEL = "Open Code";
18
+ const OPENCODE_PROVIDER_SHORT_LABEL = "OpenCode";
19
+ const PI_PROVIDER_ID = "pi";
20
+ const PI_PROVIDER_LABEL = "Pi Coding Agent";
21
+ const PI_PROVIDER_SHORT_LABEL = "Pi";
22
+ const COMBINED_PROVIDER_ID = "all";
23
+ const COMBINED_PROVIDER_LABEL = "All Providers";
24
+ const COMBINED_PROVIDER_SHORT_LABEL = "All";
25
+ const LEGACY_FALLBACK_MODEL = "gpt-5";
26
+ const LITELLM_PRICING_URL = "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json";
27
+ const MODEL_ALIASES = {
28
+ "gpt-5-codex": "gpt-5",
29
+ "gpt-5.3-codex": "gpt-5.2-codex",
30
+ };
31
+ const MODEL_PREFIXES = [
32
+ "openai/",
33
+ "azure/",
34
+ "openrouter/openai/",
35
+ "chatgpt/",
36
+ ];
37
+ const FREE_MODEL_PRICING = {
38
+ inputCostPerMToken: 0,
39
+ cachedInputCostPerMToken: 0,
40
+ outputCostPerMToken: 0,
41
+ };
42
+ const BUILTIN_MODEL_PRICING = {
43
+ "gpt-5": { inputCostPerMToken: 1.25, cachedInputCostPerMToken: 0.125, outputCostPerMToken: 10 },
44
+ "gpt-5.1-codex": { inputCostPerMToken: 1.25, cachedInputCostPerMToken: 0.125, outputCostPerMToken: 10 },
45
+ "gpt-5.1-codex-max": { inputCostPerMToken: 1.25, cachedInputCostPerMToken: 0.125, outputCostPerMToken: 10 },
46
+ "gpt-5.1-codex-mini": { inputCostPerMToken: 0.25, cachedInputCostPerMToken: 0.025, outputCostPerMToken: 2 },
47
+ "gpt-5.2": { inputCostPerMToken: 1.75, cachedInputCostPerMToken: 0.175, outputCostPerMToken: 14 },
48
+ "gpt-5.2-codex": { inputCostPerMToken: 1.75, cachedInputCostPerMToken: 0.175, outputCostPerMToken: 14 },
49
+ "gpt-5.3-codex": { inputCostPerMToken: 1.75, cachedInputCostPerMToken: 0.175, outputCostPerMToken: 14 },
50
+ "gpt-5.4": { inputCostPerMToken: 2.5, cachedInputCostPerMToken: 0.25, outputCostPerMToken: 15 },
51
+ };
52
+ const SCAN_OUTPUT_KEYS = [
53
+ "filesScanned",
54
+ "jsonlFilesScanned",
55
+ "jsonFilesScanned",
56
+ "parseErrors",
57
+ "nullInfoEventsSkipped",
58
+ "duplicateEventsSkipped",
59
+ "syntheticEventsSkipped",
60
+ "zeroTotalEventsSkipped",
61
+ "tokenEventsCounted",
62
+ "unsupportedLegacyFiles",
63
+ "activityOnlyDays",
64
+ ];
65
+ function createEmptyUsageTotals() {
66
+ return {
67
+ input_tokens: 0,
68
+ cached_input_tokens: 0,
69
+ output_tokens: 0,
70
+ reasoning_output_tokens: 0,
71
+ total_tokens: 0,
72
+ };
73
+ }
74
+ function createEmptyDailyUsage() {
75
+ return {
76
+ ...createEmptyUsageTotals(),
77
+ events: 0,
78
+ model_usage: {},
79
+ };
80
+ }
81
+ function createEmptyScanStats() {
82
+ return {
83
+ filesScanned: 0,
84
+ jsonlFilesScanned: 0,
85
+ jsonFilesScanned: 0,
86
+ parseErrors: 0,
87
+ nullInfoEventsSkipped: 0,
88
+ duplicateEventsSkipped: 0,
89
+ syntheticEventsSkipped: 0,
90
+ zeroTotalEventsSkipped: 0,
91
+ tokenEventsCounted: 0,
92
+ unsupportedLegacyFiles: 0,
93
+ activityOnlyDays: 0,
94
+ };
95
+ }
96
+ function asNonEmptyString(value) {
97
+ if (typeof value !== "string")
98
+ return null;
99
+ const trimmed = value.trim();
100
+ return trimmed === "" ? null : trimmed;
101
+ }
102
+ function toNumber(value, fallback = 0) {
103
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
104
+ }
105
+ function parseIsoTimestamp(value) {
106
+ if (!value)
107
+ return null;
108
+ const normalized = value.replace("Z", "+00:00");
109
+ const parsed = new Date(normalized);
110
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
111
+ }
112
+ function parseFlexibleTimestamp(value) {
113
+ if (typeof value === "string")
114
+ return parseIsoTimestamp(value);
115
+ if (typeof value === "number" && Number.isFinite(value)) {
116
+ const numeric = Math.abs(value) >= 1_000_000_000_000 ? value / 1000 : value;
117
+ const parsed = new Date(numeric * 1000);
118
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
119
+ }
120
+ return null;
121
+ }
122
+ function dayKeyInTimezone(date, timeZone) {
123
+ return new Intl.DateTimeFormat("en-CA", {
124
+ timeZone,
125
+ year: "numeric",
126
+ month: "2-digit",
127
+ day: "2-digit",
128
+ }).format(date);
129
+ }
130
+ function addUsage(map, dayKey, usage, model, isFallbackModel = false) {
131
+ const entry = map.get(dayKey) ?? createEmptyDailyUsage();
132
+ entry.input_tokens += usage.input_tokens;
133
+ entry.cached_input_tokens += usage.cached_input_tokens;
134
+ entry.output_tokens += usage.output_tokens;
135
+ entry.reasoning_output_tokens += usage.reasoning_output_tokens;
136
+ entry.total_tokens += usage.total_tokens;
137
+ entry.events += 1;
138
+ if (model) {
139
+ const modelEntry = entry.model_usage[model] ??
140
+ {
141
+ ...createEmptyUsageTotals(),
142
+ events: 0,
143
+ is_fallback_model: false,
144
+ };
145
+ modelEntry.input_tokens += usage.input_tokens;
146
+ modelEntry.cached_input_tokens += usage.cached_input_tokens;
147
+ modelEntry.output_tokens += usage.output_tokens;
148
+ modelEntry.reasoning_output_tokens += usage.reasoning_output_tokens;
149
+ modelEntry.total_tokens += usage.total_tokens;
150
+ modelEntry.events += 1;
151
+ modelEntry.is_fallback_model = modelEntry.is_fallback_model || isFallbackModel;
152
+ entry.model_usage[model] = modelEntry;
153
+ }
154
+ map.set(dayKey, entry);
155
+ }
156
+ function normalizeCodexUsage(raw) {
157
+ const usage = typeof raw === "object" && raw !== null ? raw : {};
158
+ const input_tokens = Math.trunc(toNumber(usage.input_tokens));
159
+ const cached_input_tokens = Math.trunc(toNumber(usage.cached_input_tokens, toNumber(usage.cache_read_input_tokens)));
160
+ const output_tokens = Math.trunc(toNumber(usage.output_tokens));
161
+ const reasoning_output_tokens = Math.trunc(toNumber(usage.reasoning_output_tokens));
162
+ const total_tokens = Math.trunc(toNumber(usage.total_tokens));
163
+ return {
164
+ input_tokens,
165
+ cached_input_tokens,
166
+ output_tokens,
167
+ reasoning_output_tokens,
168
+ total_tokens: total_tokens > 0 ? total_tokens : input_tokens + output_tokens,
169
+ };
170
+ }
171
+ function normalizeClaudeUsage(raw) {
172
+ const usage = typeof raw === "object" && raw !== null ? raw : {};
173
+ const cacheRead = Math.trunc(toNumber(usage.cache_read_input_tokens));
174
+ const cacheCreation = Math.trunc(toNumber(usage.cache_creation_input_tokens));
175
+ const input_tokens = Math.trunc(toNumber(usage.input_tokens)) + cacheRead;
176
+ const output_tokens = Math.trunc(toNumber(usage.output_tokens)) + cacheCreation;
177
+ return {
178
+ input_tokens,
179
+ cached_input_tokens: cacheRead,
180
+ output_tokens,
181
+ reasoning_output_tokens: 0,
182
+ total_tokens: input_tokens + output_tokens,
183
+ };
184
+ }
185
+ function normalizePiUsage(raw) {
186
+ const usage = typeof raw === "object" && raw !== null ? raw : {};
187
+ const cacheRead = Math.trunc(toNumber(usage.cacheRead));
188
+ const cacheWrite = Math.trunc(toNumber(usage.cacheWrite));
189
+ const input_tokens = Math.trunc(toNumber(usage.input)) + cacheRead;
190
+ const output_tokens = Math.trunc(toNumber(usage.output)) + cacheWrite;
191
+ const total_tokens = Math.trunc(toNumber(usage.totalTokens));
192
+ return {
193
+ input_tokens,
194
+ cached_input_tokens: cacheRead,
195
+ output_tokens,
196
+ reasoning_output_tokens: 0,
197
+ total_tokens: total_tokens > 0 ? total_tokens : input_tokens + output_tokens,
198
+ };
199
+ }
200
+ function normalizeOpenCodeUsage(raw) {
201
+ const tokens = typeof raw === "object" && raw !== null ? raw : {};
202
+ const cache = typeof tokens.cache === "object" && tokens.cache !== null ? tokens.cache : {};
203
+ const cacheRead = Math.trunc(toNumber(cache.read));
204
+ const cacheWrite = Math.trunc(toNumber(cache.write));
205
+ const input_tokens = Math.trunc(toNumber(tokens.input)) + cacheRead;
206
+ const output_tokens = Math.trunc(toNumber(tokens.output)) + cacheWrite;
207
+ return {
208
+ input_tokens,
209
+ cached_input_tokens: cacheRead,
210
+ output_tokens,
211
+ reasoning_output_tokens: 0,
212
+ total_tokens: input_tokens + output_tokens,
213
+ };
214
+ }
215
+ function addUsageTotals(base, delta) {
216
+ return {
217
+ input_tokens: (base?.input_tokens ?? 0) + delta.input_tokens,
218
+ cached_input_tokens: (base?.cached_input_tokens ?? 0) + delta.cached_input_tokens,
219
+ output_tokens: (base?.output_tokens ?? 0) + delta.output_tokens,
220
+ reasoning_output_tokens: (base?.reasoning_output_tokens ?? 0) + delta.reasoning_output_tokens,
221
+ total_tokens: (base?.total_tokens ?? 0) + delta.total_tokens,
222
+ };
223
+ }
224
+ function subtractUsageTotals(current, previous) {
225
+ return {
226
+ input_tokens: Math.max(current.input_tokens - (previous?.input_tokens ?? 0), 0),
227
+ cached_input_tokens: Math.max(current.cached_input_tokens - (previous?.cached_input_tokens ?? 0), 0),
228
+ output_tokens: Math.max(current.output_tokens - (previous?.output_tokens ?? 0), 0),
229
+ reasoning_output_tokens: Math.max(current.reasoning_output_tokens - (previous?.reasoning_output_tokens ?? 0), 0),
230
+ total_tokens: Math.max(current.total_tokens - (previous?.total_tokens ?? 0), 0),
231
+ };
232
+ }
233
+ function didUsageTotalsRollback(current, previous) {
234
+ if (!previous)
235
+ return false;
236
+ return (current.input_tokens < previous.input_tokens ||
237
+ current.cached_input_tokens < previous.cached_input_tokens ||
238
+ current.output_tokens < previous.output_tokens ||
239
+ current.reasoning_output_tokens < previous.reasoning_output_tokens ||
240
+ current.total_tokens < previous.total_tokens);
241
+ }
242
+ function isSyntheticUsage(usage) {
243
+ return (usage.total_tokens > 0 &&
244
+ usage.input_tokens === 0 &&
245
+ usage.cached_input_tokens === 0 &&
246
+ usage.output_tokens === 0 &&
247
+ usage.reasoning_output_tokens === 0);
248
+ }
249
+ function modelLookupCandidates(model) {
250
+ const trimmed = model.trim();
251
+ const candidates = [trimmed];
252
+ if (!MODEL_PREFIXES.some((prefix) => trimmed.startsWith(prefix))) {
253
+ for (const prefix of MODEL_PREFIXES)
254
+ candidates.push(`${prefix}${trimmed}`);
255
+ }
256
+ return candidates;
257
+ }
258
+ function hasNonZeroPricing(pricing) {
259
+ if (!pricing)
260
+ return false;
261
+ return ["input_cost_per_token", "output_cost_per_token", "cache_read_input_token_cost"].some((field) => Number(pricing[field] ?? 0) > 0);
262
+ }
263
+ function toPerMillion(value, fallback) {
264
+ const source = value ?? fallback ?? 0;
265
+ return Number(source) * MILLION;
266
+ }
267
+ function isOpenRouterFreeModel(model) {
268
+ const normalized = model.trim().toLowerCase();
269
+ return normalized === "openrouter/free" || (normalized.startsWith("openrouter/") && normalized.endsWith(":free"));
270
+ }
271
+ export async function fetchLiteLLMPricingDataset() {
272
+ const controller = new AbortController();
273
+ const timeout = setTimeout(() => controller.abort(), 20_000);
274
+ try {
275
+ const response = await fetch(LITELLM_PRICING_URL, { signal: controller.signal });
276
+ if (!response.ok)
277
+ throw new Error(`Failed to fetch pricing: ${response.status}`);
278
+ const raw = await response.json();
279
+ if (!raw || typeof raw !== "object")
280
+ return {};
281
+ return raw;
282
+ }
283
+ finally {
284
+ clearTimeout(timeout);
285
+ }
286
+ }
287
+ export function resolveModelPricing(model, dataset) {
288
+ if (isOpenRouterFreeModel(model)) {
289
+ return {
290
+ requestedModel: model,
291
+ resolvedModel: model,
292
+ pricing: { ...FREE_MODEL_PRICING },
293
+ isMissing: false,
294
+ isAlias: false,
295
+ source: "free-route",
296
+ };
297
+ }
298
+ const ds = dataset ?? {};
299
+ for (const candidate of modelLookupCandidates(model)) {
300
+ const pricing = ds[candidate];
301
+ if (pricing && hasNonZeroPricing(pricing)) {
302
+ return {
303
+ requestedModel: model,
304
+ resolvedModel: candidate,
305
+ pricing: {
306
+ inputCostPerMToken: toPerMillion(pricing.input_cost_per_token),
307
+ cachedInputCostPerMToken: toPerMillion(pricing.cache_read_input_token_cost, pricing.input_cost_per_token),
308
+ outputCostPerMToken: toPerMillion(pricing.output_cost_per_token),
309
+ },
310
+ isMissing: false,
311
+ isAlias: candidate !== model,
312
+ source: "litellm-live",
313
+ };
314
+ }
315
+ }
316
+ const alias = MODEL_ALIASES[model];
317
+ if (alias) {
318
+ for (const candidate of modelLookupCandidates(alias)) {
319
+ const pricing = ds[candidate];
320
+ if (pricing && hasNonZeroPricing(pricing)) {
321
+ return {
322
+ requestedModel: model,
323
+ resolvedModel: candidate,
324
+ pricing: {
325
+ inputCostPerMToken: toPerMillion(pricing.input_cost_per_token),
326
+ cachedInputCostPerMToken: toPerMillion(pricing.cache_read_input_token_cost, pricing.input_cost_per_token),
327
+ outputCostPerMToken: toPerMillion(pricing.output_cost_per_token),
328
+ },
329
+ isMissing: false,
330
+ isAlias: true,
331
+ source: "litellm-alias",
332
+ };
333
+ }
334
+ }
335
+ }
336
+ const builtin = BUILTIN_MODEL_PRICING[model] ?? (alias ? BUILTIN_MODEL_PRICING[alias] : undefined);
337
+ if (builtin) {
338
+ return {
339
+ requestedModel: model,
340
+ resolvedModel: alias ?? model,
341
+ pricing: { ...builtin },
342
+ isMissing: false,
343
+ isAlias: Boolean(alias),
344
+ source: "builtin-fallback",
345
+ };
346
+ }
347
+ return {
348
+ requestedModel: model,
349
+ resolvedModel: null,
350
+ pricing: { ...FREE_MODEL_PRICING },
351
+ isMissing: true,
352
+ isAlias: false,
353
+ source: "missing",
354
+ };
355
+ }
356
+ export function calculateCostBreakdown(usage, pricing) {
357
+ const cachedInput = Math.min(usage.cached_input_tokens, usage.input_tokens);
358
+ const nonCachedInput = Math.max(usage.input_tokens - cachedInput, 0);
359
+ const outputTokens = usage.output_tokens;
360
+ const inputUSD = (nonCachedInput / MILLION) * pricing.inputCostPerMToken;
361
+ const cachedInputUSD = (cachedInput / MILLION) * pricing.cachedInputCostPerMToken;
362
+ const outputUSD = (outputTokens / MILLION) * pricing.outputCostPerMToken;
363
+ return {
364
+ inputUSD,
365
+ cachedInputUSD,
366
+ outputUSD,
367
+ totalUSD: inputUSD + cachedInputUSD + outputUSD,
368
+ };
369
+ }
370
+ async function listFilesRecursive(root, suffix) {
371
+ const results = [];
372
+ async function walk(dir) {
373
+ let entries;
374
+ try {
375
+ entries = await readdir(dir, { withFileTypes: true });
376
+ }
377
+ catch {
378
+ return;
379
+ }
380
+ for (const entry of entries) {
381
+ const fullPath = join(dir, entry.name);
382
+ if (entry.isDirectory()) {
383
+ await walk(fullPath);
384
+ }
385
+ else if (entry.isFile() && fullPath.endsWith(suffix)) {
386
+ results.push(fullPath);
387
+ }
388
+ }
389
+ }
390
+ await walk(root);
391
+ results.sort();
392
+ return results;
393
+ }
394
+ async function scanCodexProvider(codexHome, includeArchived, timeZone) {
395
+ const dailyUsage = new Map();
396
+ const displayValuesByDay = new Map();
397
+ const stats = createEmptyScanStats();
398
+ const roots = [join(codexHome, "sessions")];
399
+ if (includeArchived)
400
+ roots.push(join(codexHome, "archived_sessions"));
401
+ for (const root of roots) {
402
+ if (!existsSync(root))
403
+ continue;
404
+ const files = [
405
+ ...(await listFilesRecursive(root, ".jsonl")),
406
+ ...(await listFilesRecursive(root, ".json")),
407
+ ].sort();
408
+ for (const file of files) {
409
+ stats.filesScanned += 1;
410
+ if (file.endsWith(".jsonl")) {
411
+ stats.jsonlFilesScanned += 1;
412
+ await processCodexJsonlFile(file, dailyUsage, stats, timeZone);
413
+ }
414
+ else {
415
+ stats.jsonFilesScanned += 1;
416
+ await processLegacyJsonFile(file, stats);
417
+ }
418
+ }
419
+ }
420
+ return {
421
+ providerId: DEFAULT_PROVIDER_ID,
422
+ providerLabel: DEFAULT_PROVIDER_LABEL,
423
+ providerShortLabel: DEFAULT_PROVIDER_SHORT_LABEL,
424
+ sourceHome: codexHome,
425
+ dailyUsage,
426
+ displayValuesByDay,
427
+ stats,
428
+ };
429
+ }
430
+ async function processCodexJsonlFile(filePath, dailyUsage, stats, timeZone) {
431
+ const content = await readFile(filePath, "utf8");
432
+ let currentModel = null;
433
+ let currentModelIsFallback = false;
434
+ let previousTotals = null;
435
+ for (const rawLine of content.split(/\r?\n/)) {
436
+ const line = rawLine.trim();
437
+ if (line === "")
438
+ continue;
439
+ if (!line.includes('"type":"turn_context"') && !line.includes('"type":"event_msg"'))
440
+ continue;
441
+ if (!line.includes('"type":"token_count"') && !line.includes('"type":"turn_context"'))
442
+ continue;
443
+ let record;
444
+ try {
445
+ record = JSON.parse(line);
446
+ }
447
+ catch {
448
+ stats.parseErrors += 1;
449
+ continue;
450
+ }
451
+ const recordType = record.type;
452
+ const payload = typeof record.payload === "object" && record.payload !== null ? record.payload : {};
453
+ const extractedModel = extractCodexModel(payload);
454
+ if (recordType === "turn_context") {
455
+ if (extractedModel) {
456
+ currentModel = extractedModel;
457
+ currentModelIsFallback = false;
458
+ }
459
+ continue;
460
+ }
461
+ if (recordType !== "event_msg" || payload.type !== "token_count")
462
+ continue;
463
+ const info = typeof payload.info === "object" && payload.info !== null ? payload.info : null;
464
+ if (!info) {
465
+ stats.nullInfoEventsSkipped += 1;
466
+ continue;
467
+ }
468
+ const lastUsage = normalizeCodexUsage(info.last_token_usage);
469
+ const totalUsage = normalizeCodexUsage(info.total_token_usage);
470
+ let rawUsage = null;
471
+ if (totalUsage.total_tokens > 0) {
472
+ rawUsage = didUsageTotalsRollback(totalUsage, previousTotals)
473
+ ? lastUsage
474
+ : subtractUsageTotals(totalUsage, previousTotals);
475
+ previousTotals = totalUsage;
476
+ }
477
+ else if (lastUsage.total_tokens > 0) {
478
+ rawUsage = lastUsage;
479
+ previousTotals = addUsageTotals(previousTotals, rawUsage);
480
+ }
481
+ if (!rawUsage || rawUsage.total_tokens <= 0) {
482
+ stats.zeroTotalEventsSkipped += 1;
483
+ continue;
484
+ }
485
+ if (isSyntheticUsage(rawUsage)) {
486
+ stats.syntheticEventsSkipped += 1;
487
+ continue;
488
+ }
489
+ const timestamp = parseFlexibleTimestamp(record.timestamp);
490
+ if (!timestamp) {
491
+ stats.parseErrors += 1;
492
+ continue;
493
+ }
494
+ let eventModel = extractedModel ?? currentModel;
495
+ let isFallbackModel = false;
496
+ if (extractedModel) {
497
+ currentModel = extractedModel;
498
+ currentModelIsFallback = false;
499
+ }
500
+ else if (eventModel == null) {
501
+ eventModel = LEGACY_FALLBACK_MODEL;
502
+ isFallbackModel = true;
503
+ currentModel = eventModel;
504
+ currentModelIsFallback = true;
505
+ }
506
+ else if (currentModelIsFallback) {
507
+ isFallbackModel = true;
508
+ }
509
+ addUsage(dailyUsage, dayKeyInTimezone(timestamp, timeZone), rawUsage, eventModel, isFallbackModel);
510
+ stats.tokenEventsCounted += 1;
511
+ }
512
+ }
513
+ function extractCodexModel(payload) {
514
+ const direct = asNonEmptyString(payload.model) ?? asNonEmptyString(payload.model_name);
515
+ if (direct)
516
+ return direct;
517
+ const info = typeof payload.info === "object" && payload.info !== null ? payload.info : null;
518
+ if (info) {
519
+ const infoModel = asNonEmptyString(info.model) ?? asNonEmptyString(info.model_name);
520
+ if (infoModel)
521
+ return infoModel;
522
+ const metadata = typeof info.metadata === "object" && info.metadata !== null ? info.metadata : null;
523
+ if (metadata) {
524
+ const metadataModel = asNonEmptyString(metadata.model);
525
+ if (metadataModel)
526
+ return metadataModel;
527
+ }
528
+ }
529
+ const metadata = typeof payload.metadata === "object" && payload.metadata !== null ? payload.metadata : null;
530
+ return metadata ? asNonEmptyString(metadata.model) : null;
531
+ }
532
+ async function processLegacyJsonFile(filePath, stats) {
533
+ const content = await readFile(filePath, "utf8");
534
+ try {
535
+ const parsed = JSON.parse(content);
536
+ if (typeof parsed !== "object" || parsed == null || (!("usage" in parsed) && !("token_usage" in parsed))) {
537
+ stats.unsupportedLegacyFiles += 1;
538
+ }
539
+ }
540
+ catch {
541
+ stats.parseErrors += 1;
542
+ }
543
+ }
544
+ function discoverClaudeWorkDirs() {
545
+ try {
546
+ return readdirSync(homedir(), { withFileTypes: true })
547
+ .filter((entry) => entry.isDirectory() && entry.name.startsWith(".claude-"))
548
+ .map((entry) => resolve(join(homedir(), entry.name)))
549
+ .filter((dir) => existsSync(join(dir, "projects")) ||
550
+ existsSync(join(dir, "stats-cache.json")) ||
551
+ existsSync(join(dir, "history.jsonl")));
552
+ }
553
+ catch {
554
+ return [];
555
+ }
556
+ }
557
+ function resolveClaudeConfigPaths(configuredPaths) {
558
+ const seen = new Set();
559
+ const resolvedPaths = [];
560
+ const raw = configuredPaths ?? process.env.CLAUDE_CONFIG_DIR ?? "";
561
+ const explicit = raw
562
+ .split(",")
563
+ .map((part) => part.trim())
564
+ .filter(Boolean)
565
+ .map((part) => resolve(part));
566
+ if (configuredPaths != null && configuredPaths.trim() !== "") {
567
+ for (const path of explicit) {
568
+ if (!seen.has(path)) {
569
+ seen.add(path);
570
+ resolvedPaths.push(path);
571
+ }
572
+ }
573
+ return resolvedPaths;
574
+ }
575
+ const xdg = process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config");
576
+ const defaults = [join(xdg, "claude"), join(homedir(), ".claude")];
577
+ for (const path of [...explicit, ...defaults, ...discoverClaudeWorkDirs()]) {
578
+ const resolvedPath = resolve(path);
579
+ if (!seen.has(resolvedPath)) {
580
+ seen.add(resolvedPath);
581
+ resolvedPaths.push(resolvedPath);
582
+ }
583
+ }
584
+ return resolvedPaths;
585
+ }
586
+ function getClaudeProjectDirs(configPaths) {
587
+ return configPaths
588
+ .map((base) => join(base, "projects"))
589
+ .filter((dir, index, array) => existsSync(dir) && array.indexOf(dir) === index);
590
+ }
591
+ function getClaudeStatsCacheFiles(configPaths) {
592
+ return configPaths
593
+ .map((base) => join(base, "stats-cache.json"))
594
+ .filter((file, index, array) => existsSync(file) && array.indexOf(file) === index);
595
+ }
596
+ function getClaudeHistoryFiles(configPaths) {
597
+ return configPaths
598
+ .map((base) => join(base, "history.jsonl"))
599
+ .filter((file, index, array) => existsSync(file) && array.indexOf(file) === index);
600
+ }
601
+ async function scanClaudeProvider(configPaths, timeZone) {
602
+ const dailyUsage = new Map();
603
+ const displayValuesByDay = new Map();
604
+ const stats = createEmptyScanStats();
605
+ const processedHashes = new Set();
606
+ for (const projectDir of getClaudeProjectDirs(configPaths)) {
607
+ for (const file of await listFilesRecursive(projectDir, ".jsonl")) {
608
+ stats.filesScanned += 1;
609
+ stats.jsonlFilesScanned += 1;
610
+ await processClaudeProjectFile(file, dailyUsage, stats, processedHashes, timeZone);
611
+ }
612
+ }
613
+ for (const file of getClaudeStatsCacheFiles(configPaths)) {
614
+ stats.filesScanned += 1;
615
+ stats.jsonFilesScanned += 1;
616
+ await processClaudeStatsCacheFile(file, dailyUsage, stats);
617
+ }
618
+ const coveredDates = new Set([...dailyUsage.keys()]);
619
+ for (const file of getClaudeHistoryFiles(configPaths)) {
620
+ stats.filesScanned += 1;
621
+ stats.jsonlFilesScanned += 1;
622
+ await processClaudeHistoryFile(file, displayValuesByDay, coveredDates, stats, timeZone);
623
+ }
624
+ return {
625
+ providerId: CLAUDE_PROVIDER_ID,
626
+ providerLabel: CLAUDE_PROVIDER_LABEL,
627
+ providerShortLabel: CLAUDE_PROVIDER_SHORT_LABEL,
628
+ sourceHome: configPaths[0] ?? null,
629
+ dailyUsage,
630
+ displayValuesByDay,
631
+ stats,
632
+ };
633
+ }
634
+ async function processClaudeProjectFile(filePath, dailyUsage, stats, processedHashes, timeZone) {
635
+ const content = await readFile(filePath, "utf8");
636
+ for (const rawLine of content.split(/\r?\n/)) {
637
+ const line = rawLine.trim();
638
+ if (line === "")
639
+ continue;
640
+ let record;
641
+ try {
642
+ record = JSON.parse(line);
643
+ }
644
+ catch {
645
+ stats.parseErrors += 1;
646
+ continue;
647
+ }
648
+ const timestamp = parseFlexibleTimestamp(record.timestamp);
649
+ if (!timestamp || !record.message?.usage)
650
+ continue;
651
+ const uniqueHash = asNonEmptyString(record.message.id) && asNonEmptyString(record.requestId)
652
+ ? `${record.message.id}:${record.requestId}`
653
+ : null;
654
+ if (uniqueHash && processedHashes.has(uniqueHash)) {
655
+ stats.duplicateEventsSkipped += 1;
656
+ continue;
657
+ }
658
+ if (uniqueHash)
659
+ processedHashes.add(uniqueHash);
660
+ const usage = normalizeClaudeUsage(record.message.usage);
661
+ if (usage.total_tokens <= 0) {
662
+ stats.zeroTotalEventsSkipped += 1;
663
+ continue;
664
+ }
665
+ const model = asNonEmptyString(record.message.model);
666
+ addUsage(dailyUsage, dayKeyInTimezone(timestamp, timeZone), usage, model === "<synthetic>" ? null : model);
667
+ stats.tokenEventsCounted += 1;
668
+ }
669
+ }
670
+ function distributeTokenComponents(total, weights) {
671
+ const weightSum = weights.reduce((sum, value) => sum + value, 0);
672
+ if (total <= 0 || weightSum <= 0)
673
+ return weights.map(() => 0);
674
+ const exact = weights.map((weight) => (weight / weightSum) * total);
675
+ const allocated = exact.map((value) => Math.floor(value));
676
+ let remainder = total - allocated.reduce((sum, value) => sum + value, 0);
677
+ const order = exact
678
+ .map((value, index) => ({ index, fraction: value - allocated[index], weight: weights[index] }))
679
+ .sort((a, b) => (b.fraction - a.fraction) || (b.weight - a.weight));
680
+ for (const item of order) {
681
+ if (remainder <= 0)
682
+ break;
683
+ allocated[item.index] += 1;
684
+ remainder -= 1;
685
+ }
686
+ return allocated;
687
+ }
688
+ function createClaudeStatsCacheUsage(totalTokens, usage) {
689
+ if (totalTokens <= 0)
690
+ return createEmptyUsageTotals();
691
+ const [scaledInput, scaledOutput, scaledCacheRead, scaledCacheCreation] = distributeTokenComponents(totalTokens, [
692
+ Math.trunc(toNumber(usage?.inputTokens)),
693
+ Math.trunc(toNumber(usage?.outputTokens)),
694
+ Math.trunc(toNumber(usage?.cacheReadInputTokens)),
695
+ Math.trunc(toNumber(usage?.cacheCreationInputTokens)),
696
+ ]);
697
+ if (scaledInput === 0 && scaledOutput === 0 && scaledCacheRead === 0 && scaledCacheCreation === 0) {
698
+ return {
699
+ input_tokens: totalTokens,
700
+ cached_input_tokens: 0,
701
+ output_tokens: 0,
702
+ reasoning_output_tokens: 0,
703
+ total_tokens: totalTokens,
704
+ };
705
+ }
706
+ return {
707
+ input_tokens: scaledInput + scaledCacheRead,
708
+ cached_input_tokens: scaledCacheRead,
709
+ output_tokens: scaledOutput + scaledCacheCreation,
710
+ reasoning_output_tokens: 0,
711
+ total_tokens: totalTokens,
712
+ };
713
+ }
714
+ async function processClaudeStatsCacheFile(filePath, dailyUsage, stats) {
715
+ let parsed;
716
+ try {
717
+ parsed = JSON.parse(await readFile(filePath, "utf8"));
718
+ }
719
+ catch {
720
+ stats.parseErrors += 1;
721
+ return;
722
+ }
723
+ const rows = Array.isArray(parsed.dailyModelTokens) ? parsed.dailyModelTokens : [];
724
+ const modelUsageMap = typeof parsed.modelUsage === "object" && parsed.modelUsage != null
725
+ ? parsed.modelUsage
726
+ : {};
727
+ for (const row of rows) {
728
+ if (typeof row !== "object" || row == null)
729
+ continue;
730
+ const date = asNonEmptyString(row.date);
731
+ if (!date || dailyUsage.has(date))
732
+ continue;
733
+ const tokensByModel = typeof row.tokensByModel === "object" &&
734
+ row.tokensByModel != null
735
+ ? row.tokensByModel
736
+ : {};
737
+ for (const [rawModelName, totalTokensRaw] of Object.entries(tokensByModel)) {
738
+ const totalTokens = Math.trunc(toNumber(totalTokensRaw));
739
+ if (totalTokens <= 0)
740
+ continue;
741
+ const usage = createClaudeStatsCacheUsage(totalTokens, typeof modelUsageMap[rawModelName] === "object" && modelUsageMap[rawModelName] != null
742
+ ? modelUsageMap[rawModelName]
743
+ : undefined);
744
+ addUsage(dailyUsage, date, usage, rawModelName);
745
+ }
746
+ }
747
+ }
748
+ async function processClaudeHistoryFile(filePath, displayValuesByDay, coveredDates, stats, timeZone) {
749
+ const content = await readFile(filePath, "utf8");
750
+ for (const rawLine of content.split(/\r?\n/)) {
751
+ const line = rawLine.trim();
752
+ if (line === "")
753
+ continue;
754
+ try {
755
+ const record = JSON.parse(line);
756
+ const timestamp = parseFlexibleTimestamp(record.timestamp);
757
+ if (!timestamp)
758
+ continue;
759
+ const dayKey = dayKeyInTimezone(timestamp, timeZone);
760
+ if (coveredDates.has(dayKey))
761
+ continue;
762
+ displayValuesByDay.set(dayKey, (displayValuesByDay.get(dayKey) ?? 0) + 1);
763
+ }
764
+ catch {
765
+ stats.parseErrors += 1;
766
+ }
767
+ }
768
+ }
769
+ function resolveOpenCodeBaseDir(configuredPath) {
770
+ const candidates = [];
771
+ if (configuredPath)
772
+ candidates.push(resolve(configuredPath));
773
+ if (process.env.OPENCODE_DATA_DIR?.trim())
774
+ candidates.push(resolve(process.env.OPENCODE_DATA_DIR.trim()));
775
+ candidates.push(resolve(join(homedir(), ".local", "share", "opencode")));
776
+ for (const candidate of candidates) {
777
+ if (!existsSync(candidate))
778
+ continue;
779
+ if (existsSync(join(candidate, "opencode.db")) || existsSync(join(candidate, "storage", "message"))) {
780
+ return candidate;
781
+ }
782
+ }
783
+ return null;
784
+ }
785
+ async function scanOpenCodeProvider(baseDir, timeZone) {
786
+ const dailyUsage = new Map();
787
+ const displayValuesByDay = new Map();
788
+ const stats = createEmptyScanStats();
789
+ const dedupeIds = new Set();
790
+ const databasePath = join(baseDir, "opencode.db");
791
+ if (existsSync(databasePath)) {
792
+ stats.filesScanned += 1;
793
+ await processOpenCodeDatabase(databasePath, dailyUsage, stats, dedupeIds, timeZone);
794
+ }
795
+ else {
796
+ for (const file of await listFilesRecursive(join(baseDir, "storage", "message"), ".json")) {
797
+ stats.filesScanned += 1;
798
+ stats.jsonFilesScanned += 1;
799
+ await processOpenCodeLegacyFile(file, dailyUsage, stats, dedupeIds, timeZone);
800
+ }
801
+ }
802
+ return {
803
+ providerId: OPENCODE_PROVIDER_ID,
804
+ providerLabel: OPENCODE_PROVIDER_LABEL,
805
+ providerShortLabel: OPENCODE_PROVIDER_SHORT_LABEL,
806
+ sourceHome: baseDir,
807
+ dailyUsage,
808
+ displayValuesByDay,
809
+ stats,
810
+ };
811
+ }
812
+ function addOpenCodeMessage(message, dailyUsage, stats, dedupeIds, timeZone, fallbackId) {
813
+ const messageId = asNonEmptyString(message.id) ?? fallbackId ?? null;
814
+ if (messageId && dedupeIds.has(messageId)) {
815
+ stats.duplicateEventsSkipped += 1;
816
+ return;
817
+ }
818
+ if (messageId)
819
+ dedupeIds.add(messageId);
820
+ const usage = normalizeOpenCodeUsage(message.tokens);
821
+ if (usage.total_tokens <= 0) {
822
+ stats.zeroTotalEventsSkipped += 1;
823
+ return;
824
+ }
825
+ const timestamp = parseFlexibleTimestamp(message.time?.created);
826
+ if (!timestamp) {
827
+ stats.parseErrors += 1;
828
+ return;
829
+ }
830
+ addUsage(dailyUsage, dayKeyInTimezone(timestamp, timeZone), usage, asNonEmptyString(message.modelID));
831
+ stats.tokenEventsCounted += 1;
832
+ }
833
+ async function processOpenCodeLegacyFile(filePath, dailyUsage, stats, dedupeIds, timeZone) {
834
+ try {
835
+ const message = JSON.parse(await readFile(filePath, "utf8"));
836
+ addOpenCodeMessage(message, dailyUsage, stats, dedupeIds, timeZone);
837
+ }
838
+ catch {
839
+ stats.parseErrors += 1;
840
+ }
841
+ }
842
+ async function processOpenCodeDatabase(databasePath, dailyUsage, stats, dedupeIds, timeZone) {
843
+ const { DatabaseSync } = await import("node:sqlite");
844
+ const db = new DatabaseSync(databasePath, { readOnly: true });
845
+ try {
846
+ const rows = db.prepare("SELECT id, data FROM message ORDER BY time_created ASC").iterate();
847
+ for (const row of rows) {
848
+ try {
849
+ const message = JSON.parse(row.data);
850
+ addOpenCodeMessage(message, dailyUsage, stats, dedupeIds, timeZone, row.id);
851
+ }
852
+ catch {
853
+ stats.parseErrors += 1;
854
+ }
855
+ }
856
+ }
857
+ finally {
858
+ db.close();
859
+ }
860
+ }
861
+ function resolvePiSessionsDir(configuredPath) {
862
+ const candidates = [];
863
+ if (configuredPath)
864
+ candidates.push(resolve(configuredPath));
865
+ if (process.env.PI_CODING_AGENT_DIR?.trim())
866
+ candidates.push(resolve(process.env.PI_CODING_AGENT_DIR.trim()));
867
+ candidates.push(resolve(join(homedir(), ".pi", "agent")));
868
+ for (const candidate of candidates) {
869
+ if (candidate.endsWith("/sessions") && existsSync(candidate))
870
+ return candidate;
871
+ const sessionsDir = join(candidate, "sessions");
872
+ if (existsSync(sessionsDir))
873
+ return sessionsDir;
874
+ }
875
+ return null;
876
+ }
877
+ async function scanPiProvider(sessionsDir, timeZone) {
878
+ const dailyUsage = new Map();
879
+ const displayValuesByDay = new Map();
880
+ const stats = createEmptyScanStats();
881
+ for (const file of await listFilesRecursive(sessionsDir, ".jsonl")) {
882
+ stats.filesScanned += 1;
883
+ stats.jsonlFilesScanned += 1;
884
+ await processPiJsonlFile(file, dailyUsage, stats, timeZone);
885
+ }
886
+ return {
887
+ providerId: PI_PROVIDER_ID,
888
+ providerLabel: PI_PROVIDER_LABEL,
889
+ providerShortLabel: PI_PROVIDER_SHORT_LABEL,
890
+ sourceHome: resolve(dirname(sessionsDir)),
891
+ dailyUsage,
892
+ displayValuesByDay,
893
+ stats,
894
+ };
895
+ }
896
+ async function processPiJsonlFile(filePath, dailyUsage, stats, timeZone) {
897
+ const content = await readFile(filePath, "utf8");
898
+ for (const rawLine of content.split(/\r?\n/)) {
899
+ const line = rawLine.trim();
900
+ if (line === "")
901
+ continue;
902
+ if (!line.includes('"role":"assistant"') && !line.includes('"role": "assistant"'))
903
+ continue;
904
+ if (!line.includes('"usage"'))
905
+ continue;
906
+ let record;
907
+ try {
908
+ record = JSON.parse(line);
909
+ }
910
+ catch {
911
+ stats.parseErrors += 1;
912
+ continue;
913
+ }
914
+ if (record.type != null && record.type !== "message")
915
+ continue;
916
+ if (record.message?.role !== "assistant" || !record.message.usage)
917
+ continue;
918
+ const usage = normalizePiUsage(record.message.usage);
919
+ if (usage.total_tokens <= 0) {
920
+ stats.zeroTotalEventsSkipped += 1;
921
+ continue;
922
+ }
923
+ const timestamp = parseFlexibleTimestamp(record.timestamp) ?? parseFlexibleTimestamp(record.message.timestamp);
924
+ if (!timestamp) {
925
+ stats.parseErrors += 1;
926
+ continue;
927
+ }
928
+ addUsage(dailyUsage, dayKeyInTimezone(timestamp, timeZone), usage, asNonEmptyString(record.message.model));
929
+ stats.tokenEventsCounted += 1;
930
+ }
931
+ }
932
+ export function providerScanHasUsage(scan) {
933
+ for (const usage of scan.dailyUsage.values()) {
934
+ if (usage.total_tokens > 0)
935
+ return true;
936
+ }
937
+ for (const value of scan.displayValuesByDay.values()) {
938
+ if (value > 0)
939
+ return true;
940
+ }
941
+ return false;
942
+ }
943
+ export function buildMonthlyRollups(reportDays) {
944
+ const months = new Map();
945
+ for (const day of reportDays) {
946
+ if ((day.totalTokens || 0) <= 0 && (day.costUSD || 0) <= 0)
947
+ continue;
948
+ const monthKey = day.date.slice(0, 7);
949
+ let month = months.get(monthKey);
950
+ if (!month) {
951
+ month = {
952
+ row: {
953
+ month: monthKey,
954
+ inputTokens: 0,
955
+ cachedInputTokens: 0,
956
+ outputTokens: 0,
957
+ reasoningTokens: 0,
958
+ totalTokens: 0,
959
+ events: 0,
960
+ activeDays: 0,
961
+ costBreakdownUSD: { inputUSD: 0, cachedInputUSD: 0, outputUSD: 0, totalUSD: 0 },
962
+ costUSD: 0,
963
+ modelTotals: {},
964
+ modelBreakdown: [],
965
+ },
966
+ modelBreakdownIndex: new Map(),
967
+ };
968
+ months.set(monthKey, month);
969
+ }
970
+ month.row.inputTokens += day.inputTokens;
971
+ month.row.cachedInputTokens += day.cachedInputTokens;
972
+ month.row.outputTokens += day.outputTokens;
973
+ month.row.reasoningTokens += day.reasoningTokens;
974
+ month.row.totalTokens += day.totalTokens;
975
+ month.row.events += day.events;
976
+ if (day.totalTokens > 0)
977
+ month.row.activeDays += 1;
978
+ month.row.costBreakdownUSD.inputUSD += day.costBreakdownUSD.inputUSD;
979
+ month.row.costBreakdownUSD.cachedInputUSD += day.costBreakdownUSD.cachedInputUSD;
980
+ month.row.costBreakdownUSD.outputUSD += day.costBreakdownUSD.outputUSD;
981
+ month.row.costBreakdownUSD.totalUSD += day.costBreakdownUSD.totalUSD;
982
+ month.row.costUSD += day.costUSD;
983
+ for (const modelEntry of day.modelBreakdown) {
984
+ const name = String(modelEntry.name);
985
+ const existing = month.modelBreakdownIndex.get(name) ?? {
986
+ name,
987
+ inputTokens: 0,
988
+ cachedInputTokens: 0,
989
+ outputTokens: 0,
990
+ reasoningTokens: 0,
991
+ totalTokens: 0,
992
+ events: 0,
993
+ isFallbackModel: false,
994
+ isMissingPricing: false,
995
+ isAliasPricing: false,
996
+ resolvedPricingModel: asNonEmptyString(modelEntry.resolvedPricingModel) ?? null,
997
+ pricingSource: asNonEmptyString(modelEntry.pricingSource) ?? null,
998
+ pricingPerMToken: modelEntry.pricingPerMToken ?? null,
999
+ costBreakdownUSD: { inputUSD: 0, cachedInputUSD: 0, outputUSD: 0, totalUSD: 0 },
1000
+ costUSD: 0,
1001
+ };
1002
+ existing.inputTokens += Number(modelEntry.inputTokens ?? 0);
1003
+ existing.cachedInputTokens += Number(modelEntry.cachedInputTokens ?? 0);
1004
+ existing.outputTokens += Number(modelEntry.outputTokens ?? 0);
1005
+ existing.reasoningTokens += Number(modelEntry.reasoningTokens ?? 0);
1006
+ existing.totalTokens += Number(modelEntry.totalTokens ?? 0);
1007
+ existing.events += Number(modelEntry.events ?? 0);
1008
+ existing.isFallbackModel = existing.isFallbackModel || Boolean(modelEntry.isFallbackModel);
1009
+ existing.isMissingPricing = existing.isMissingPricing || Boolean(modelEntry.isMissingPricing);
1010
+ existing.isAliasPricing = existing.isAliasPricing || Boolean(modelEntry.isAliasPricing);
1011
+ existing.costBreakdownUSD.inputUSD += Number(modelEntry.costBreakdownUSD?.inputUSD ?? 0);
1012
+ existing.costBreakdownUSD.cachedInputUSD += Number(modelEntry.costBreakdownUSD?.cachedInputUSD ?? 0);
1013
+ existing.costBreakdownUSD.outputUSD += Number(modelEntry.costBreakdownUSD?.outputUSD ?? 0);
1014
+ existing.costBreakdownUSD.totalUSD += Number(modelEntry.costBreakdownUSD?.totalUSD ?? 0);
1015
+ existing.costUSD += Number(modelEntry.costUSD ?? 0);
1016
+ month.modelBreakdownIndex.set(name, existing);
1017
+ }
1018
+ }
1019
+ const rows = [...months.entries()]
1020
+ .sort(([left], [right]) => left.localeCompare(right))
1021
+ .map(([, month]) => {
1022
+ const breakdown = [...month.modelBreakdownIndex.values()].sort((a, b) => Number(b.totalTokens) - Number(a.totalTokens));
1023
+ month.row.modelBreakdown = breakdown;
1024
+ month.row.modelTotals = Object.fromEntries(breakdown.map((entry) => [String(entry.name), Number(entry.totalTokens)]));
1025
+ return month.row;
1026
+ });
1027
+ return rows;
1028
+ }
1029
+ function serializeScanStats(stats) {
1030
+ return { ...stats };
1031
+ }
1032
+ function buildCombinedScanStats(providerReports) {
1033
+ const combined = createEmptyScanStats();
1034
+ for (const report of providerReports) {
1035
+ for (const key of SCAN_OUTPUT_KEYS)
1036
+ combined[key] += Number(report.scan[key] ?? 0);
1037
+ }
1038
+ return combined;
1039
+ }
1040
+ function buildProviderReport(scan, pricingDataset, pricingSourceKind, pricingSourceLabel) {
1041
+ const allDays = [...new Set([...scan.dailyUsage.keys(), ...scan.displayValuesByDay.keys()])].sort();
1042
+ const days = [];
1043
+ const missingPricingModels = new Set();
1044
+ const costTotalsUSD = { inputUSD: 0, cachedInputUSD: 0, outputUSD: 0, totalUSD: 0 };
1045
+ for (const dayKey of allDays) {
1046
+ const usage = scan.dailyUsage.get(dayKey) ?? createEmptyDailyUsage();
1047
+ const modelBreakdown = Object.entries(usage.model_usage)
1048
+ .sort(([, left], [, right]) => right.total_tokens - left.total_tokens)
1049
+ .map(([name, modelUsage]) => {
1050
+ const resolved = resolveModelPricing(name, pricingDataset);
1051
+ const costBreakdownUSD = calculateCostBreakdown(modelUsage, resolved.pricing);
1052
+ if (resolved.isMissing)
1053
+ missingPricingModels.add(name);
1054
+ return {
1055
+ name,
1056
+ inputTokens: modelUsage.input_tokens,
1057
+ cachedInputTokens: modelUsage.cached_input_tokens,
1058
+ outputTokens: modelUsage.output_tokens,
1059
+ reasoningTokens: modelUsage.reasoning_output_tokens,
1060
+ totalTokens: modelUsage.total_tokens,
1061
+ events: modelUsage.events,
1062
+ isFallbackModel: modelUsage.is_fallback_model,
1063
+ isMissingPricing: resolved.isMissing,
1064
+ isAliasPricing: resolved.isAlias,
1065
+ resolvedPricingModel: resolved.resolvedModel,
1066
+ pricingSource: resolved.source,
1067
+ pricingPerMToken: resolved.pricing,
1068
+ costBreakdownUSD,
1069
+ costUSD: costBreakdownUSD.totalUSD,
1070
+ };
1071
+ });
1072
+ const costBreakdownUSD = modelBreakdown.reduce((acc, entry) => ({
1073
+ inputUSD: acc.inputUSD + Number(entry.costBreakdownUSD.inputUSD),
1074
+ cachedInputUSD: acc.cachedInputUSD + Number(entry.costBreakdownUSD.cachedInputUSD),
1075
+ outputUSD: acc.outputUSD + Number(entry.costBreakdownUSD.outputUSD),
1076
+ totalUSD: acc.totalUSD + Number(entry.costBreakdownUSD.totalUSD),
1077
+ }), { inputUSD: 0, cachedInputUSD: 0, outputUSD: 0, totalUSD: 0 });
1078
+ costTotalsUSD.inputUSD += costBreakdownUSD.inputUSD;
1079
+ costTotalsUSD.cachedInputUSD += costBreakdownUSD.cachedInputUSD;
1080
+ costTotalsUSD.outputUSD += costBreakdownUSD.outputUSD;
1081
+ costTotalsUSD.totalUSD += costBreakdownUSD.totalUSD;
1082
+ days.push({
1083
+ date: dayKey,
1084
+ inputTokens: usage.input_tokens,
1085
+ cachedInputTokens: usage.cached_input_tokens,
1086
+ outputTokens: usage.output_tokens,
1087
+ reasoningTokens: usage.reasoning_output_tokens,
1088
+ totalTokens: usage.total_tokens,
1089
+ events: usage.events,
1090
+ costBreakdownUSD,
1091
+ costUSD: costBreakdownUSD.totalUSD,
1092
+ modelTotals: Object.fromEntries(modelBreakdown.map((entry) => [entry.name, Number(entry.totalTokens)])),
1093
+ modelBreakdown,
1094
+ displayValue: scan.displayValuesByDay.get(dayKey) ?? 0,
1095
+ });
1096
+ }
1097
+ const scanStats = serializeScanStats(scan.stats);
1098
+ scanStats.activityOnlyDays = [...scan.displayValuesByDay.keys()].filter((dayKey) => !scan.dailyUsage.has(dayKey)).length;
1099
+ return {
1100
+ providerId: scan.providerId,
1101
+ providerLabel: scan.providerLabel,
1102
+ providerShortLabel: scan.providerShortLabel,
1103
+ sourceHome: scan.sourceHome,
1104
+ days,
1105
+ monthly: buildMonthlyRollups(days),
1106
+ pricing: {
1107
+ source: pricingSourceKind,
1108
+ sourceLabel: pricingSourceLabel,
1109
+ url: LITELLM_PRICING_URL,
1110
+ missingModels: [...missingPricingModels].sort(),
1111
+ },
1112
+ costTotalsUSD,
1113
+ scan: scanStats,
1114
+ };
1115
+ }
1116
+ export function buildCombinedReport(providerReports) {
1117
+ const combinedByDay = new Map();
1118
+ const missingModels = new Set();
1119
+ const costTotalsUSD = { inputUSD: 0, cachedInputUSD: 0, outputUSD: 0, totalUSD: 0 };
1120
+ const pricingSources = new Set();
1121
+ const pricingLabels = new Set();
1122
+ const pricingUrls = new Set();
1123
+ for (const report of providerReports) {
1124
+ pricingSources.add(report.pricing.source);
1125
+ pricingLabels.add(report.pricing.sourceLabel);
1126
+ pricingUrls.add(report.pricing.url);
1127
+ for (const model of report.pricing.missingModels)
1128
+ missingModels.add(model);
1129
+ costTotalsUSD.inputUSD += report.costTotalsUSD.inputUSD;
1130
+ costTotalsUSD.cachedInputUSD += report.costTotalsUSD.cachedInputUSD;
1131
+ costTotalsUSD.outputUSD += report.costTotalsUSD.outputUSD;
1132
+ costTotalsUSD.totalUSD += report.costTotalsUSD.totalUSD;
1133
+ for (const day of report.days) {
1134
+ const existing = combinedByDay.get(day.date) ??
1135
+ {
1136
+ date: day.date,
1137
+ inputTokens: 0,
1138
+ cachedInputTokens: 0,
1139
+ outputTokens: 0,
1140
+ reasoningTokens: 0,
1141
+ totalTokens: 0,
1142
+ events: 0,
1143
+ costBreakdownUSD: { inputUSD: 0, cachedInputUSD: 0, outputUSD: 0, totalUSD: 0 },
1144
+ costUSD: 0,
1145
+ modelTotals: {},
1146
+ modelBreakdown: [],
1147
+ displayValue: 0,
1148
+ };
1149
+ existing.inputTokens += day.inputTokens;
1150
+ existing.cachedInputTokens += day.cachedInputTokens;
1151
+ existing.outputTokens += day.outputTokens;
1152
+ existing.reasoningTokens += day.reasoningTokens;
1153
+ existing.totalTokens += day.totalTokens;
1154
+ existing.events += day.events;
1155
+ existing.displayValue += day.displayValue;
1156
+ existing.costBreakdownUSD.inputUSD += day.costBreakdownUSD.inputUSD;
1157
+ existing.costBreakdownUSD.cachedInputUSD += day.costBreakdownUSD.cachedInputUSD;
1158
+ existing.costBreakdownUSD.outputUSD += day.costBreakdownUSD.outputUSD;
1159
+ existing.costBreakdownUSD.totalUSD += day.costBreakdownUSD.totalUSD;
1160
+ existing.costUSD += day.costUSD;
1161
+ const modelIndex = new Map(existing.modelBreakdown.map((entry) => [String(entry.name), entry]));
1162
+ for (const modelEntry of day.modelBreakdown) {
1163
+ const name = String(modelEntry.name);
1164
+ const merged = modelIndex.get(name) ?? {
1165
+ name,
1166
+ inputTokens: 0,
1167
+ cachedInputTokens: 0,
1168
+ outputTokens: 0,
1169
+ reasoningTokens: 0,
1170
+ totalTokens: 0,
1171
+ events: 0,
1172
+ isFallbackModel: false,
1173
+ isMissingPricing: false,
1174
+ isAliasPricing: false,
1175
+ resolvedPricingModel: modelEntry.resolvedPricingModel ?? null,
1176
+ pricingSource: modelEntry.pricingSource ?? null,
1177
+ pricingPerMToken: modelEntry.pricingPerMToken ?? null,
1178
+ costBreakdownUSD: { inputUSD: 0, cachedInputUSD: 0, outputUSD: 0, totalUSD: 0 },
1179
+ costUSD: 0,
1180
+ };
1181
+ merged.inputTokens = Number(merged.inputTokens) + Number(modelEntry.inputTokens ?? 0);
1182
+ merged.cachedInputTokens = Number(merged.cachedInputTokens) + Number(modelEntry.cachedInputTokens ?? 0);
1183
+ merged.outputTokens = Number(merged.outputTokens) + Number(modelEntry.outputTokens ?? 0);
1184
+ merged.reasoningTokens = Number(merged.reasoningTokens) + Number(modelEntry.reasoningTokens ?? 0);
1185
+ merged.totalTokens = Number(merged.totalTokens) + Number(modelEntry.totalTokens ?? 0);
1186
+ merged.events = Number(merged.events) + Number(modelEntry.events ?? 0);
1187
+ merged.isFallbackModel = Boolean(merged.isFallbackModel) || Boolean(modelEntry.isFallbackModel);
1188
+ merged.isMissingPricing = Boolean(merged.isMissingPricing) || Boolean(modelEntry.isMissingPricing);
1189
+ merged.isAliasPricing = Boolean(merged.isAliasPricing) || Boolean(modelEntry.isAliasPricing);
1190
+ merged.costBreakdownUSD.inputUSD += Number(modelEntry.costBreakdownUSD.inputUSD);
1191
+ merged.costBreakdownUSD.cachedInputUSD += Number(modelEntry.costBreakdownUSD.cachedInputUSD);
1192
+ merged.costBreakdownUSD.outputUSD += Number(modelEntry.costBreakdownUSD.outputUSD);
1193
+ merged.costBreakdownUSD.totalUSD += Number(modelEntry.costBreakdownUSD.totalUSD);
1194
+ merged.costUSD = Number(merged.costUSD) + Number(modelEntry.costUSD ?? 0);
1195
+ if (merged.resolvedPricingModel == null)
1196
+ merged.resolvedPricingModel = modelEntry.resolvedPricingModel ?? null;
1197
+ if (merged.pricingSource == null)
1198
+ merged.pricingSource = modelEntry.pricingSource ?? null;
1199
+ if (merged.pricingPerMToken == null)
1200
+ merged.pricingPerMToken = modelEntry.pricingPerMToken ?? null;
1201
+ modelIndex.set(name, merged);
1202
+ }
1203
+ existing.modelBreakdown = [...modelIndex.values()].sort((a, b) => Number(b.totalTokens) - Number(a.totalTokens));
1204
+ existing.modelTotals = Object.fromEntries(existing.modelBreakdown.map((entry) => [String(entry.name), Number(entry.totalTokens)]));
1205
+ combinedByDay.set(day.date, existing);
1206
+ }
1207
+ }
1208
+ const days = [...combinedByDay.values()].sort((a, b) => a.date.localeCompare(b.date));
1209
+ return {
1210
+ providerId: COMBINED_PROVIDER_ID,
1211
+ providerLabel: COMBINED_PROVIDER_LABEL,
1212
+ providerShortLabel: COMBINED_PROVIDER_SHORT_LABEL,
1213
+ sourceHome: null,
1214
+ days,
1215
+ monthly: buildMonthlyRollups(days),
1216
+ pricing: {
1217
+ source: pricingSources.size === 1 ? [...pricingSources][0] : "mixed",
1218
+ sourceLabel: pricingLabels.size === 1 ? [...pricingLabels][0] : "Mixed provider pricing",
1219
+ url: pricingUrls.size === 1 ? [...pricingUrls][0] : LITELLM_PRICING_URL,
1220
+ missingModels: [...missingModels].sort(),
1221
+ },
1222
+ costTotalsUSD,
1223
+ scan: buildCombinedScanStats(providerReports),
1224
+ };
1225
+ }
1226
+ export async function buildReportPayload(providerScans, timeZone) {
1227
+ let pricingDataset = null;
1228
+ let pricingSourceKind = "builtin-fallback";
1229
+ let pricingSourceLabel = "Built-in fallback pricing";
1230
+ try {
1231
+ pricingDataset = await fetchLiteLLMPricingDataset();
1232
+ pricingSourceKind = "litellm-live";
1233
+ pricingSourceLabel = "LiteLLM live pricing";
1234
+ }
1235
+ catch {
1236
+ pricingDataset = null;
1237
+ }
1238
+ const providerReports = providerScans.map((scan) => buildProviderReport(scan, pricingDataset, pricingSourceKind, pricingSourceLabel));
1239
+ const combined = buildCombinedReport(providerReports);
1240
+ const providers = Object.fromEntries(providerReports.map((report) => [report.providerId, report]));
1241
+ const providerOrder = providerReports.map((report) => report.providerId);
1242
+ const defaultProvider = providerOrder[0] ?? DEFAULT_PROVIDER_ID;
1243
+ const defaultProviderReport = providers[defaultProvider] ?? combined;
1244
+ const now = new Date();
1245
+ return {
1246
+ schemaVersion: REPORT_SCHEMA_VERSION,
1247
+ kind: "agent-usage-report",
1248
+ timezone: timeZone,
1249
+ generatedAt: now.toISOString(),
1250
+ generatedAtDisplay: new Intl.DateTimeFormat("en-CA", {
1251
+ year: "numeric",
1252
+ month: "2-digit",
1253
+ day: "2-digit",
1254
+ hour: "2-digit",
1255
+ minute: "2-digit",
1256
+ timeZone,
1257
+ timeZoneName: "short",
1258
+ }).format(now).replace(",", ""),
1259
+ generatedLocalDate: dayKeyInTimezone(now, timeZone),
1260
+ platform: `${osPlatform()} ${osRelease()} · Node ${process.version.replace(/^v/, "")}`,
1261
+ defaultProvider,
1262
+ providerOrder,
1263
+ providerOptions: providerReports.map((report) => ({
1264
+ id: report.providerId,
1265
+ label: report.providerLabel,
1266
+ shortLabel: report.providerShortLabel,
1267
+ })),
1268
+ providers,
1269
+ combined,
1270
+ capabilities: {
1271
+ multiProvider: providerReports.length > 1,
1272
+ providerControls: providerReports.length > 1,
1273
+ },
1274
+ sourceHome: defaultProviderReport.sourceHome,
1275
+ codexHome: providers[DEFAULT_PROVIDER_ID]?.sourceHome ?? null,
1276
+ days: defaultProviderReport.days,
1277
+ monthly: defaultProviderReport.monthly,
1278
+ pricing: defaultProviderReport.pricing,
1279
+ costTotalsUSD: defaultProviderReport.costTotalsUSD,
1280
+ scan: defaultProviderReport.scan,
1281
+ };
1282
+ }
1283
+ async function loadTemplate() {
1284
+ return readFile(join(__dirname, "template.html"), "utf8");
1285
+ }
1286
+ export async function writeOutput(report, outputHtml, outputJson) {
1287
+ await writeFile(outputJson, JSON.stringify(report, null, 2), "utf8");
1288
+ const template = await loadTemplate();
1289
+ await writeFile(outputHtml, template.replace("__DATA__", JSON.stringify(report)), "utf8");
1290
+ }
1291
+ export function parseCliArgs(argv) {
1292
+ const args = {
1293
+ codexHome: join(homedir(), ".codex"),
1294
+ claudeConfigDir: null,
1295
+ opencodeDir: null,
1296
+ piAgentDir: null,
1297
+ timezone: DEFAULT_TIMEZONE,
1298
+ outputHtml: "agent-usage-report.html",
1299
+ outputJson: "agent-usage-data.json",
1300
+ skipArchived: false,
1301
+ };
1302
+ for (let index = 0; index < argv.length; index += 1) {
1303
+ const arg = argv[index];
1304
+ const next = argv[index + 1];
1305
+ switch (arg) {
1306
+ case "--codex-home":
1307
+ if (!next)
1308
+ throw new Error("Missing value for --codex-home");
1309
+ args.codexHome = next;
1310
+ index += 1;
1311
+ break;
1312
+ case "--claude-config-dir":
1313
+ if (!next)
1314
+ throw new Error("Missing value for --claude-config-dir");
1315
+ args.claudeConfigDir = next;
1316
+ index += 1;
1317
+ break;
1318
+ case "--opencode-dir":
1319
+ if (!next)
1320
+ throw new Error("Missing value for --opencode-dir");
1321
+ args.opencodeDir = next;
1322
+ index += 1;
1323
+ break;
1324
+ case "--pi-agent-dir":
1325
+ if (!next)
1326
+ throw new Error("Missing value for --pi-agent-dir");
1327
+ args.piAgentDir = next;
1328
+ index += 1;
1329
+ break;
1330
+ case "--timezone":
1331
+ if (!next)
1332
+ throw new Error("Missing value for --timezone");
1333
+ args.timezone = next;
1334
+ index += 1;
1335
+ break;
1336
+ case "--output-html":
1337
+ if (!next)
1338
+ throw new Error("Missing value for --output-html");
1339
+ args.outputHtml = next;
1340
+ index += 1;
1341
+ break;
1342
+ case "--output-json":
1343
+ if (!next)
1344
+ throw new Error("Missing value for --output-json");
1345
+ args.outputJson = next;
1346
+ index += 1;
1347
+ break;
1348
+ case "--skip-archived":
1349
+ args.skipArchived = true;
1350
+ break;
1351
+ case "-h":
1352
+ case "--help":
1353
+ printHelp();
1354
+ process.exit(0);
1355
+ default:
1356
+ throw new Error(`Unknown argument: ${arg}`);
1357
+ }
1358
+ }
1359
+ return args;
1360
+ }
1361
+ function printHelp() {
1362
+ console.log(`usage: agent-usage-report [options]
1363
+
1364
+ Options:
1365
+ --codex-home <path> Path to the Codex home directory
1366
+ --claude-config-dir <paths> Claude config dir or comma-separated dirs
1367
+ --opencode-dir <path> OpenCode data directory
1368
+ --pi-agent-dir <path> Pi Coding Agent dir or sessions dir
1369
+ --timezone <iana-tz> Day bucketing timezone
1370
+ --output-html <path> HTML output path
1371
+ --output-json <path> JSON output path
1372
+ --skip-archived Skip ~/.codex/archived_sessions
1373
+ -h, --help Show this help text`);
1374
+ }
1375
+ export async function generateReport(args) {
1376
+ const codexHome = resolve(args.codexHome);
1377
+ if (!existsSync(codexHome))
1378
+ throw new Error(`Configured usage source does not exist: ${codexHome}`);
1379
+ const claudeConfigPaths = resolveClaudeConfigPaths(args.claudeConfigDir);
1380
+ const openCodeBaseDir = resolveOpenCodeBaseDir(args.opencodeDir);
1381
+ const piSessionsDir = resolvePiSessionsDir(args.piAgentDir);
1382
+ const providerScans = [];
1383
+ providerScans.push(await scanCodexProvider(codexHome, !args.skipArchived, args.timezone));
1384
+ if (getClaudeProjectDirs(claudeConfigPaths).length > 0 ||
1385
+ getClaudeStatsCacheFiles(claudeConfigPaths).length > 0 ||
1386
+ getClaudeHistoryFiles(claudeConfigPaths).length > 0) {
1387
+ const claudeScan = await scanClaudeProvider(claudeConfigPaths, args.timezone);
1388
+ if (providerScanHasUsage(claudeScan))
1389
+ providerScans.push(claudeScan);
1390
+ }
1391
+ if (openCodeBaseDir) {
1392
+ const openCodeScan = await scanOpenCodeProvider(openCodeBaseDir, args.timezone);
1393
+ if (providerScanHasUsage(openCodeScan))
1394
+ providerScans.push(openCodeScan);
1395
+ }
1396
+ if (piSessionsDir) {
1397
+ const piScan = await scanPiProvider(piSessionsDir, args.timezone);
1398
+ if (providerScanHasUsage(piScan))
1399
+ providerScans.push(piScan);
1400
+ }
1401
+ return buildReportPayload(providerScans, args.timezone);
1402
+ }
1403
+ export async function main(argv = process.argv.slice(2)) {
1404
+ const args = parseCliArgs(argv);
1405
+ const report = await generateReport(args);
1406
+ const outputHtml = resolve(args.outputHtml);
1407
+ const outputJson = resolve(args.outputJson);
1408
+ await writeOutput(report, outputHtml, outputJson);
1409
+ const combined = report.combined;
1410
+ const primaryScan = report.providers[DEFAULT_PROVIDER_ID]?.scan ?? createEmptyScanStats();
1411
+ console.log(`Scanned ${primaryScan.filesScanned} local usage files from ${resolve(args.codexHome)}`);
1412
+ console.log(`Counted ${primaryScan.tokenEventsCounted} token events across ${combined.days.length} days`);
1413
+ console.log(`Total tokens in extracted dataset: ${combined.days.reduce((sum, day) => sum + day.totalTokens, 0).toLocaleString("en-US")}`);
1414
+ console.log(`HTML report: ${outputHtml}`);
1415
+ console.log(`JSON data: ${outputJson}`);
1416
+ }
1417
+ //# sourceMappingURL=generator.js.map