From 6fc11bb4c0a1810c2fd86734f1a535e1fde04755 Mon Sep 17 00:00:00 2001 From: Vedang Manerikar Date: Wed, 28 Jan 2026 20:00:54 +0530 Subject: [PATCH 01/22] fix(ccusage-opencode): silence logger in JSON mode When running `ccusage-opencode` with `--json` flag, logger messages (like pricing fetcher logs) appear in the output, contaminating the JSON and making it impossible to parse. This commit fixes the issue by following the same pattern as used by `ccusage` and the other apps. Fixes: #829 --- apps/opencode/src/commands/daily.ts | 6 +++++- apps/opencode/src/commands/monthly.ts | 6 +++++- apps/opencode/src/commands/session.ts | 11 +++++++---- apps/opencode/src/commands/weekly.ts | 6 +++++- 4 files changed, 22 insertions(+), 7 deletions(-) diff --git a/apps/opencode/src/commands/daily.ts b/apps/opencode/src/commands/daily.ts index 1ad9b1a8..4ef74517 100644 --- a/apps/opencode/src/commands/daily.ts +++ b/apps/opencode/src/commands/daily.ts @@ -33,7 +33,11 @@ export const dailyCommand = define({ async run(ctx) { const jsonOutput = Boolean(ctx.values.json); - const entries = await loadOpenCodeMessages(); + if (jsonOutput) { + logger.level = 0; + } + + let entries = await loadOpenCodeMessages(); if (entries.length === 0) { const output = jsonOutput diff --git a/apps/opencode/src/commands/monthly.ts b/apps/opencode/src/commands/monthly.ts index 453795c5..f297ff05 100644 --- a/apps/opencode/src/commands/monthly.ts +++ b/apps/opencode/src/commands/monthly.ts @@ -33,7 +33,11 @@ export const monthlyCommand = define({ async run(ctx) { const jsonOutput = Boolean(ctx.values.json); - const entries = await loadOpenCodeMessages(); + if (jsonOutput) { + logger.level = 0; + } + + let entries = await loadOpenCodeMessages(); if (entries.length === 0) { const output = jsonOutput diff --git a/apps/opencode/src/commands/session.ts b/apps/opencode/src/commands/session.ts index c36467c0..80379075 100644 --- a/apps/opencode/src/commands/session.ts +++ b/apps/opencode/src/commands/session.ts @@ -33,10 +33,13 @@ export const sessionCommand = define({ async run(ctx) { const jsonOutput = Boolean(ctx.values.json); - const [entries, sessionMetadataMap] = await Promise.all([ - loadOpenCodeMessages(), - loadOpenCodeSessions(), - ]); + if (jsonOutput) { + logger.level = 0; + } + + let entries = await loadOpenCodeMessages(); + + const sessionMetadataMap = await loadOpenCodeSessions(); if (entries.length === 0) { const output = jsonOutput diff --git a/apps/opencode/src/commands/weekly.ts b/apps/opencode/src/commands/weekly.ts index 011e8204..5bba3f42 100644 --- a/apps/opencode/src/commands/weekly.ts +++ b/apps/opencode/src/commands/weekly.ts @@ -58,7 +58,11 @@ export const weeklyCommand = define({ async run(ctx) { const jsonOutput = Boolean(ctx.values.json); - const entries = await loadOpenCodeMessages(); + if (jsonOutput) { + logger.level = 0; + } + + let entries = await loadOpenCodeMessages(); if (entries.length === 0) { const output = jsonOutput From 40d53f696886f23e5e14b36dc8e5ff0fd672184b Mon Sep 17 00:00:00 2001 From: Vedang Manerikar Date: Wed, 28 Jan 2026 20:00:54 +0530 Subject: [PATCH 02/22] fix(ccusage-opencode): Add support for --since and --until flags Currently, the `--since` and `--until` flags are ignored in `ccusage-opencode`. When running commands like `ccusage-opencode daily --since 20260101 --json`, the output includes all data regardless of the date filter specified by `--since`. This commit fixes the issue. We also create `date-utils.ts` and `_shared-args.ts` to extract repeated code from ccusage-opencode commands. Fixes: #801 --- apps/opencode/src/_shared-args.ts | 48 +++++++ apps/opencode/src/commands/daily.ts | 21 ++- apps/opencode/src/commands/monthly.ts | 21 ++- apps/opencode/src/commands/session.ts | 21 ++- apps/opencode/src/commands/weekly.ts | 21 ++- apps/opencode/src/date-utils.ts | 180 ++++++++++++++++++++++++++ 6 files changed, 268 insertions(+), 44 deletions(-) create mode 100644 apps/opencode/src/_shared-args.ts create mode 100644 apps/opencode/src/date-utils.ts diff --git a/apps/opencode/src/_shared-args.ts b/apps/opencode/src/_shared-args.ts new file mode 100644 index 00000000..077870e8 --- /dev/null +++ b/apps/opencode/src/_shared-args.ts @@ -0,0 +1,48 @@ +import type { Args } from 'gunshi'; +import * as v from 'valibot'; + +/** + * Filter date schema for YYYYMMDD format (e.g., 20250125) + */ +const filterDateRegex = /^\d{8}$/; +export const filterDateSchema = v.pipe( + v.string(), + v.regex(filterDateRegex, 'Date must be in YYYYMMDD format (e.g., 20250125)'), + v.brand('FilterDate'), +); + +/** + * Parses and validates a date argument in YYYYMMDD format + * @param value - Date string to parse + * @returns Validated date string + */ +function parseDateArg(value: string): string { + return v.parse(filterDateSchema, value); +} + +/** + * Shared command line arguments used across multiple opencode CLI commands + */ +export const sharedArgs = { + since: { + type: 'custom', + short: 's', + description: 'Filter from date (YYYYMMDD format, e.g., 20250125)', + parse: parseDateArg, + }, + until: { + type: 'custom', + short: 'u', + description: 'Filter until date (YYYYMMDD format, e.g., 20250130)', + parse: parseDateArg, + }, + json: { + type: 'boolean', + short: 'j', + description: 'Output in JSON format', + }, + compact: { + type: 'boolean', + description: 'Force compact table mode', + }, +} as const satisfies Args; diff --git a/apps/opencode/src/commands/daily.ts b/apps/opencode/src/commands/daily.ts index 4ef74517..11268c76 100644 --- a/apps/opencode/src/commands/daily.ts +++ b/apps/opencode/src/commands/daily.ts @@ -10,8 +10,10 @@ import { import { groupBy } from 'es-toolkit'; import { define } from 'gunshi'; import pc from 'picocolors'; +import { sharedArgs } from '../_shared-args.ts'; import { calculateCostForEntry } from '../cost-utils.ts'; import { loadOpenCodeMessages } from '../data-loader.ts'; +import { isDateInRange } from '../date-utils.ts'; import { logger } from '../logger.ts'; const TABLE_COLUMN_COUNT = 8; @@ -19,17 +21,7 @@ const TABLE_COLUMN_COUNT = 8; export const dailyCommand = define({ name: 'daily', description: 'Show OpenCode token usage grouped by day', - args: { - json: { - type: 'boolean', - short: 'j', - description: 'Output in JSON format', - }, - compact: { - type: 'boolean', - description: 'Force compact table mode', - }, - }, + args: sharedArgs, async run(ctx) { const jsonOutput = Boolean(ctx.values.json); @@ -39,6 +31,13 @@ export const dailyCommand = define({ let entries = await loadOpenCodeMessages(); + const since = ctx.values.since ?? null; + const until = ctx.values.until ?? null; + + if (since != null || until != null) { + entries = entries.filter((entry) => isDateInRange(entry.timestamp, since, until)); + } + if (entries.length === 0) { const output = jsonOutput ? JSON.stringify({ daily: [], totals: null }) diff --git a/apps/opencode/src/commands/monthly.ts b/apps/opencode/src/commands/monthly.ts index f297ff05..c56996aa 100644 --- a/apps/opencode/src/commands/monthly.ts +++ b/apps/opencode/src/commands/monthly.ts @@ -10,8 +10,10 @@ import { import { groupBy } from 'es-toolkit'; import { define } from 'gunshi'; import pc from 'picocolors'; +import { sharedArgs } from '../_shared-args.ts'; import { calculateCostForEntry } from '../cost-utils.ts'; import { loadOpenCodeMessages } from '../data-loader.ts'; +import { isDateInRange } from '../date-utils.ts'; import { logger } from '../logger.ts'; const TABLE_COLUMN_COUNT = 8; @@ -19,17 +21,7 @@ const TABLE_COLUMN_COUNT = 8; export const monthlyCommand = define({ name: 'monthly', description: 'Show OpenCode token usage grouped by month', - args: { - json: { - type: 'boolean', - short: 'j', - description: 'Output in JSON format', - }, - compact: { - type: 'boolean', - description: 'Force compact table mode', - }, - }, + args: sharedArgs, async run(ctx) { const jsonOutput = Boolean(ctx.values.json); @@ -39,6 +31,13 @@ export const monthlyCommand = define({ let entries = await loadOpenCodeMessages(); + const since = ctx.values.since ?? null; + const until = ctx.values.until ?? null; + + if (since != null || until != null) { + entries = entries.filter((entry) => isDateInRange(entry.timestamp, since, until)); + } + if (entries.length === 0) { const output = jsonOutput ? JSON.stringify({ monthly: [], totals: null }) diff --git a/apps/opencode/src/commands/session.ts b/apps/opencode/src/commands/session.ts index 80379075..3a6c7c1f 100644 --- a/apps/opencode/src/commands/session.ts +++ b/apps/opencode/src/commands/session.ts @@ -10,8 +10,10 @@ import { import { groupBy } from 'es-toolkit'; import { define } from 'gunshi'; import pc from 'picocolors'; +import { sharedArgs } from '../_shared-args.ts'; import { calculateCostForEntry } from '../cost-utils.ts'; import { loadOpenCodeMessages, loadOpenCodeSessions } from '../data-loader.ts'; +import { isDateInRange } from '../date-utils.ts'; import { logger } from '../logger.ts'; const TABLE_COLUMN_COUNT = 8; @@ -19,17 +21,7 @@ const TABLE_COLUMN_COUNT = 8; export const sessionCommand = define({ name: 'session', description: 'Show OpenCode token usage grouped by session', - args: { - json: { - type: 'boolean', - short: 'j', - description: 'Output in JSON format', - }, - compact: { - type: 'boolean', - description: 'Force compact table mode', - }, - }, + args: sharedArgs, async run(ctx) { const jsonOutput = Boolean(ctx.values.json); @@ -39,6 +31,13 @@ export const sessionCommand = define({ let entries = await loadOpenCodeMessages(); + const since = ctx.values.since ?? null; + const until = ctx.values.until ?? null; + + if (since != null || until != null) { + entries = entries.filter((entry) => isDateInRange(entry.timestamp, since, until)); + } + const sessionMetadataMap = await loadOpenCodeSessions(); if (entries.length === 0) { diff --git a/apps/opencode/src/commands/weekly.ts b/apps/opencode/src/commands/weekly.ts index 5bba3f42..67c511df 100644 --- a/apps/opencode/src/commands/weekly.ts +++ b/apps/opencode/src/commands/weekly.ts @@ -10,8 +10,10 @@ import { import { groupBy } from 'es-toolkit'; import { define } from 'gunshi'; import pc from 'picocolors'; +import { sharedArgs } from '../_shared-args.ts'; import { calculateCostForEntry } from '../cost-utils.ts'; import { loadOpenCodeMessages } from '../data-loader.ts'; +import { isDateInRange } from '../date-utils.ts'; import { logger } from '../logger.ts'; const TABLE_COLUMN_COUNT = 8; @@ -44,17 +46,7 @@ function getISOWeek(date: Date): string { export const weeklyCommand = define({ name: 'weekly', description: 'Show OpenCode token usage grouped by week (ISO week format)', - args: { - json: { - type: 'boolean', - short: 'j', - description: 'Output in JSON format', - }, - compact: { - type: 'boolean', - description: 'Force compact table mode', - }, - }, + args: sharedArgs, async run(ctx) { const jsonOutput = Boolean(ctx.values.json); @@ -64,6 +56,13 @@ export const weeklyCommand = define({ let entries = await loadOpenCodeMessages(); + const since = ctx.values.since ?? null; + const until = ctx.values.until ?? null; + + if (since != null || until != null) { + entries = entries.filter((entry) => isDateInRange(entry.timestamp, since, until)); + } + if (entries.length === 0) { const output = jsonOutput ? JSON.stringify({ weekly: [], totals: null }) diff --git a/apps/opencode/src/date-utils.ts b/apps/opencode/src/date-utils.ts new file mode 100644 index 00000000..8bc1149d --- /dev/null +++ b/apps/opencode/src/date-utils.ts @@ -0,0 +1,180 @@ +/** + * @fileoverview Date utility functions for OpenCode usage analysis + * + * This module provides functions for date comparison and filtering + * used across all command implementations. + * + * @module date-utils + */ + +/** + * Parse a date string in YYYYMMDD format to a Date object + * @param dateStr - Date string in YYYYMMDD format + * @returns Date object or null if invalid + */ +function parseYYYYMMDD(dateStr: string): Date | null { + if (dateStr.length !== 8) { + return null; + } + + const year = Number.parseInt(dateStr.slice(0, 4), 10); + const month = Number.parseInt(dateStr.slice(4, 6), 10) - 1; // Month is 0-indexed + const day = Number.parseInt(dateStr.slice(6, 8), 10); + + if ( + Number.isNaN(year) || + Number.isNaN(month) || + Number.isNaN(day) || + month < 0 || + month > 11 || + day < 1 || + day > 31 + ) { + return null; + } + + const date = new Date(year, month, day); + // Check if the date is valid (e.g., not Feb 30) + if (date.getFullYear() !== year || date.getMonth() !== month || date.getDate() !== day) { + return null; + } + + return date; +} + +/** + * Check if a date is within the specified range + * @param date - Date to check + * @param since - Start date in YYYYMMDD format (inclusive), or null for no lower bound + * @param until - End date in YYYYMMDD format (inclusive), or null for no upper bound + * @returns true if date is within range, false otherwise + */ +export function isDateInRange(date: Date, since: string | null, until: string | null): boolean { + // Normalize date to midnight UTC for consistent comparison + const normalizedDate = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); + + if (since != null) { + const sinceDate = parseYYYYMMDD(since); + if (sinceDate == null) { + return false; + } + const normalizedSince = new Date( + Date.UTC(sinceDate.getFullYear(), sinceDate.getMonth(), sinceDate.getDate()), + ); + if (normalizedDate < normalizedSince) { + return false; + } + } + + if (until != null) { + const untilDate = parseYYYYMMDD(until); + if (untilDate == null) { + return false; + } + const normalizedUntil = new Date( + Date.UTC(untilDate.getFullYear(), untilDate.getMonth(), untilDate.getDate()), + ); + if (normalizedDate > normalizedUntil) { + return false; + } + } + + return true; +} + +if (import.meta.vitest != null) { + const { describe, it, expect } = import.meta.vitest; + + describe('date-utils', () => { + describe('parseYYYYMMDD', () => { + it('should parse valid dates', () => { + const date = parseYYYYMMDD('20250128'); + expect(date).not.toBeNull(); + expect(date?.getFullYear()).toBe(2025); + expect(date?.getMonth()).toBe(0); // January + expect(date?.getDate()).toBe(28); + }); + + it('should handle leap years', () => { + const date = parseYYYYMMDD('20240229'); + expect(date).not.toBeNull(); + expect(date?.getFullYear()).toBe(2024); + expect(date?.getMonth()).toBe(1); // February + expect(date?.getDate()).toBe(29); + }); + + it('should reject invalid dates', () => { + expect(parseYYYYMMDD('20240230')).toBeNull(); // Feb 30 doesn't exist + expect(parseYYYYMMDD('20241301')).toBeNull(); // Month 13 + expect(parseYYYYMMDD('20240001')).toBeNull(); // Month 0 + expect(parseYYYYMMDD('20240132')).toBeNull(); // Day 32 + expect(parseYYYYMMDD('20240100')).toBeNull(); // Day 0 + expect(parseYYYYMMDD('202501')).toBeNull(); // Too short + expect(parseYYYYMMDD('202501281')).toBeNull(); // Too long + expect(parseYYYYMMDD('abcd0128')).toBeNull(); // Non-numeric + }); + }); + + describe('isDateInRange', () => { + it('should accept dates within range', () => { + const date = new Date('2025-01-15T12:00:00Z'); + expect(isDateInRange(date, '20250110', '20250120')).toBe(true); + }); + + it('should accept dates on boundaries', () => { + const date1 = new Date('2025-01-10T00:00:00Z'); + const date2 = new Date('2025-01-20T23:59:59Z'); + expect(isDateInRange(date1, '20250110', '20250120')).toBe(true); + expect(isDateInRange(date2, '20250110', '20250120')).toBe(true); + }); + + it('should reject dates before since', () => { + const date = new Date('2025-01-09T23:59:59Z'); + expect(isDateInRange(date, '20250110', '20250120')).toBe(false); + }); + + it('should reject dates after until', () => { + const date = new Date('2025-01-21T00:00:01Z'); + expect(isDateInRange(date, '20250110', '20250120')).toBe(false); + }); + + it('should accept all dates when since is null', () => { + const date1 = new Date('2025-01-01T00:00:00Z'); + const date2 = new Date('2025-01-20T23:59:59Z'); + expect(isDateInRange(date1, null, '20250120')).toBe(true); + expect(isDateInRange(date2, null, '20250120')).toBe(true); + }); + + it('should accept all dates when until is null', () => { + const date1 = new Date('2025-01-10T00:00:00Z'); + const date2 = new Date('2025-12-31T23:59:59Z'); + expect(isDateInRange(date1, '20250110', null)).toBe(true); + expect(isDateInRange(date2, '20250110', null)).toBe(true); + }); + + it('should accept all dates when both are null', () => { + const date = new Date('2025-01-15T12:00:00Z'); + expect(isDateInRange(date, null, null)).toBe(true); + }); + + it('should handle different timezones correctly', () => { + // Create dates in different timezones + const date1 = new Date('2025-01-15T00:00:00-05:00'); // 5:00 UTC + const date2 = new Date('2025-01-15T23:59:59+05:00'); // 18:59 UTC + // Both should be accepted as they're on the same day in UTC + expect(isDateInRange(date1, '20250115', '20250115')).toBe(true); + expect(isDateInRange(date2, '20250115', '20250115')).toBe(true); + }); + + it('should reject invalid since date', () => { + const date = new Date('2025-01-15T12:00:00Z'); + expect(isDateInRange(date, '20241301', '20250120')).toBe(false); + }); + + it('should reject invalid until date', () => { + const date = new Date('2025-01-15T12:00:00Z'); + expect(isDateInRange(date, '20250110', '20240230')).toBe(false); + }); + }); + }); +} From 7367fe7fd8e84c381f044dd188c5b99548ad5a79 Mon Sep 17 00:00:00 2001 From: Vedang Manerikar Date: Wed, 28 Jan 2026 20:00:54 +0530 Subject: [PATCH 03/22] fix(ccusage-codex): rename costUSD to totalCost in totals dict The `totals` dict structure is inconsistent across the ccusage apps. Specifically, `ccusage-codex` uses `costUSD` while all other apps use `totalCost` for the cost field. This makes it impossible to write clean scripts that rely on a consistent output shape across all apps. This commit fixes the issue. Fixes: #831 --- apps/codex/src/_types.ts | 18 ++++++++++++------ apps/codex/src/commands/daily.ts | 4 ++-- apps/codex/src/commands/monthly.ts | 4 ++-- apps/codex/src/commands/session.ts | 4 ++-- apps/codex/src/daily-report.ts | 4 ++++ apps/codex/src/monthly-report.ts | 4 ++++ apps/codex/src/session-report.ts | 4 ++++ 7 files changed, 30 insertions(+), 12 deletions(-) diff --git a/apps/codex/src/_types.ts b/apps/codex/src/_types.ts index 3540e218..b9255722 100644 --- a/apps/codex/src/_types.ts +++ b/apps/codex/src/_types.ts @@ -20,14 +20,16 @@ export type ModelUsage = TokenUsageDelta & { export type DailyUsageSummary = { date: string; firstTimestamp: string; - costUSD: number; + totalCost: number; + costUSD: number; // Legacy field, use totalCost instead models: Map; } & TokenUsageDelta; export type MonthlyUsageSummary = { month: string; firstTimestamp: string; - costUSD: number; + totalCost: number; + costUSD: number; // Legacy field, use totalCost instead models: Map; } & TokenUsageDelta; @@ -35,7 +37,8 @@ export type SessionUsageSummary = { sessionId: string; firstTimestamp: string; lastTimestamp: string; - costUSD: number; + totalCost: number; + costUSD: number; // Legacy field, use totalCost instead models: Map; } & TokenUsageDelta; @@ -61,7 +64,8 @@ export type DailyReportRow = { outputTokens: number; reasoningOutputTokens: number; totalTokens: number; - costUSD: number; + totalCost: number; + costUSD: number; // Legacy field, use totalCost instead models: Record; }; @@ -72,7 +76,8 @@ export type MonthlyReportRow = { outputTokens: number; reasoningOutputTokens: number; totalTokens: number; - costUSD: number; + totalCost: number; + costUSD: number; // Legacy field, use totalCost instead models: Record; }; @@ -86,6 +91,7 @@ export type SessionReportRow = { outputTokens: number; reasoningOutputTokens: number; totalTokens: number; - costUSD: number; + totalCost: number; + costUSD: number; // Legacy field, use totalCost instead models: Record; }; diff --git a/apps/codex/src/commands/daily.ts b/apps/codex/src/commands/daily.ts index 6dc7c6f0..077bd66f 100644 --- a/apps/codex/src/commands/daily.ts +++ b/apps/codex/src/commands/daily.ts @@ -80,7 +80,7 @@ export const dailyCommand = define({ acc.outputTokens += row.outputTokens; acc.reasoningOutputTokens += row.reasoningOutputTokens; acc.totalTokens += row.totalTokens; - acc.costUSD += row.costUSD; + acc.totalCost += row.totalCost; return acc; }, { @@ -89,7 +89,7 @@ export const dailyCommand = define({ outputTokens: 0, reasoningOutputTokens: 0, totalTokens: 0, - costUSD: 0, + totalCost: 0, }, ); diff --git a/apps/codex/src/commands/monthly.ts b/apps/codex/src/commands/monthly.ts index 2a5abc5e..3741ff2e 100644 --- a/apps/codex/src/commands/monthly.ts +++ b/apps/codex/src/commands/monthly.ts @@ -82,7 +82,7 @@ export const monthlyCommand = define({ acc.outputTokens += row.outputTokens; acc.reasoningOutputTokens += row.reasoningOutputTokens; acc.totalTokens += row.totalTokens; - acc.costUSD += row.costUSD; + acc.totalCost += row.totalCost; return acc; }, { @@ -91,7 +91,7 @@ export const monthlyCommand = define({ outputTokens: 0, reasoningOutputTokens: 0, totalTokens: 0, - costUSD: 0, + totalCost: 0, }, ); diff --git a/apps/codex/src/commands/session.ts b/apps/codex/src/commands/session.ts index 5dbe1a7e..a0e8bf76 100644 --- a/apps/codex/src/commands/session.ts +++ b/apps/codex/src/commands/session.ts @@ -87,7 +87,7 @@ export const sessionCommand = define({ acc.outputTokens += row.outputTokens; acc.reasoningOutputTokens += row.reasoningOutputTokens; acc.totalTokens += row.totalTokens; - acc.costUSD += row.costUSD; + acc.totalCost += row.totalCost; return acc; }, { @@ -96,7 +96,7 @@ export const sessionCommand = define({ outputTokens: 0, reasoningOutputTokens: 0, totalTokens: 0, - costUSD: 0, + totalCost: 0, }, ); diff --git a/apps/codex/src/daily-report.ts b/apps/codex/src/daily-report.ts index c44a34a9..2e5ecc50 100644 --- a/apps/codex/src/daily-report.ts +++ b/apps/codex/src/daily-report.ts @@ -26,6 +26,7 @@ function createSummary(date: string, initialTimestamp: string): DailyUsageSummar outputTokens: 0, reasoningOutputTokens: 0, totalTokens: 0, + totalCost: 0, costUSD: 0, models: new Map(), }; @@ -99,6 +100,7 @@ export async function buildDailyReport( } cost += calculateCostUSD(usage, pricing); } + summary.totalCost = cost; summary.costUSD = cost; const rowModels: Record = {}; @@ -113,6 +115,7 @@ export async function buildDailyReport( outputTokens: summary.outputTokens, reasoningOutputTokens: summary.reasoningOutputTokens, totalTokens: summary.totalTokens, + totalCost: cost, costUSD: cost, models: rowModels, }); @@ -199,6 +202,7 @@ if (import.meta.vitest != null) { (300 / 1_000_000) * 0.6 + (100 / 1_000_000) * 0.06 + (200 / 1_000_000) * 2; + expect(first.totalCost).toBeCloseTo(expectedCost, 10); expect(first.costUSD).toBeCloseTo(expectedCost, 10); }); }); diff --git a/apps/codex/src/monthly-report.ts b/apps/codex/src/monthly-report.ts index b29e03ae..827b605c 100644 --- a/apps/codex/src/monthly-report.ts +++ b/apps/codex/src/monthly-report.ts @@ -26,6 +26,7 @@ function createSummary(month: string, initialTimestamp: string): MonthlyUsageSum outputTokens: 0, reasoningOutputTokens: 0, totalTokens: 0, + totalCost: 0, costUSD: 0, models: new Map(), }; @@ -100,6 +101,7 @@ export async function buildMonthlyReport( } cost += calculateCostUSD(usage, pricing); } + summary.totalCost = cost; summary.costUSD = cost; const rowModels: Record = {}; @@ -114,6 +116,7 @@ export async function buildMonthlyReport( outputTokens: summary.outputTokens, reasoningOutputTokens: summary.reasoningOutputTokens, totalTokens: summary.totalTokens, + totalCost: cost, costUSD: cost, models: rowModels, }); @@ -199,6 +202,7 @@ if (import.meta.vitest != null) { (300 / 1_000_000) * 0.6 + (100 / 1_000_000) * 0.06 + (200 / 1_000_000) * 2; + expect(first.totalCost).toBeCloseTo(expectedCost, 10); expect(first.costUSD).toBeCloseTo(expectedCost, 10); }); }); diff --git a/apps/codex/src/session-report.ts b/apps/codex/src/session-report.ts index 867d6103..d26b8fd3 100644 --- a/apps/codex/src/session-report.ts +++ b/apps/codex/src/session-report.ts @@ -27,6 +27,7 @@ function createSummary(sessionId: string, initialTimestamp: string): SessionUsag outputTokens: 0, reasoningOutputTokens: 0, totalTokens: 0, + totalCost: 0, costUSD: 0, models: new Map(), }; @@ -120,6 +121,7 @@ export async function buildSessionReport( } cost += calculateCostUSD(usage, pricing); } + summary.totalCost = cost; summary.costUSD = cost; const rowModels: Record = {}; @@ -142,6 +144,7 @@ export async function buildSessionReport( outputTokens: summary.outputTokens, reasoningOutputTokens: summary.reasoningOutputTokens, totalTokens: summary.totalTokens, + totalCost: cost, costUSD: cost, models: rowModels, }); @@ -231,6 +234,7 @@ if (import.meta.vitest != null) { (300 / 1_000_000) * 0.6 + (100 / 1_000_000) * 0.06 + (200 / 1_000_000) * 2; + expect(second.totalCost).toBeCloseTo(expectedCost, 10); expect(second.costUSD).toBeCloseTo(expectedCost, 10); }); }); From eb0651dfa67f0a1a32f1e75f51e72f8169a049b4 Mon Sep 17 00:00:00 2001 From: Vedang Manerikar Date: Wed, 28 Jan 2026 20:00:54 +0530 Subject: [PATCH 04/22] fix(ccusage-codex): add `cacheCreationTokens` and `cacheReadTokens` fields to codex output `codex` provides the field `cachedInputTokens`. It does not have the fields that all the other apps provide. So we make an educating remaping of names to make fields across apps consistent. Fixes: #830 --- apps/codex/src/_types.ts | 17 +++++++++++++---- apps/codex/src/command-utils.ts | 7 +++++-- apps/codex/src/commands/daily.ts | 6 ++++-- apps/codex/src/commands/monthly.ts | 6 ++++-- apps/codex/src/commands/session.ts | 6 ++++-- apps/codex/src/daily-report.ts | 20 +++++++++++++++++--- apps/codex/src/data-loader.ts | 9 +++++++-- apps/codex/src/monthly-report.ts | 20 +++++++++++++++++--- apps/codex/src/session-report.ts | 19 ++++++++++++++++--- apps/codex/src/token-utils.ts | 12 +++++++----- 10 files changed, 94 insertions(+), 28 deletions(-) diff --git a/apps/codex/src/_types.ts b/apps/codex/src/_types.ts index b9255722..37c1b4a4 100644 --- a/apps/codex/src/_types.ts +++ b/apps/codex/src/_types.ts @@ -1,6 +1,8 @@ export type TokenUsageDelta = { inputTokens: number; - cachedInputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + cachedInputTokens?: number; outputTokens: number; reasoningOutputTokens: number; totalTokens: number; @@ -15,6 +17,7 @@ export type TokenUsageEvent = TokenUsageDelta & { export type ModelUsage = TokenUsageDelta & { isFallback?: boolean; + cachedInputTokens?: number; // Legacy field, now split into cacheCreationTokens + cacheReadTokens }; export type DailyUsageSummary = { @@ -60,25 +63,29 @@ export type PricingSource = { export type DailyReportRow = { date: string; inputTokens: number; - cachedInputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; outputTokens: number; reasoningOutputTokens: number; totalTokens: number; totalCost: number; costUSD: number; // Legacy field, use totalCost instead models: Record; + cachedInputTokens?: number; // Legacy field for backward compatibility }; export type MonthlyReportRow = { month: string; inputTokens: number; - cachedInputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; outputTokens: number; reasoningOutputTokens: number; totalTokens: number; totalCost: number; costUSD: number; // Legacy field, use totalCost instead models: Record; + cachedInputTokens?: number; // Legacy field for backward compatibility }; export type SessionReportRow = { @@ -87,11 +94,13 @@ export type SessionReportRow = { sessionFile: string; directory: string; inputTokens: number; - cachedInputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; outputTokens: number; reasoningOutputTokens: number; totalTokens: number; totalCost: number; costUSD: number; // Legacy field, use totalCost instead models: Record; + cachedInputTokens?: number; // Legacy field for backward compatibility }; diff --git a/apps/codex/src/command-utils.ts b/apps/codex/src/command-utils.ts index 89c31d41..ca21876b 100644 --- a/apps/codex/src/command-utils.ts +++ b/apps/codex/src/command-utils.ts @@ -2,7 +2,9 @@ import { sort } from 'fast-sort'; export type UsageGroup = { inputTokens: number; - cachedInputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + cachedInputTokens?: number; outputTokens: number; reasoningOutputTokens: number; }; @@ -13,7 +15,8 @@ export function splitUsageTokens(usage: UsageGroup): { cacheReadTokens: number; outputTokens: number; } { - const cacheReadTokens = Math.min(usage.cachedInputTokens, usage.inputTokens); + const cacheReadTokens = + usage.cacheReadTokens ?? Math.min(usage.cachedInputTokens ?? 0, usage.inputTokens); const inputTokens = Math.max(usage.inputTokens - cacheReadTokens, 0); const outputTokens = Math.max(usage.outputTokens, 0); const rawReasoning = usage.reasoningOutputTokens ?? 0; diff --git a/apps/codex/src/commands/daily.ts b/apps/codex/src/commands/daily.ts index 077bd66f..33a70c02 100644 --- a/apps/codex/src/commands/daily.ts +++ b/apps/codex/src/commands/daily.ts @@ -76,7 +76,8 @@ export const dailyCommand = define({ const totals = rows.reduce( (acc, row) => { acc.inputTokens += row.inputTokens; - acc.cachedInputTokens += row.cachedInputTokens; + acc.cacheCreationTokens += row.cacheCreationTokens; + acc.cacheReadTokens += row.cacheReadTokens; acc.outputTokens += row.outputTokens; acc.reasoningOutputTokens += row.reasoningOutputTokens; acc.totalTokens += row.totalTokens; @@ -85,7 +86,8 @@ export const dailyCommand = define({ }, { inputTokens: 0, - cachedInputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, outputTokens: 0, reasoningOutputTokens: 0, totalTokens: 0, diff --git a/apps/codex/src/commands/monthly.ts b/apps/codex/src/commands/monthly.ts index 3741ff2e..e3f00bad 100644 --- a/apps/codex/src/commands/monthly.ts +++ b/apps/codex/src/commands/monthly.ts @@ -78,7 +78,8 @@ export const monthlyCommand = define({ const totals = rows.reduce( (acc, row) => { acc.inputTokens += row.inputTokens; - acc.cachedInputTokens += row.cachedInputTokens; + acc.cacheCreationTokens += row.cacheCreationTokens; + acc.cacheReadTokens += row.cacheReadTokens; acc.outputTokens += row.outputTokens; acc.reasoningOutputTokens += row.reasoningOutputTokens; acc.totalTokens += row.totalTokens; @@ -87,7 +88,8 @@ export const monthlyCommand = define({ }, { inputTokens: 0, - cachedInputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, outputTokens: 0, reasoningOutputTokens: 0, totalTokens: 0, diff --git a/apps/codex/src/commands/session.ts b/apps/codex/src/commands/session.ts index a0e8bf76..7db7a6f1 100644 --- a/apps/codex/src/commands/session.ts +++ b/apps/codex/src/commands/session.ts @@ -83,7 +83,8 @@ export const sessionCommand = define({ const totals = rows.reduce( (acc, row) => { acc.inputTokens += row.inputTokens; - acc.cachedInputTokens += row.cachedInputTokens; + acc.cacheCreationTokens += row.cacheCreationTokens; + acc.cacheReadTokens += row.cacheReadTokens; acc.outputTokens += row.outputTokens; acc.reasoningOutputTokens += row.reasoningOutputTokens; acc.totalTokens += row.totalTokens; @@ -92,7 +93,8 @@ export const sessionCommand = define({ }, { inputTokens: 0, - cachedInputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, outputTokens: 0, reasoningOutputTokens: 0, totalTokens: 0, diff --git a/apps/codex/src/daily-report.ts b/apps/codex/src/daily-report.ts index 2e5ecc50..158031ec 100644 --- a/apps/codex/src/daily-report.ts +++ b/apps/codex/src/daily-report.ts @@ -22,7 +22,8 @@ function createSummary(date: string, initialTimestamp: string): DailyUsageSummar date, firstTimestamp: initialTimestamp, inputTokens: 0, - cachedInputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, outputTokens: 0, reasoningOutputTokens: 0, totalTokens: 0, @@ -105,13 +106,19 @@ export async function buildDailyReport( const rowModels: Record = {}; for (const [modelName, usage] of summary.models) { - rowModels[modelName] = { ...usage }; + const modelEntry: ModelUsage = { ...usage }; + if (usage.cacheReadTokens != null) { + modelEntry.cachedInputTokens = usage.cacheReadTokens; + } + rowModels[modelName] = modelEntry; } rows.push({ date: formatDisplayDate(summary.date, locale, timezone), inputTokens: summary.inputTokens, - cachedInputTokens: summary.cachedInputTokens, + cacheCreationTokens: summary.cacheCreationTokens, + cacheReadTokens: summary.cacheReadTokens, + cachedInputTokens: summary.cacheReadTokens, outputTokens: summary.outputTokens, reasoningOutputTokens: summary.reasoningOutputTokens, totalTokens: summary.totalTokens, @@ -153,6 +160,8 @@ if (import.meta.vitest != null) { timestamp: '2025-09-11T03:00:00.000Z', model: 'gpt-5', inputTokens: 1_000, + cacheCreationTokens: 0, + cacheReadTokens: 200, cachedInputTokens: 200, outputTokens: 500, reasoningOutputTokens: 0, @@ -163,6 +172,8 @@ if (import.meta.vitest != null) { timestamp: '2025-09-11T05:00:00.000Z', model: 'gpt-5-mini', inputTokens: 400, + cacheCreationTokens: 0, + cacheReadTokens: 100, cachedInputTokens: 100, outputTokens: 200, reasoningOutputTokens: 50, @@ -173,6 +184,8 @@ if (import.meta.vitest != null) { timestamp: '2025-09-12T01:00:00.000Z', model: 'gpt-5', inputTokens: 2_000, + cacheCreationTokens: 0, + cacheReadTokens: 0, cachedInputTokens: 0, outputTokens: 800, reasoningOutputTokens: 0, @@ -190,6 +203,7 @@ if (import.meta.vitest != null) { const first = report[0]!; expect(first.date).toContain('2025'); expect(first.inputTokens).toBe(1_400); + expect(first.cacheReadTokens).toBe(300); expect(first.cachedInputTokens).toBe(300); expect(first.outputTokens).toBe(700); expect(first.reasoningOutputTokens).toBe(50); diff --git a/apps/codex/src/data-loader.ts b/apps/codex/src/data-loader.ts index ef23a8f5..c6ae79b9 100644 --- a/apps/codex/src/data-loader.ts +++ b/apps/codex/src/data-loader.ts @@ -94,7 +94,8 @@ function convertToDelta(raw: RawUsage): TokenUsageDelta { return { inputTokens: raw.input_tokens, - cachedInputTokens: cached, + cacheCreationTokens: 0, + cacheReadTokens: cached, outputTokens: raw.output_tokens, reasoningOutputTokens: raw.reasoning_output_tokens, totalTokens: total, @@ -342,7 +343,11 @@ export async function loadTokenUsageEvents(options: LoadOptions = {}): Promise = {}; for (const [modelName, usage] of summary.models) { - rowModels[modelName] = { ...usage }; + const modelEntry: ModelUsage = { ...usage }; + if (usage.cacheReadTokens != null) { + modelEntry.cachedInputTokens = usage.cacheReadTokens; + } + rowModels[modelName] = modelEntry; } rows.push({ month: formatDisplayMonth(summary.month, locale, timezone), inputTokens: summary.inputTokens, - cachedInputTokens: summary.cachedInputTokens, + cacheCreationTokens: summary.cacheCreationTokens, + cacheReadTokens: summary.cacheReadTokens, + cachedInputTokens: summary.cacheReadTokens, outputTokens: summary.outputTokens, reasoningOutputTokens: summary.reasoningOutputTokens, totalTokens: summary.totalTokens, @@ -154,6 +161,8 @@ if (import.meta.vitest != null) { timestamp: '2025-08-11T03:00:00.000Z', model: 'gpt-5', inputTokens: 1_000, + cacheCreationTokens: 0, + cacheReadTokens: 200, cachedInputTokens: 200, outputTokens: 500, reasoningOutputTokens: 0, @@ -164,6 +173,8 @@ if (import.meta.vitest != null) { timestamp: '2025-08-20T05:00:00.000Z', model: 'gpt-5-mini', inputTokens: 400, + cacheCreationTokens: 0, + cacheReadTokens: 100, cachedInputTokens: 100, outputTokens: 200, reasoningOutputTokens: 50, @@ -174,6 +185,8 @@ if (import.meta.vitest != null) { timestamp: '2025-09-12T01:00:00.000Z', model: 'gpt-5', inputTokens: 2_000, + cacheCreationTokens: 0, + cacheReadTokens: 0, cachedInputTokens: 0, outputTokens: 800, reasoningOutputTokens: 0, @@ -190,6 +203,7 @@ if (import.meta.vitest != null) { expect(report).toHaveLength(2); const first = report[0]!; expect(first.inputTokens).toBe(1_400); + expect(first.cacheReadTokens).toBe(300); expect(first.cachedInputTokens).toBe(300); expect(first.outputTokens).toBe(700); expect(first.reasoningOutputTokens).toBe(50); diff --git a/apps/codex/src/session-report.ts b/apps/codex/src/session-report.ts index d26b8fd3..f17e67a9 100644 --- a/apps/codex/src/session-report.ts +++ b/apps/codex/src/session-report.ts @@ -23,7 +23,8 @@ function createSummary(sessionId: string, initialTimestamp: string): SessionUsag firstTimestamp: initialTimestamp, lastTimestamp: initialTimestamp, inputTokens: 0, - cachedInputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, outputTokens: 0, reasoningOutputTokens: 0, totalTokens: 0, @@ -126,7 +127,11 @@ export async function buildSessionReport( const rowModels: Record = {}; for (const [modelName, usage] of summary.models) { - rowModels[modelName] = { ...usage }; + const modelEntry: ModelUsage = { ...usage }; + if (usage.cacheReadTokens != null) { + modelEntry.cachedInputTokens = usage.cacheReadTokens; + } + rowModels[modelName] = modelEntry; } const separatorIndex = summary.sessionId.lastIndexOf('/'); @@ -140,7 +145,9 @@ export async function buildSessionReport( sessionFile, directory, inputTokens: summary.inputTokens, - cachedInputTokens: summary.cachedInputTokens, + cacheCreationTokens: summary.cacheCreationTokens, + cacheReadTokens: summary.cacheReadTokens, + cachedInputTokens: summary.cacheReadTokens, outputTokens: summary.outputTokens, reasoningOutputTokens: summary.reasoningOutputTokens, totalTokens: summary.totalTokens, @@ -183,6 +190,8 @@ if (import.meta.vitest != null) { timestamp: '2025-09-12T01:00:00.000Z', model: 'gpt-5', inputTokens: 1_000, + cacheCreationTokens: 0, + cacheReadTokens: 100, cachedInputTokens: 100, outputTokens: 500, reasoningOutputTokens: 0, @@ -193,6 +202,8 @@ if (import.meta.vitest != null) { timestamp: '2025-09-12T02:00:00.000Z', model: 'gpt-5-mini', inputTokens: 400, + cacheCreationTokens: 0, + cacheReadTokens: 100, cachedInputTokens: 100, outputTokens: 200, reasoningOutputTokens: 30, @@ -203,6 +214,8 @@ if (import.meta.vitest != null) { timestamp: '2025-09-11T23:30:00.000Z', model: 'gpt-5', inputTokens: 800, + cacheCreationTokens: 0, + cacheReadTokens: 0, cachedInputTokens: 0, outputTokens: 300, reasoningOutputTokens: 0, diff --git a/apps/codex/src/token-utils.ts b/apps/codex/src/token-utils.ts index bcbc3982..25d29053 100644 --- a/apps/codex/src/token-utils.ts +++ b/apps/codex/src/token-utils.ts @@ -5,7 +5,8 @@ import { MILLION } from './_consts.ts'; export function createEmptyUsage(): TokenUsageDelta { return { inputTokens: 0, - cachedInputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, outputTokens: 0, reasoningOutputTokens: 0, totalTokens: 0, @@ -14,14 +15,16 @@ export function createEmptyUsage(): TokenUsageDelta { export function addUsage(target: TokenUsageDelta, delta: TokenUsageDelta): void { target.inputTokens += delta.inputTokens; - target.cachedInputTokens += delta.cachedInputTokens; + target.cacheCreationTokens += delta.cacheCreationTokens; + target.cacheReadTokens += delta.cacheReadTokens; target.outputTokens += delta.outputTokens; target.reasoningOutputTokens += delta.reasoningOutputTokens; target.totalTokens += delta.totalTokens; } function nonCachedInputTokens(usage: TokenUsageDelta): number { - const nonCached = usage.inputTokens - usage.cachedInputTokens; + const cached = usage.cacheReadTokens ?? 0; + const nonCached = usage.inputTokens - cached; return nonCached > 0 ? nonCached : 0; } @@ -41,8 +44,7 @@ function nonCachedInputTokens(usage: TokenUsageDelta): number { */ export function calculateCostUSD(usage: TokenUsageDelta, pricing: ModelPricing): number { const nonCachedInput = nonCachedInputTokens(usage); - const cachedInput = - usage.cachedInputTokens > usage.inputTokens ? usage.inputTokens : usage.cachedInputTokens; + const cachedInput = usage.cacheReadTokens ?? 0; const outputTokens = usage.outputTokens; const inputCost = (nonCachedInput / MILLION) * pricing.inputCostPerMToken; From cfa00d1beddcaff863b2f097a8ade62c2ee03c1b Mon Sep 17 00:00:00 2001 From: Vedang Manerikar Date: Sat, 31 Jan 2026 12:33:06 +0530 Subject: [PATCH 05/22] fix(mcp): align codex JSON parsing with codex CLI Fixes: #830 Fixes: #831 --- apps/mcp/src/codex.ts | 118 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 105 insertions(+), 13 deletions(-) diff --git a/apps/mcp/src/codex.ts b/apps/mcp/src/codex.ts index 61dbd538..d3855e3c 100644 --- a/apps/mcp/src/codex.ts +++ b/apps/mcp/src/codex.ts @@ -1,56 +1,74 @@ import type { CliInvocation } from './cli-utils.ts'; +import process from 'node:process'; +import { createFixture } from 'fs-fixture'; import { z } from 'zod'; import { createCliInvocation, executeCliCommand, resolveBinaryPath } from './cli-utils.ts'; const codexModelUsageSchema = z.object({ inputTokens: z.number(), - cachedInputTokens: z.number(), + cacheCreationTokens: z.number(), + cacheReadTokens: z.number(), outputTokens: z.number(), reasoningOutputTokens: z.number(), totalTokens: z.number(), + // Legacy field (Codex used `cachedInputTokens` before splitting cache fields) + cachedInputTokens: z.number().optional(), isFallback: z.boolean().optional(), }); const codexTotalsSchema = z.object({ inputTokens: z.number(), - cachedInputTokens: z.number(), + cacheCreationTokens: z.number(), + cacheReadTokens: z.number(), outputTokens: z.number(), reasoningOutputTokens: z.number(), totalTokens: z.number(), - costUSD: z.number(), + totalCost: z.number(), }); const codexDailyRowSchema = z.object({ date: z.string(), inputTokens: z.number(), - cachedInputTokens: z.number(), + cacheCreationTokens: z.number(), + cacheReadTokens: z.number(), outputTokens: z.number(), reasoningOutputTokens: z.number(), totalTokens: z.number(), - costUSD: z.number(), + totalCost: z.number(), models: z.record(z.string(), codexModelUsageSchema), + // Legacy fields (kept for backward compatibility) + cachedInputTokens: z.number().optional(), + costUSD: z.number().optional(), }); const codexMonthlyRowSchema = z.object({ month: z.string(), inputTokens: z.number(), - cachedInputTokens: z.number(), + cacheCreationTokens: z.number(), + cacheReadTokens: z.number(), outputTokens: z.number(), reasoningOutputTokens: z.number(), totalTokens: z.number(), - costUSD: z.number(), + totalCost: z.number(), models: z.record(z.string(), codexModelUsageSchema), + // Legacy fields (kept for backward compatibility) + cachedInputTokens: z.number().optional(), + costUSD: z.number().optional(), }); // Response schemas for internal parsing only - not exported -const codexDailyResponseSchema = z.object({ - daily: z.array(codexDailyRowSchema), - totals: codexTotalsSchema.nullable(), -}); +const codexDailyResponseSchema = z.union([ + z.object({ + daily: z.array(codexDailyRowSchema), + totals: codexTotalsSchema, + }), + // Legacy behavior: some versions returned `[]` when filters yielded no rows + z.array(z.never()), +]); const codexMonthlyResponseSchema = z.object({ monthly: z.array(codexMonthlyRowSchema), - totals: codexTotalsSchema.nullable(), + totals: codexTotalsSchema, }); export const codexParametersShape = { @@ -111,10 +129,84 @@ async function runCodexCliJson( export async function getCodexDaily(parameters: z.infer) { const raw = await runCodexCliJson('daily', parameters); - return codexDailyResponseSchema.parse(JSON.parse(raw)); + const parsed = JSON.parse(raw) as unknown; + const normalized = codexDailyResponseSchema.parse(parsed); + if (Array.isArray(normalized)) { + return { + daily: [], + totals: { + inputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + outputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 0, + totalCost: 0, + }, + }; + } + return normalized; } export async function getCodexMonthly(parameters: z.infer) { const raw = await runCodexCliJson('monthly', parameters); return codexMonthlyResponseSchema.parse(JSON.parse(raw)); } + +if (import.meta.vitest != null) { + describe('getCodexDaily/getCodexMonthly', () => { + it('parses empty daily output (no data)', async () => { + const originalCodexHome = process.env.CODEX_HOME; + await using fixture = await createFixture({ sessions: {} }); + process.env.CODEX_HOME = fixture.path; + try { + const result = await getCodexDaily({ offline: true }); + expect(result).toEqual({ + daily: [], + totals: { + inputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + outputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 0, + totalCost: 0, + }, + }); + } finally { + if (originalCodexHome == null) { + delete process.env.CODEX_HOME; + } else { + process.env.CODEX_HOME = originalCodexHome; + } + } + }); + + it('parses empty monthly output (no data)', async () => { + const originalCodexHome = process.env.CODEX_HOME; + await using fixture = await createFixture({ sessions: {} }); + process.env.CODEX_HOME = fixture.path; + try { + const result = await getCodexMonthly({ offline: true }); + expect(result).toEqual({ + monthly: [], + totals: { + inputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + outputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 0, + totalCost: 0, + }, + }); + } finally { + if (originalCodexHome == null) { + delete process.env.CODEX_HOME; + } else { + process.env.CODEX_HOME = originalCodexHome; + } + } + }); + }); +} From f4e8f68d5215c8385e78b6ac8a95e12121b9ca04 Mon Sep 17 00:00:00 2001 From: Vedang Manerikar Date: Wed, 28 Jan 2026 20:00:54 +0530 Subject: [PATCH 06/22] fix(ccusage-pi): Add `totalTokens` to totals dict All the other apps provide a `totalTokens` field which is a sum of the individual fields. `ccusage-pi` does not provide this field in its json output. This commit fixes the issue. Fixes: #830 --- apps/pi/src/commands/daily.ts | 3 +++ apps/pi/src/commands/monthly.ts | 3 +++ apps/pi/src/commands/session.ts | 3 +++ 3 files changed, 9 insertions(+) diff --git a/apps/pi/src/commands/daily.ts b/apps/pi/src/commands/daily.ts index 5e372f4d..8494ca00 100644 --- a/apps/pi/src/commands/daily.ts +++ b/apps/pi/src/commands/daily.ts @@ -74,6 +74,7 @@ export const dailyCommand = define({ outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, + totalTokens: 0, totalCost: 0, }; @@ -82,6 +83,8 @@ export const dailyCommand = define({ totals.outputTokens += d.outputTokens; totals.cacheCreationTokens += d.cacheCreationTokens; totals.cacheReadTokens += d.cacheReadTokens; + totals.totalTokens += + d.inputTokens + d.outputTokens + d.cacheCreationTokens + d.cacheReadTokens; totals.totalCost += d.totalCost; } diff --git a/apps/pi/src/commands/monthly.ts b/apps/pi/src/commands/monthly.ts index 105c74af..5775ed91 100644 --- a/apps/pi/src/commands/monthly.ts +++ b/apps/pi/src/commands/monthly.ts @@ -74,6 +74,7 @@ export const monthlyCommand = define({ outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, + totalTokens: 0, totalCost: 0, }; @@ -82,6 +83,8 @@ export const monthlyCommand = define({ totals.outputTokens += d.outputTokens; totals.cacheCreationTokens += d.cacheCreationTokens; totals.cacheReadTokens += d.cacheReadTokens; + totals.totalTokens += + d.inputTokens + d.outputTokens + d.cacheCreationTokens + d.cacheReadTokens; totals.totalCost += d.totalCost; } diff --git a/apps/pi/src/commands/session.ts b/apps/pi/src/commands/session.ts index d3cc8432..431d357b 100644 --- a/apps/pi/src/commands/session.ts +++ b/apps/pi/src/commands/session.ts @@ -75,6 +75,7 @@ export const sessionCommand = define({ outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, + totalTokens: 0, totalCost: 0, }; @@ -83,6 +84,8 @@ export const sessionCommand = define({ totals.outputTokens += d.outputTokens; totals.cacheCreationTokens += d.cacheCreationTokens; totals.cacheReadTokens += d.cacheReadTokens; + totals.totalTokens += + d.inputTokens + d.outputTokens + d.cacheCreationTokens + d.cacheReadTokens; totals.totalCost += d.totalCost; } From 3ab28039ebf2c69ac18264e2aa23752145573f1e Mon Sep 17 00:00:00 2001 From: Vedang Manerikar Date: Sat, 31 Jan 2026 13:03:29 +0530 Subject: [PATCH 07/22] fix(ccusage-amp): include cache tokens in totalTokens Fixes: #830 --- apps/amp/src/commands/daily.ts | 2 +- apps/amp/src/commands/monthly.ts | 2 +- apps/amp/src/commands/session.ts | 2 +- apps/amp/src/data-loader.ts | 3 ++- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/amp/src/commands/daily.ts b/apps/amp/src/commands/daily.ts index 2c728575..38d1cca0 100644 --- a/apps/amp/src/commands/daily.ts +++ b/apps/amp/src/commands/daily.ts @@ -98,7 +98,7 @@ export const dailyCommand = define({ modelsSet.add(event.model); } - const totalTokens = inputTokens + outputTokens; + const totalTokens = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens; dailyData.push({ date, diff --git a/apps/amp/src/commands/monthly.ts b/apps/amp/src/commands/monthly.ts index b7c869cd..e5c1dff6 100644 --- a/apps/amp/src/commands/monthly.ts +++ b/apps/amp/src/commands/monthly.ts @@ -98,7 +98,7 @@ export const monthlyCommand = define({ modelsSet.add(event.model); } - const totalTokens = inputTokens + outputTokens; + const totalTokens = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens; monthlyData.push({ month, diff --git a/apps/amp/src/commands/session.ts b/apps/amp/src/commands/session.ts index 623955a5..79f1f79d 100644 --- a/apps/amp/src/commands/session.ts +++ b/apps/amp/src/commands/session.ts @@ -104,7 +104,7 @@ export const sessionCommand = define({ } } - const totalTokens = inputTokens + outputTokens; + const totalTokens = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens; const threadInfo = threads.get(threadId); sessionData.push({ diff --git a/apps/amp/src/data-loader.ts b/apps/amp/src/data-loader.ts index 7713c7d1..d9c53cd9 100644 --- a/apps/amp/src/data-loader.ts +++ b/apps/amp/src/data-loader.ts @@ -157,7 +157,7 @@ function convertLedgerEventToUsageEvent( outputTokens, cacheCreationInputTokens, cacheReadInputTokens, - totalTokens: inputTokens + outputTokens, + totalTokens: inputTokens + outputTokens + cacheCreationInputTokens + cacheReadInputTokens, }; } @@ -330,6 +330,7 @@ if (import.meta.vitest != null) { expect(event.outputTokens).toBe(50); expect(event.cacheCreationInputTokens).toBe(500); expect(event.cacheReadInputTokens).toBe(200); + expect(event.totalTokens).toBe(850); expect(event.credits).toBe(1.5); expect(threads.get('T-test-thread-123')).toEqual({ From 9edca55ae17a8862a94207bce38ca106353e4f82 Mon Sep 17 00:00:00 2001 From: Vedang Manerikar Date: Wed, 28 Jan 2026 20:00:54 +0530 Subject: [PATCH 08/22] fix: normalize totals outputs across ccusage/codex/opencode/pi/amp The ccusage apps have **inconsistent behavior** when no usage data exists. The reference app (`ccusage`) is itself inconsistent, and the other apps don't follow a consistent pattern. This makes it difficult to write scripts that work across all apps. This commit makes the behaviour consistent across all apps, by using the same structure for the totals dict across all of them. Fixes: #832 --- apps/amp/src/commands/daily.ts | 21 ++++++++++++---- apps/amp/src/commands/monthly.ts | 24 ++++++++++++++---- apps/amp/src/commands/session.ts | 21 ++++++++++++---- apps/ccusage/src/commands/daily.ts | 12 ++++++++- apps/ccusage/src/commands/session.ts | 9 ++++++- apps/codex/src/commands/daily.ts | 34 ++++++++++++++++++++----- apps/codex/src/commands/monthly.ts | 34 ++++++++++++++++++++++--- apps/codex/src/commands/session.ts | 36 +++++++++++++++++++++------ apps/opencode/src/commands/daily.ts | 20 +++++++++++---- apps/opencode/src/commands/monthly.ts | 20 +++++++++++---- apps/opencode/src/commands/session.ts | 20 +++++++++++---- apps/opencode/src/commands/weekly.ts | 20 +++++++++++---- apps/pi/src/commands/daily.ts | 10 +++++++- apps/pi/src/commands/monthly.ts | 10 +++++++- apps/pi/src/commands/session.ts | 10 +++++++- 15 files changed, 243 insertions(+), 58 deletions(-) diff --git a/apps/amp/src/commands/daily.ts b/apps/amp/src/commands/daily.ts index 38d1cca0..dca32166 100644 --- a/apps/amp/src/commands/daily.ts +++ b/apps/amp/src/commands/daily.ts @@ -48,11 +48,22 @@ export const dailyCommand = define({ const { events } = await loadAmpUsageEvents(); if (events.length === 0) { - const output = jsonOutput - ? JSON.stringify({ daily: [], totals: null }) - : 'No Amp usage data found.'; - // eslint-disable-next-line no-console - console.log(output); + if (jsonOutput) { + const emptyTotals = { + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + totalTokens: 0, + credits: 0, + totalCost: 0, + }; + // eslint-disable-next-line no-console + console.log(JSON.stringify({ daily: [], totals: emptyTotals }, null, 2)); + } else { + // eslint-disable-next-line no-console + console.log('No Amp usage data found.'); + } return; } diff --git a/apps/amp/src/commands/monthly.ts b/apps/amp/src/commands/monthly.ts index e5c1dff6..09f1100a 100644 --- a/apps/amp/src/commands/monthly.ts +++ b/apps/amp/src/commands/monthly.ts @@ -48,11 +48,25 @@ export const monthlyCommand = define({ const { events } = await loadAmpUsageEvents(); if (events.length === 0) { - const output = jsonOutput - ? JSON.stringify({ monthly: [], totals: null }) - : 'No Amp usage data found.'; - // eslint-disable-next-line no-console - console.log(output); + if (jsonOutput) { + const emptyOutput = { + monthly: [], + totals: { + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + totalTokens: 0, + credits: 0, + totalCost: 0, + }, + }; + // eslint-disable-next-line no-console + console.log(JSON.stringify(emptyOutput, null, 2)); + } else { + // eslint-disable-next-line no-console + console.log('No Amp usage data found.'); + } return; } diff --git a/apps/amp/src/commands/session.ts b/apps/amp/src/commands/session.ts index 79f1f79d..9821f9bc 100644 --- a/apps/amp/src/commands/session.ts +++ b/apps/amp/src/commands/session.ts @@ -47,11 +47,22 @@ export const sessionCommand = define({ const { events, threads } = await loadAmpUsageEvents(); if (events.length === 0) { - const output = jsonOutput - ? JSON.stringify({ sessions: [], totals: null }) - : 'No Amp usage data found.'; - // eslint-disable-next-line no-console - console.log(output); + if (jsonOutput) { + const emptyTotals = { + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + totalTokens: 0, + credits: 0, + totalCost: 0, + }; + // eslint-disable-next-line no-console + console.log(JSON.stringify({ sessions: [], totals: emptyTotals }, null, 2)); + } else { + // eslint-disable-next-line no-console + console.log('No Amp usage data found.'); + } return; } diff --git a/apps/ccusage/src/commands/daily.ts b/apps/ccusage/src/commands/daily.ts index 16585eb9..9e124fc2 100644 --- a/apps/ccusage/src/commands/daily.ts +++ b/apps/ccusage/src/commands/daily.ts @@ -82,7 +82,17 @@ export const dailyCommand = define({ if (dailyData.length === 0) { if (useJson) { - log(JSON.stringify([])); + const totals = createTotalsObject({ + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + totalCost: 0, + }); + const jsonOutput = mergedOptions.instances + ? { projects: {}, totals } + : { daily: [], totals }; + log(JSON.stringify(jsonOutput, null, 2)); } else { logger.warn('No Claude usage data found.'); } diff --git a/apps/ccusage/src/commands/session.ts b/apps/ccusage/src/commands/session.ts index a825063b..9440c3e3 100644 --- a/apps/ccusage/src/commands/session.ts +++ b/apps/ccusage/src/commands/session.ts @@ -76,7 +76,14 @@ export const sessionCommand = define({ if (sessionData.length === 0) { if (useJson) { - log(JSON.stringify([])); + const totals = createTotalsObject({ + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + totalCost: 0, + }); + log(JSON.stringify({ sessions: [], totals }, null, 2)); } else { logger.warn('No Claude usage data found.'); } diff --git a/apps/codex/src/commands/daily.ts b/apps/codex/src/commands/daily.ts index 33a70c02..c66b675f 100644 --- a/apps/codex/src/commands/daily.ts +++ b/apps/codex/src/commands/daily.ts @@ -48,7 +48,20 @@ export const dailyCommand = define({ } if (events.length === 0) { - log(jsonOutput ? JSON.stringify({ daily: [], totals: null }) : 'No Codex usage data found.'); + if (jsonOutput) { + const emptyTotals = { + inputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + outputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 0, + totalCost: 0, + }; + log(JSON.stringify({ daily: [], totals: emptyTotals }, null, 2)); + } else { + log('No Codex usage data found.'); + } return; } @@ -65,11 +78,20 @@ export const dailyCommand = define({ }); if (rows.length === 0) { - log( - jsonOutput - ? JSON.stringify({ daily: [], totals: null }) - : 'No Codex usage data found for provided filters.', - ); + if (jsonOutput) { + const emptyTotals = { + inputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + outputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 0, + totalCost: 0, + }; + log(JSON.stringify({ daily: [], totals: emptyTotals }, null, 2)); + } else { + log('No Codex usage data found for provided filters.'); + } return; } diff --git a/apps/codex/src/commands/monthly.ts b/apps/codex/src/commands/monthly.ts index e3f00bad..af0fcac5 100644 --- a/apps/codex/src/commands/monthly.ts +++ b/apps/codex/src/commands/monthly.ts @@ -48,9 +48,20 @@ export const monthlyCommand = define({ } if (events.length === 0) { - log( - jsonOutput ? JSON.stringify({ monthly: [], totals: null }) : 'No Codex usage data found.', - ); + if (jsonOutput) { + const emptyTotals = { + inputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + outputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 0, + totalCost: 0, + }; + log(JSON.stringify({ monthly: [], totals: emptyTotals }, null, 2)); + } else { + log('No Codex usage data found.'); + } return; } @@ -69,7 +80,22 @@ export const monthlyCommand = define({ if (rows.length === 0) { log( jsonOutput - ? JSON.stringify({ monthly: [], totals: null }) + ? JSON.stringify( + { + monthly: [], + totals: { + inputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + outputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 0, + totalCost: 0, + }, + }, + null, + 2, + ) : 'No Codex usage data found for provided filters.', ); return; diff --git a/apps/codex/src/commands/session.ts b/apps/codex/src/commands/session.ts index 7db7a6f1..f0846b9b 100644 --- a/apps/codex/src/commands/session.ts +++ b/apps/codex/src/commands/session.ts @@ -53,9 +53,20 @@ export const sessionCommand = define({ } if (events.length === 0) { - log( - jsonOutput ? JSON.stringify({ sessions: [], totals: null }) : 'No Codex usage data found.', - ); + if (jsonOutput) { + const emptyTotals = { + inputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + outputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 0, + totalCost: 0, + }; + log(JSON.stringify({ sessions: [], totals: emptyTotals }, null, 2)); + } else { + log('No Codex usage data found.'); + } return; } @@ -72,11 +83,20 @@ export const sessionCommand = define({ }); if (rows.length === 0) { - log( - jsonOutput - ? JSON.stringify({ sessions: [], totals: null }) - : 'No Codex usage data found for provided filters.', - ); + if (jsonOutput) { + const emptyTotals = { + inputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + outputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 0, + totalCost: 0, + }; + log(JSON.stringify({ sessions: [], totals: emptyTotals }, null, 2)); + } else { + log('No Codex usage data found for provided filters.'); + } return; } diff --git a/apps/opencode/src/commands/daily.ts b/apps/opencode/src/commands/daily.ts index 11268c76..d9d6cf4a 100644 --- a/apps/opencode/src/commands/daily.ts +++ b/apps/opencode/src/commands/daily.ts @@ -39,11 +39,21 @@ export const dailyCommand = define({ } if (entries.length === 0) { - const output = jsonOutput - ? JSON.stringify({ daily: [], totals: null }) - : 'No OpenCode usage data found.'; - // eslint-disable-next-line no-console - console.log(output); + if (jsonOutput) { + const emptyTotals = { + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + totalTokens: 0, + totalCost: 0, + }; + // eslint-disable-next-line no-console + console.log(JSON.stringify({ daily: [], totals: emptyTotals }, null, 2)); + } else { + // eslint-disable-next-line no-console + console.log('No OpenCode usage data found.'); + } return; } diff --git a/apps/opencode/src/commands/monthly.ts b/apps/opencode/src/commands/monthly.ts index c56996aa..4e76eca5 100644 --- a/apps/opencode/src/commands/monthly.ts +++ b/apps/opencode/src/commands/monthly.ts @@ -39,11 +39,21 @@ export const monthlyCommand = define({ } if (entries.length === 0) { - const output = jsonOutput - ? JSON.stringify({ monthly: [], totals: null }) - : 'No OpenCode usage data found.'; - // eslint-disable-next-line no-console - console.log(output); + if (jsonOutput) { + const emptyTotals = { + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + totalTokens: 0, + totalCost: 0, + }; + // eslint-disable-next-line no-console + console.log(JSON.stringify({ monthly: [], totals: emptyTotals }, null, 2)); + } else { + // eslint-disable-next-line no-console + console.log('No OpenCode usage data found.'); + } return; } diff --git a/apps/opencode/src/commands/session.ts b/apps/opencode/src/commands/session.ts index 3a6c7c1f..2e853e39 100644 --- a/apps/opencode/src/commands/session.ts +++ b/apps/opencode/src/commands/session.ts @@ -41,11 +41,21 @@ export const sessionCommand = define({ const sessionMetadataMap = await loadOpenCodeSessions(); if (entries.length === 0) { - const output = jsonOutput - ? JSON.stringify({ sessions: [], totals: null }) - : 'No OpenCode usage data found.'; - // eslint-disable-next-line no-console - console.log(output); + if (jsonOutput) { + const emptyTotals = { + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + totalTokens: 0, + totalCost: 0, + }; + // eslint-disable-next-line no-console + console.log(JSON.stringify({ sessions: [], totals: emptyTotals }, null, 2)); + } else { + // eslint-disable-next-line no-console + console.log('No OpenCode usage data found.'); + } return; } diff --git a/apps/opencode/src/commands/weekly.ts b/apps/opencode/src/commands/weekly.ts index 67c511df..6d9e363e 100644 --- a/apps/opencode/src/commands/weekly.ts +++ b/apps/opencode/src/commands/weekly.ts @@ -64,11 +64,21 @@ export const weeklyCommand = define({ } if (entries.length === 0) { - const output = jsonOutput - ? JSON.stringify({ weekly: [], totals: null }) - : 'No OpenCode usage data found.'; - // eslint-disable-next-line no-console - console.log(output); + if (jsonOutput) { + const emptyTotals = { + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + totalTokens: 0, + totalCost: 0, + }; + // eslint-disable-next-line no-console + console.log(JSON.stringify({ weekly: [], totals: emptyTotals }, null, 2)); + } else { + // eslint-disable-next-line no-console + console.log('No OpenCode usage data found.'); + } return; } diff --git a/apps/pi/src/commands/daily.ts b/apps/pi/src/commands/daily.ts index 8494ca00..6b89ab33 100644 --- a/apps/pi/src/commands/daily.ts +++ b/apps/pi/src/commands/daily.ts @@ -62,7 +62,15 @@ export const dailyCommand = define({ if (piData.length === 0) { if (ctx.values.json) { - log(JSON.stringify([])); + const totals = { + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + totalTokens: 0, + totalCost: 0, + }; + log(JSON.stringify({ daily: [], totals }, null, 2)); } else { logger.warn('No usage data found.'); } diff --git a/apps/pi/src/commands/monthly.ts b/apps/pi/src/commands/monthly.ts index 5775ed91..96f97806 100644 --- a/apps/pi/src/commands/monthly.ts +++ b/apps/pi/src/commands/monthly.ts @@ -62,7 +62,15 @@ export const monthlyCommand = define({ if (piData.length === 0) { if (ctx.values.json) { - log(JSON.stringify([])); + const emptyTotals = { + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + totalTokens: 0, + totalCost: 0, + }; + log(JSON.stringify({ monthly: [], totals: emptyTotals }, null, 2)); } else { logger.warn('No usage data found.'); } diff --git a/apps/pi/src/commands/session.ts b/apps/pi/src/commands/session.ts index 431d357b..7749113b 100644 --- a/apps/pi/src/commands/session.ts +++ b/apps/pi/src/commands/session.ts @@ -63,7 +63,15 @@ export const sessionCommand = define({ if (piData.length === 0) { if (ctx.values.json) { - log(JSON.stringify([])); + const totals = { + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + totalTokens: 0, + totalCost: 0, + }; + log(JSON.stringify({ sessions: [], totals }, null, 2)); } else { logger.warn('No usage data found.'); } From d6a8b29a619a4c78ae430bad196ce5a7be1f21df Mon Sep 17 00:00:00 2001 From: Vedang Manerikar Date: Mon, 2 Feb 2026 19:53:00 +0530 Subject: [PATCH 09/22] fix(codex): skip zero-usage deltas Refs: #830 --- apps/codex/src/data-loader.ts | 42 ++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/apps/codex/src/data-loader.ts b/apps/codex/src/data-loader.ts index c6ae79b9..b1a5ed63 100644 --- a/apps/codex/src/data-loader.ts +++ b/apps/codex/src/data-loader.ts @@ -309,7 +309,8 @@ export async function loadTokenUsageEvents(options: LoadOptions = {}): Promise { + await using fixture = await createFixture({ + sessions: { + 'zero-usage.jsonl': [ + JSON.stringify({ + timestamp: '2025-09-20T10:00:00.000Z', + type: 'turn_context', + payload: { + model: 'gpt-5', + }, + }), + JSON.stringify({ + timestamp: '2025-09-20T10:00:05.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + info: { + last_token_usage: { + input_tokens: 0, + cached_input_tokens: 0, + output_tokens: 0, + reasoning_output_tokens: 0, + total_tokens: 0, + }, + model: 'gpt-5', + }, + }, + }), + ].join('\n'), + }, + }); + + const { events } = await loadTokenUsageEvents({ + sessionDirs: [fixture.getPath('sessions')], + }); + + expect(events).toHaveLength(0); + }); + it('falls back to legacy model when metadata is missing entirely', async () => { await using fixture = await createFixture({ sessions: { From 363059937972010d2fcc689f353723fdffb64507 Mon Sep 17 00:00:00 2001 From: Vedang Manerikar Date: Mon, 2 Feb 2026 19:57:11 +0530 Subject: [PATCH 10/22] fix(ccusage): apply jq to empty session output Refs: #832 --- apps/ccusage/src/commands/session.ts | 119 ++++++++++++++++++++------- 1 file changed, 88 insertions(+), 31 deletions(-) diff --git a/apps/ccusage/src/commands/session.ts b/apps/ccusage/src/commands/session.ts index 9440c3e3..88b99195 100644 --- a/apps/ccusage/src/commands/session.ts +++ b/apps/ccusage/src/commands/session.ts @@ -1,4 +1,5 @@ import type { UsageReportConfig } from '@ccusage/terminal/table'; +import type { SessionUsage } from '../data-loader.ts'; import process from 'node:process'; import { addEmptySeparatorRow, @@ -23,6 +24,59 @@ import { handleSessionIdLookup } from './_session_id.ts'; // eslint-disable-next-line ts/no-unused-vars const { order: _, ...sharedArgs } = sharedCommandConfig.args; +type SessionJsonOutput = { + sessions: Array<{ + sessionId: string; + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + totalTokens: number; + totalCost: number; + lastActivity: string; + modelsUsed: string[]; + modelBreakdowns: SessionUsage['modelBreakdowns']; + projectPath: string; + }>; + totals: ReturnType; +}; + +function buildSessionJsonOutput( + sessionData: SessionUsage[], + totals: Parameters[0], +): SessionJsonOutput { + return { + sessions: sessionData.map((data) => ({ + sessionId: data.sessionId, + inputTokens: data.inputTokens, + outputTokens: data.outputTokens, + cacheCreationTokens: data.cacheCreationTokens, + cacheReadTokens: data.cacheReadTokens, + totalTokens: getTotalTokens(data), + totalCost: data.totalCost, + lastActivity: data.lastActivity, + modelsUsed: data.modelsUsed, + modelBreakdowns: data.modelBreakdowns, + projectPath: data.projectPath, + })), + totals: createTotalsObject(totals), + }; +} + +async function renderSessionJsonOutput( + sessionData: SessionUsage[], + totals: Parameters[0], + jq: string | null | undefined, +): Result.ResultAsync { + const jsonOutput = buildSessionJsonOutput(sessionData, totals); + + if (jq != null) { + return processWithJq(jsonOutput, jq); + } + + return Result.succeed(JSON.stringify(jsonOutput, null, 2)); +} + export const sessionCommand = define({ name: 'session', description: 'Show usage report grouped by conversation session', @@ -76,14 +130,19 @@ export const sessionCommand = define({ if (sessionData.length === 0) { if (useJson) { - const totals = createTotalsObject({ + const totals = { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, totalCost: 0, - }); - log(JSON.stringify({ sessions: [], totals }, null, 2)); + }; + const jsonResult = await renderSessionJsonOutput([], totals, ctx.values.jq); + if (Result.isFailure(jsonResult)) { + logger.error(jsonResult.error.message); + process.exit(1); + } + log(jsonResult.value); } else { logger.warn('No Claude usage data found.'); } @@ -100,35 +159,12 @@ export const sessionCommand = define({ } if (useJson) { - // Output JSON format - const jsonOutput = { - sessions: sessionData.map((data) => ({ - sessionId: data.sessionId, - inputTokens: data.inputTokens, - outputTokens: data.outputTokens, - cacheCreationTokens: data.cacheCreationTokens, - cacheReadTokens: data.cacheReadTokens, - totalTokens: getTotalTokens(data), - totalCost: data.totalCost, - lastActivity: data.lastActivity, - modelsUsed: data.modelsUsed, - modelBreakdowns: data.modelBreakdowns, - projectPath: data.projectPath, - })), - totals: createTotalsObject(totals), - }; - - // Process with jq if specified - if (ctx.values.jq != null) { - const jqResult = await processWithJq(jsonOutput, ctx.values.jq); - if (Result.isFailure(jqResult)) { - logger.error(jqResult.error.message); - process.exit(1); - } - log(jqResult.value); - } else { - log(JSON.stringify(jsonOutput, null, 2)); + const jsonResult = await renderSessionJsonOutput(sessionData, totals, ctx.values.jq); + if (Result.isFailure(jsonResult)) { + logger.error(jsonResult.error.message); + process.exit(1); } + log(jsonResult.value); } else { // Print header logger.box('Claude Code Token Usage Report - By Session'); @@ -199,5 +235,26 @@ export const sessionCommand = define({ }, }); +if (import.meta.vitest != null) { + describe('renderSessionJsonOutput', () => { + it('applies jq to empty session payloads', async () => { + const result = await renderSessionJsonOutput( + [], + { + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + totalCost: 0, + }, + '.totals.totalTokens', + ); + + const output = Result.unwrap(result); + expect(output).toBe('0'); + }); + }); +} + // Note: Tests for --id functionality are covered by the existing loadSessionUsageById tests // in data-loader.ts, since this command directly uses that function. From b1128c6e6f3fb658974e2dc021d4b58622eb0948 Mon Sep 17 00:00:00 2001 From: Vedang Manerikar Date: Mon, 2 Feb 2026 20:00:02 +0530 Subject: [PATCH 11/22] fix(codex): use totalCost in monthly table Refs: #831 --- apps/codex/src/commands/monthly.ts | 89 ++++++++++++++++++++++++------ 1 file changed, 73 insertions(+), 16 deletions(-) diff --git a/apps/codex/src/commands/monthly.ts b/apps/codex/src/commands/monthly.ts index af0fcac5..ae43643e 100644 --- a/apps/codex/src/commands/monthly.ts +++ b/apps/codex/src/commands/monthly.ts @@ -1,3 +1,4 @@ +import type { MonthlyReportRow } from '../_types.ts'; import process from 'node:process'; import { addEmptySeparatorRow, @@ -20,6 +21,39 @@ import { CodexPricingSource } from '../pricing.ts'; const TABLE_COLUMN_COUNT = 8; +type MonthlyDisplayTotals = { + inputTokens: number; + outputTokens: number; + reasoningTokens: number; + cacheReadTokens: number; + totalTokens: number; + totalCost: number; +}; + +function createMonthlyDisplayTotals(): MonthlyDisplayTotals { + return { + inputTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + cacheReadTokens: 0, + totalTokens: 0, + totalCost: 0, + }; +} + +function updateMonthlyDisplayTotals( + totals: MonthlyDisplayTotals, + row: MonthlyReportRow, + split: ReturnType, +): void { + totals.inputTokens += split.inputTokens; + totals.outputTokens += split.outputTokens; + totals.reasoningTokens += split.reasoningTokens; + totals.cacheReadTokens += split.cacheReadTokens; + totals.totalTokens += row.totalTokens; + totals.totalCost += row.totalCost; +} + export const monthlyCommand = define({ name: 'monthly', description: 'Show Codex token usage grouped by month', @@ -161,23 +195,11 @@ export const monthlyCommand = define({ dateFormatter: (dateStr: string) => formatDateCompact(dateStr), }); - const totalsForDisplay = { - inputTokens: 0, - outputTokens: 0, - reasoningTokens: 0, - cacheReadTokens: 0, - totalTokens: 0, - costUSD: 0, - }; + const totalsForDisplay = createMonthlyDisplayTotals(); for (const row of rows) { const split = splitUsageTokens(row); - totalsForDisplay.inputTokens += split.inputTokens; - totalsForDisplay.outputTokens += split.outputTokens; - totalsForDisplay.reasoningTokens += split.reasoningTokens; - totalsForDisplay.cacheReadTokens += split.cacheReadTokens; - totalsForDisplay.totalTokens += row.totalTokens; - totalsForDisplay.costUSD += row.costUSD; + updateMonthlyDisplayTotals(totalsForDisplay, row, split); table.push([ row.month, @@ -187,7 +209,7 @@ export const monthlyCommand = define({ formatNumber(split.reasoningTokens), formatNumber(split.cacheReadTokens), formatNumber(row.totalTokens), - formatCurrency(row.costUSD), + formatCurrency(row.totalCost), ]); } @@ -200,7 +222,7 @@ export const monthlyCommand = define({ pc.yellow(formatNumber(totalsForDisplay.reasoningTokens)), pc.yellow(formatNumber(totalsForDisplay.cacheReadTokens)), pc.yellow(formatNumber(totalsForDisplay.totalTokens)), - pc.yellow(formatCurrency(totalsForDisplay.costUSD)), + pc.yellow(formatCurrency(totalsForDisplay.totalCost)), ]); log(table.toString()); @@ -214,3 +236,38 @@ export const monthlyCommand = define({ } }, }); + +if (import.meta.vitest != null) { + describe('updateMonthlyDisplayTotals', () => { + it('tracks totalCost instead of legacy costUSD', () => { + const row: MonthlyReportRow = { + month: '2025-01', + inputTokens: 100, + cacheCreationTokens: 0, + cacheReadTokens: 0, + outputTokens: 50, + reasoningOutputTokens: 10, + totalTokens: 150, + totalCost: 1.5, + costUSD: 99, + models: { + 'gpt-5': { + inputTokens: 100, + cacheCreationTokens: 0, + cacheReadTokens: 0, + outputTokens: 50, + reasoningOutputTokens: 10, + totalTokens: 150, + }, + }, + }; + + const totals = createMonthlyDisplayTotals(); + const split = splitUsageTokens(row); + updateMonthlyDisplayTotals(totals, row, split); + + expect(totals.totalCost).toBe(1.5); + expect(totals.totalCost).not.toBe(row.costUSD); + }); + }); +} From c2cea10ade12bc21807e618236d267bc50c82e5f Mon Sep 17 00:00:00 2001 From: Vedang Manerikar Date: Mon, 2 Feb 2026 20:04:35 +0530 Subject: [PATCH 12/22] fix(mcp): harden codex JSON parsing Refs: #830 Refs: #831 --- apps/mcp/package.json | 1 + apps/mcp/src/codex.ts | 43 +++++++++++++++++++++++++++++++++++++------ pnpm-lock.yaml | 3 +++ 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/apps/mcp/package.json b/apps/mcp/package.json index 9ff89e82..119ac140 100644 --- a/apps/mcp/package.json +++ b/apps/mcp/package.json @@ -65,6 +65,7 @@ }, "devDependencies": { "@ccusage/internal": "workspace:*", + "@praha/byethrow": "catalog:runtime", "@ryoppippi/eslint-config": "catalog:lint", "@typescript/native-preview": "catalog:types", "clean-pkg-json": "catalog:release", diff --git a/apps/mcp/src/codex.ts b/apps/mcp/src/codex.ts index d3855e3c..0c0bfb7f 100644 --- a/apps/mcp/src/codex.ts +++ b/apps/mcp/src/codex.ts @@ -1,8 +1,9 @@ import type { CliInvocation } from './cli-utils.ts'; import process from 'node:process'; +import { Result } from '@praha/byethrow'; import { createFixture } from 'fs-fixture'; import { z } from 'zod'; -import { createCliInvocation, executeCliCommand, resolveBinaryPath } from './cli-utils.ts'; +import * as cliUtils from './cli-utils.ts'; const codexModelUsageSchema = z.object({ inputTokens: z.number(), @@ -88,8 +89,8 @@ function getCodexInvocation(): CliInvocation { return cachedCodexInvocation; } - const entryPath = resolveBinaryPath('@ccusage/codex', 'ccusage-codex'); - cachedCodexInvocation = createCliInvocation(entryPath); + const entryPath = cliUtils.resolveBinaryPath('@ccusage/codex', 'ccusage-codex'); + cachedCodexInvocation = cliUtils.createCliInvocation(entryPath); return cachedCodexInvocation; } @@ -122,14 +123,28 @@ async function runCodexCliJson( cliArgs.push('--no-offline'); } - return executeCliCommand(executable, cliArgs, { + return cliUtils.executeCliCommand(executable, cliArgs, { // Keep default log level to allow JSON output }); } +function parseCodexJsonOutput(raw: string, command: 'daily' | 'monthly'): unknown { + const parseResult = Result.try({ + try: () => JSON.parse(raw) as unknown, + catch: (error) => error, + }); + const parsed = parseResult(); + if (Result.isFailure(parsed)) { + const errorMessage = + parsed.error instanceof Error ? parsed.error.message : String(parsed.error); + throw new Error(`Failed to parse Codex ${command} output: ${errorMessage}. Raw output: ${raw}`); + } + return parsed.value; +} + export async function getCodexDaily(parameters: z.infer) { const raw = await runCodexCliJson('daily', parameters); - const parsed = JSON.parse(raw) as unknown; + const parsed = parseCodexJsonOutput(raw, 'daily'); const normalized = codexDailyResponseSchema.parse(parsed); if (Array.isArray(normalized)) { return { @@ -150,11 +165,17 @@ export async function getCodexDaily(parameters: z.infer) { const raw = await runCodexCliJson('monthly', parameters); - return codexMonthlyResponseSchema.parse(JSON.parse(raw)); + const parsed = parseCodexJsonOutput(raw, 'monthly'); + return codexMonthlyResponseSchema.parse(parsed); } if (import.meta.vitest != null) { describe('getCodexDaily/getCodexMonthly', () => { + afterEach(() => { + vi.restoreAllMocks(); + cachedCodexInvocation = null; + }); + it('parses empty daily output (no data)', async () => { const originalCodexHome = process.env.CODEX_HOME; await using fixture = await createFixture({ sessions: {} }); @@ -208,5 +229,15 @@ if (import.meta.vitest != null) { } } }); + + it('throws a helpful error for invalid daily JSON output', async () => { + vi.spyOn(cliUtils, 'executeCliCommand').mockResolvedValue('not-json'); + await expect(getCodexDaily({})).rejects.toThrow('Failed to parse Codex daily output'); + }); + + it('throws a helpful error for invalid monthly JSON output', async () => { + vi.spyOn(cliUtils, 'executeCliCommand').mockResolvedValue('not-json'); + await expect(getCodexMonthly({})).rejects.toThrow('Failed to parse Codex monthly output'); + }); }); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d33a20bd..a394af55 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -490,6 +490,9 @@ importers: '@ccusage/internal': specifier: workspace:* version: link:../../packages/internal + '@praha/byethrow': + specifier: catalog:runtime + version: 0.6.3 '@ryoppippi/eslint-config': specifier: catalog:lint version: 0.4.0(@vue/compiler-sfc@3.5.21)(eslint-plugin-format@1.0.2(eslint@9.35.0(jiti@2.6.1)))(eslint@9.35.0(jiti@2.6.1))(typescript@5.9.2)(vitest@4.0.15(@types/node@24.5.1)(happy-dom@16.8.1)(jiti@2.6.1)(yaml@2.8.1)) From 8b62e68228fd0034714c27a7ee59d94b1664f98b Mon Sep 17 00:00:00 2001 From: Vedang Manerikar Date: Mon, 2 Feb 2026 20:07:39 +0530 Subject: [PATCH 13/22] fix(opencode): validate filter dates in UTC Refs: #801 --- apps/opencode/src/_shared-args.ts | 19 ++++++++++++++++++- apps/opencode/src/date-utils.ts | 26 ++++++++++++++------------ 2 files changed, 32 insertions(+), 13 deletions(-) diff --git a/apps/opencode/src/_shared-args.ts b/apps/opencode/src/_shared-args.ts index 077870e8..07a8a196 100644 --- a/apps/opencode/src/_shared-args.ts +++ b/apps/opencode/src/_shared-args.ts @@ -1,5 +1,6 @@ import type { Args } from 'gunshi'; import * as v from 'valibot'; +import { parseYYYYMMDD } from './date-utils.ts'; /** * Filter date schema for YYYYMMDD format (e.g., 20250125) @@ -17,7 +18,11 @@ export const filterDateSchema = v.pipe( * @returns Validated date string */ function parseDateArg(value: string): string { - return v.parse(filterDateSchema, value); + const parsed = v.parse(filterDateSchema, value); + if (parseYYYYMMDD(parsed) == null) { + throw new Error('Date must be a valid calendar date (YYYYMMDD).'); + } + return parsed; } /** @@ -46,3 +51,15 @@ export const sharedArgs = { description: 'Force compact table mode', }, } as const satisfies Args; + +if (import.meta.vitest != null) { + describe('parseDateArg', () => { + it('accepts valid calendar dates', () => { + expect(parseDateArg('20250125')).toBe('20250125'); + }); + + it('rejects invalid calendar dates', () => { + expect(() => parseDateArg('20240230')).toThrow('Date must be a valid calendar date'); + }); + }); +} diff --git a/apps/opencode/src/date-utils.ts b/apps/opencode/src/date-utils.ts index 8bc1149d..4472af76 100644 --- a/apps/opencode/src/date-utils.ts +++ b/apps/opencode/src/date-utils.ts @@ -12,7 +12,7 @@ * @param dateStr - Date string in YYYYMMDD format * @returns Date object or null if invalid */ -function parseYYYYMMDD(dateStr: string): Date | null { +export function parseYYYYMMDD(dateStr: string): Date | null { if (dateStr.length !== 8) { return null; } @@ -33,9 +33,9 @@ function parseYYYYMMDD(dateStr: string): Date | null { return null; } - const date = new Date(year, month, day); + const date = new Date(Date.UTC(year, month, day)); // Check if the date is valid (e.g., not Feb 30) - if (date.getFullYear() !== year || date.getMonth() !== month || date.getDate() !== day) { + if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month || date.getUTCDate() !== day) { return null; } @@ -51,7 +51,9 @@ function parseYYYYMMDD(dateStr: string): Date | null { */ export function isDateInRange(date: Date, since: string | null, until: string | null): boolean { // Normalize date to midnight UTC for consistent comparison - const normalizedDate = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); + const normalizedDate = new Date( + Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()), + ); if (since != null) { const sinceDate = parseYYYYMMDD(since); @@ -59,7 +61,7 @@ export function isDateInRange(date: Date, since: string | null, until: string | return false; } const normalizedSince = new Date( - Date.UTC(sinceDate.getFullYear(), sinceDate.getMonth(), sinceDate.getDate()), + Date.UTC(sinceDate.getUTCFullYear(), sinceDate.getUTCMonth(), sinceDate.getUTCDate()), ); if (normalizedDate < normalizedSince) { return false; @@ -72,7 +74,7 @@ export function isDateInRange(date: Date, since: string | null, until: string | return false; } const normalizedUntil = new Date( - Date.UTC(untilDate.getFullYear(), untilDate.getMonth(), untilDate.getDate()), + Date.UTC(untilDate.getUTCFullYear(), untilDate.getUTCMonth(), untilDate.getUTCDate()), ); if (normalizedDate > normalizedUntil) { return false; @@ -90,17 +92,17 @@ if (import.meta.vitest != null) { it('should parse valid dates', () => { const date = parseYYYYMMDD('20250128'); expect(date).not.toBeNull(); - expect(date?.getFullYear()).toBe(2025); - expect(date?.getMonth()).toBe(0); // January - expect(date?.getDate()).toBe(28); + expect(date?.getUTCFullYear()).toBe(2025); + expect(date?.getUTCMonth()).toBe(0); // January + expect(date?.getUTCDate()).toBe(28); }); it('should handle leap years', () => { const date = parseYYYYMMDD('20240229'); expect(date).not.toBeNull(); - expect(date?.getFullYear()).toBe(2024); - expect(date?.getMonth()).toBe(1); // February - expect(date?.getDate()).toBe(29); + expect(date?.getUTCFullYear()).toBe(2024); + expect(date?.getUTCMonth()).toBe(1); // February + expect(date?.getUTCDate()).toBe(29); }); it('should reject invalid dates', () => { From c073b6490c7547565f8d9e7fc2429bc2a4363950 Mon Sep 17 00:00:00 2001 From: Vedang Manerikar Date: Mon, 2 Feb 2026 20:09:13 +0530 Subject: [PATCH 14/22] refactor(pi): simplify monthly totalTokens calculation Refs: #830 --- apps/pi/src/commands/monthly.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/pi/src/commands/monthly.ts b/apps/pi/src/commands/monthly.ts index 96f97806..e45e29ac 100644 --- a/apps/pi/src/commands/monthly.ts +++ b/apps/pi/src/commands/monthly.ts @@ -91,11 +91,15 @@ export const monthlyCommand = define({ totals.outputTokens += d.outputTokens; totals.cacheCreationTokens += d.cacheCreationTokens; totals.cacheReadTokens += d.cacheReadTokens; - totals.totalTokens += - d.inputTokens + d.outputTokens + d.cacheCreationTokens + d.cacheReadTokens; totals.totalCost += d.totalCost; } + totals.totalTokens = + totals.inputTokens + + totals.outputTokens + + totals.cacheCreationTokens + + totals.cacheReadTokens; + if (ctx.values.json) { log( JSON.stringify( From 19b85e085f7dbf6b3cd3da8e00b902fb57c10ad5 Mon Sep 17 00:00:00 2001 From: Vedang Manerikar Date: Mon, 2 Feb 2026 20:11:48 +0530 Subject: [PATCH 15/22] docs: add helper docstrings for review Refs: #830 --- apps/ccusage/src/commands/session.ts | 6 ++++++ apps/codex/src/commands/monthly.ts | 6 ++++++ apps/mcp/src/codex.ts | 3 +++ 3 files changed, 15 insertions(+) diff --git a/apps/ccusage/src/commands/session.ts b/apps/ccusage/src/commands/session.ts index 88b99195..3c423792 100644 --- a/apps/ccusage/src/commands/session.ts +++ b/apps/ccusage/src/commands/session.ts @@ -41,6 +41,9 @@ type SessionJsonOutput = { totals: ReturnType; }; +/** + * Build the JSON output payload for session usage reports. + */ function buildSessionJsonOutput( sessionData: SessionUsage[], totals: Parameters[0], @@ -63,6 +66,9 @@ function buildSessionJsonOutput( }; } +/** + * Render session usage JSON output, applying jq when requested. + */ async function renderSessionJsonOutput( sessionData: SessionUsage[], totals: Parameters[0], diff --git a/apps/codex/src/commands/monthly.ts b/apps/codex/src/commands/monthly.ts index ae43643e..d3cadfdd 100644 --- a/apps/codex/src/commands/monthly.ts +++ b/apps/codex/src/commands/monthly.ts @@ -30,6 +30,9 @@ type MonthlyDisplayTotals = { totalCost: number; }; +/** + * Create a zeroed display totals accumulator for monthly table output. + */ function createMonthlyDisplayTotals(): MonthlyDisplayTotals { return { inputTokens: 0, @@ -41,6 +44,9 @@ function createMonthlyDisplayTotals(): MonthlyDisplayTotals { }; } +/** + * Update display totals using a pre-split row for monthly output. + */ function updateMonthlyDisplayTotals( totals: MonthlyDisplayTotals, row: MonthlyReportRow, diff --git a/apps/mcp/src/codex.ts b/apps/mcp/src/codex.ts index 0c0bfb7f..2bb2554f 100644 --- a/apps/mcp/src/codex.ts +++ b/apps/mcp/src/codex.ts @@ -128,6 +128,9 @@ async function runCodexCliJson( }); } +/** + * Parse Codex CLI JSON output with a helpful error when parsing fails. + */ function parseCodexJsonOutput(raw: string, command: 'daily' | 'monthly'): unknown { const parseResult = Result.try({ try: () => JSON.parse(raw) as unknown, From f37de3f90aa620c1474afcc12881a02a331770f2 Mon Sep 17 00:00:00 2001 From: Vedang Manerikar Date: Thu, 5 Feb 2026 11:27:04 +0530 Subject: [PATCH 16/22] fix(opencode): align cache input token fields Refs: #830 --- apps/opencode/src/commands/daily.ts | 35 ++++++++--------- apps/opencode/src/commands/monthly.ts | 35 ++++++++--------- apps/opencode/src/commands/session.ts | 55 ++++++++++++++------------- apps/opencode/src/commands/weekly.ts | 35 ++++++++--------- 4 files changed, 82 insertions(+), 78 deletions(-) diff --git a/apps/opencode/src/commands/daily.ts b/apps/opencode/src/commands/daily.ts index d9d6cf4a..ca3d1a7b 100644 --- a/apps/opencode/src/commands/daily.ts +++ b/apps/opencode/src/commands/daily.ts @@ -43,8 +43,8 @@ export const dailyCommand = define({ const emptyTotals = { inputTokens: 0, outputTokens: 0, - cacheCreationTokens: 0, - cacheReadTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, totalTokens: 0, totalCost: 0, }; @@ -65,8 +65,8 @@ export const dailyCommand = define({ date: string; inputTokens: number; outputTokens: number; - cacheCreationTokens: number; - cacheReadTokens: number; + cacheCreationInputTokens: number; + cacheReadInputTokens: number; totalTokens: number; totalCost: number; modelsUsed: string[]; @@ -75,28 +75,29 @@ export const dailyCommand = define({ for (const [date, dayEntries] of Object.entries(entriesByDate)) { let inputTokens = 0; let outputTokens = 0; - let cacheCreationTokens = 0; - let cacheReadTokens = 0; + let cacheCreationInputTokens = 0; + let cacheReadInputTokens = 0; let totalCost = 0; const modelsSet = new Set(); for (const entry of dayEntries) { inputTokens += entry.usage.inputTokens; outputTokens += entry.usage.outputTokens; - cacheCreationTokens += entry.usage.cacheCreationInputTokens; - cacheReadTokens += entry.usage.cacheReadInputTokens; + cacheCreationInputTokens += entry.usage.cacheCreationInputTokens; + cacheReadInputTokens += entry.usage.cacheReadInputTokens; totalCost += await calculateCostForEntry(entry, fetcher); modelsSet.add(entry.model); } - const totalTokens = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens; + const totalTokens = + inputTokens + outputTokens + cacheCreationInputTokens + cacheReadInputTokens; dailyData.push({ date, inputTokens, outputTokens, - cacheCreationTokens, - cacheReadTokens, + cacheCreationInputTokens, + cacheReadInputTokens, totalTokens, totalCost, modelsUsed: Array.from(modelsSet), @@ -108,8 +109,8 @@ export const dailyCommand = define({ const totals = { inputTokens: dailyData.reduce((sum, d) => sum + d.inputTokens, 0), outputTokens: dailyData.reduce((sum, d) => sum + d.outputTokens, 0), - cacheCreationTokens: dailyData.reduce((sum, d) => sum + d.cacheCreationTokens, 0), - cacheReadTokens: dailyData.reduce((sum, d) => sum + d.cacheReadTokens, 0), + cacheCreationInputTokens: dailyData.reduce((sum, d) => sum + d.cacheCreationInputTokens, 0), + cacheReadInputTokens: dailyData.reduce((sum, d) => sum + d.cacheReadInputTokens, 0), totalTokens: dailyData.reduce((sum, d) => sum + d.totalTokens, 0), totalCost: dailyData.reduce((sum, d) => sum + d.totalCost, 0), }; @@ -158,8 +159,8 @@ export const dailyCommand = define({ formatModelsDisplayMultiline(data.modelsUsed), formatNumber(data.inputTokens), formatNumber(data.outputTokens), - formatNumber(data.cacheCreationTokens), - formatNumber(data.cacheReadTokens), + formatNumber(data.cacheCreationInputTokens), + formatNumber(data.cacheReadInputTokens), formatNumber(data.totalTokens), formatCurrency(data.totalCost), ]); @@ -171,8 +172,8 @@ export const dailyCommand = define({ '', pc.yellow(formatNumber(totals.inputTokens)), pc.yellow(formatNumber(totals.outputTokens)), - pc.yellow(formatNumber(totals.cacheCreationTokens)), - pc.yellow(formatNumber(totals.cacheReadTokens)), + pc.yellow(formatNumber(totals.cacheCreationInputTokens)), + pc.yellow(formatNumber(totals.cacheReadInputTokens)), pc.yellow(formatNumber(totals.totalTokens)), pc.yellow(formatCurrency(totals.totalCost)), ]); diff --git a/apps/opencode/src/commands/monthly.ts b/apps/opencode/src/commands/monthly.ts index 4e76eca5..ee76330d 100644 --- a/apps/opencode/src/commands/monthly.ts +++ b/apps/opencode/src/commands/monthly.ts @@ -43,8 +43,8 @@ export const monthlyCommand = define({ const emptyTotals = { inputTokens: 0, outputTokens: 0, - cacheCreationTokens: 0, - cacheReadTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, totalTokens: 0, totalCost: 0, }; @@ -65,8 +65,8 @@ export const monthlyCommand = define({ month: string; inputTokens: number; outputTokens: number; - cacheCreationTokens: number; - cacheReadTokens: number; + cacheCreationInputTokens: number; + cacheReadInputTokens: number; totalTokens: number; totalCost: number; modelsUsed: string[]; @@ -75,28 +75,29 @@ export const monthlyCommand = define({ for (const [month, monthEntries] of Object.entries(entriesByMonth)) { let inputTokens = 0; let outputTokens = 0; - let cacheCreationTokens = 0; - let cacheReadTokens = 0; + let cacheCreationInputTokens = 0; + let cacheReadInputTokens = 0; let totalCost = 0; const modelsSet = new Set(); for (const entry of monthEntries) { inputTokens += entry.usage.inputTokens; outputTokens += entry.usage.outputTokens; - cacheCreationTokens += entry.usage.cacheCreationInputTokens; - cacheReadTokens += entry.usage.cacheReadInputTokens; + cacheCreationInputTokens += entry.usage.cacheCreationInputTokens; + cacheReadInputTokens += entry.usage.cacheReadInputTokens; totalCost += await calculateCostForEntry(entry, fetcher); modelsSet.add(entry.model); } - const totalTokens = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens; + const totalTokens = + inputTokens + outputTokens + cacheCreationInputTokens + cacheReadInputTokens; monthlyData.push({ month, inputTokens, outputTokens, - cacheCreationTokens, - cacheReadTokens, + cacheCreationInputTokens, + cacheReadInputTokens, totalTokens, totalCost, modelsUsed: Array.from(modelsSet), @@ -108,8 +109,8 @@ export const monthlyCommand = define({ const totals = { inputTokens: monthlyData.reduce((sum, d) => sum + d.inputTokens, 0), outputTokens: monthlyData.reduce((sum, d) => sum + d.outputTokens, 0), - cacheCreationTokens: monthlyData.reduce((sum, d) => sum + d.cacheCreationTokens, 0), - cacheReadTokens: monthlyData.reduce((sum, d) => sum + d.cacheReadTokens, 0), + cacheCreationInputTokens: monthlyData.reduce((sum, d) => sum + d.cacheCreationInputTokens, 0), + cacheReadInputTokens: monthlyData.reduce((sum, d) => sum + d.cacheReadInputTokens, 0), totalTokens: monthlyData.reduce((sum, d) => sum + d.totalTokens, 0), totalCost: monthlyData.reduce((sum, d) => sum + d.totalCost, 0), }; @@ -158,8 +159,8 @@ export const monthlyCommand = define({ formatModelsDisplayMultiline(data.modelsUsed), formatNumber(data.inputTokens), formatNumber(data.outputTokens), - formatNumber(data.cacheCreationTokens), - formatNumber(data.cacheReadTokens), + formatNumber(data.cacheCreationInputTokens), + formatNumber(data.cacheReadInputTokens), formatNumber(data.totalTokens), formatCurrency(data.totalCost), ]); @@ -171,8 +172,8 @@ export const monthlyCommand = define({ '', pc.yellow(formatNumber(totals.inputTokens)), pc.yellow(formatNumber(totals.outputTokens)), - pc.yellow(formatNumber(totals.cacheCreationTokens)), - pc.yellow(formatNumber(totals.cacheReadTokens)), + pc.yellow(formatNumber(totals.cacheCreationInputTokens)), + pc.yellow(formatNumber(totals.cacheReadInputTokens)), pc.yellow(formatNumber(totals.totalTokens)), pc.yellow(formatCurrency(totals.totalCost)), ]); diff --git a/apps/opencode/src/commands/session.ts b/apps/opencode/src/commands/session.ts index 2e853e39..f5cf7aa5 100644 --- a/apps/opencode/src/commands/session.ts +++ b/apps/opencode/src/commands/session.ts @@ -45,8 +45,8 @@ export const sessionCommand = define({ const emptyTotals = { inputTokens: 0, outputTokens: 0, - cacheCreationTokens: 0, - cacheReadTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, totalTokens: 0, totalCost: 0, }; @@ -69,8 +69,8 @@ export const sessionCommand = define({ parentID: string | null; inputTokens: number; outputTokens: number; - cacheCreationTokens: number; - cacheReadTokens: number; + cacheCreationInputTokens: number; + cacheReadInputTokens: number; totalTokens: number; totalCost: number; modelsUsed: string[]; @@ -82,8 +82,8 @@ export const sessionCommand = define({ for (const [sessionID, sessionEntries] of Object.entries(entriesBySession)) { let inputTokens = 0; let outputTokens = 0; - let cacheCreationTokens = 0; - let cacheReadTokens = 0; + let cacheCreationInputTokens = 0; + let cacheReadInputTokens = 0; let totalCost = 0; const modelsSet = new Set(); let lastActivity = sessionEntries[0]!.timestamp; @@ -91,8 +91,8 @@ export const sessionCommand = define({ for (const entry of sessionEntries) { inputTokens += entry.usage.inputTokens; outputTokens += entry.usage.outputTokens; - cacheCreationTokens += entry.usage.cacheCreationInputTokens; - cacheReadTokens += entry.usage.cacheReadInputTokens; + cacheCreationInputTokens += entry.usage.cacheCreationInputTokens; + cacheReadInputTokens += entry.usage.cacheReadInputTokens; totalCost += await calculateCostForEntry(entry, fetcher); modelsSet.add(entry.model); @@ -101,7 +101,8 @@ export const sessionCommand = define({ } } - const totalTokens = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens; + const totalTokens = + inputTokens + outputTokens + cacheCreationInputTokens + cacheReadInputTokens; const metadata = sessionMetadataMap.get(sessionID); @@ -111,8 +112,8 @@ export const sessionCommand = define({ parentID: metadata?.parentID ?? null, inputTokens, outputTokens, - cacheCreationTokens, - cacheReadTokens, + cacheCreationInputTokens, + cacheReadInputTokens, totalTokens, totalCost, modelsUsed: Array.from(modelsSet), @@ -125,8 +126,8 @@ export const sessionCommand = define({ const totals = { inputTokens: sessionData.reduce((sum, s) => sum + s.inputTokens, 0), outputTokens: sessionData.reduce((sum, s) => sum + s.outputTokens, 0), - cacheCreationTokens: sessionData.reduce((sum, s) => sum + s.cacheCreationTokens, 0), - cacheReadTokens: sessionData.reduce((sum, s) => sum + s.cacheReadTokens, 0), + cacheCreationInputTokens: sessionData.reduce((sum, s) => sum + s.cacheCreationInputTokens, 0), + cacheReadInputTokens: sessionData.reduce((sum, s) => sum + s.cacheReadInputTokens, 0), totalTokens: sessionData.reduce((sum, s) => sum + s.totalTokens, 0), totalCost: sessionData.reduce((sum, s) => sum + s.totalCost, 0), }; @@ -184,8 +185,8 @@ export const sessionCommand = define({ formatModelsDisplayMultiline(parentSession.modelsUsed), formatNumber(parentSession.inputTokens), formatNumber(parentSession.outputTokens), - formatNumber(parentSession.cacheCreationTokens), - formatNumber(parentSession.cacheReadTokens), + formatNumber(parentSession.cacheCreationInputTokens), + formatNumber(parentSession.cacheReadInputTokens), formatNumber(parentSession.totalTokens), formatCurrency(parentSession.totalCost), ]); @@ -198,8 +199,8 @@ export const sessionCommand = define({ formatModelsDisplayMultiline(subSession.modelsUsed), formatNumber(subSession.inputTokens), formatNumber(subSession.outputTokens), - formatNumber(subSession.cacheCreationTokens), - formatNumber(subSession.cacheReadTokens), + formatNumber(subSession.cacheCreationInputTokens), + formatNumber(subSession.cacheReadInputTokens), formatNumber(subSession.totalTokens), formatCurrency(subSession.totalCost), ]); @@ -209,12 +210,12 @@ export const sessionCommand = define({ parentSession.inputTokens + subSessions.reduce((sum, s) => sum + s.inputTokens, 0); const subtotalOutputTokens = parentSession.outputTokens + subSessions.reduce((sum, s) => sum + s.outputTokens, 0); - const subtotalCacheCreationTokens = - parentSession.cacheCreationTokens + - subSessions.reduce((sum, s) => sum + s.cacheCreationTokens, 0); - const subtotalCacheReadTokens = - parentSession.cacheReadTokens + - subSessions.reduce((sum, s) => sum + s.cacheReadTokens, 0); + const subtotalCacheCreationInputTokens = + parentSession.cacheCreationInputTokens + + subSessions.reduce((sum, s) => sum + s.cacheCreationInputTokens, 0); + const subtotalCacheReadInputTokens = + parentSession.cacheReadInputTokens + + subSessions.reduce((sum, s) => sum + s.cacheReadInputTokens, 0); const subtotalTotalTokens = parentSession.totalTokens + subSessions.reduce((sum, s) => sum + s.totalTokens, 0); const subtotalCost = @@ -225,8 +226,8 @@ export const sessionCommand = define({ '', pc.yellow(formatNumber(subtotalInputTokens)), pc.yellow(formatNumber(subtotalOutputTokens)), - pc.yellow(formatNumber(subtotalCacheCreationTokens)), - pc.yellow(formatNumber(subtotalCacheReadTokens)), + pc.yellow(formatNumber(subtotalCacheCreationInputTokens)), + pc.yellow(formatNumber(subtotalCacheReadInputTokens)), pc.yellow(formatNumber(subtotalTotalTokens)), pc.yellow(formatCurrency(subtotalCost)), ]); @@ -239,8 +240,8 @@ export const sessionCommand = define({ '', pc.yellow(formatNumber(totals.inputTokens)), pc.yellow(formatNumber(totals.outputTokens)), - pc.yellow(formatNumber(totals.cacheCreationTokens)), - pc.yellow(formatNumber(totals.cacheReadTokens)), + pc.yellow(formatNumber(totals.cacheCreationInputTokens)), + pc.yellow(formatNumber(totals.cacheReadInputTokens)), pc.yellow(formatNumber(totals.totalTokens)), pc.yellow(formatCurrency(totals.totalCost)), ]); diff --git a/apps/opencode/src/commands/weekly.ts b/apps/opencode/src/commands/weekly.ts index 6d9e363e..a70d4f76 100644 --- a/apps/opencode/src/commands/weekly.ts +++ b/apps/opencode/src/commands/weekly.ts @@ -68,8 +68,8 @@ export const weeklyCommand = define({ const emptyTotals = { inputTokens: 0, outputTokens: 0, - cacheCreationTokens: 0, - cacheReadTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, totalTokens: 0, totalCost: 0, }; @@ -90,8 +90,8 @@ export const weeklyCommand = define({ week: string; inputTokens: number; outputTokens: number; - cacheCreationTokens: number; - cacheReadTokens: number; + cacheCreationInputTokens: number; + cacheReadInputTokens: number; totalTokens: number; totalCost: number; modelsUsed: string[]; @@ -100,28 +100,29 @@ export const weeklyCommand = define({ for (const [week, weekEntries] of Object.entries(entriesByWeek)) { let inputTokens = 0; let outputTokens = 0; - let cacheCreationTokens = 0; - let cacheReadTokens = 0; + let cacheCreationInputTokens = 0; + let cacheReadInputTokens = 0; let totalCost = 0; const modelsSet = new Set(); for (const entry of weekEntries) { inputTokens += entry.usage.inputTokens; outputTokens += entry.usage.outputTokens; - cacheCreationTokens += entry.usage.cacheCreationInputTokens; - cacheReadTokens += entry.usage.cacheReadInputTokens; + cacheCreationInputTokens += entry.usage.cacheCreationInputTokens; + cacheReadInputTokens += entry.usage.cacheReadInputTokens; totalCost += await calculateCostForEntry(entry, fetcher); modelsSet.add(entry.model); } - const totalTokens = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens; + const totalTokens = + inputTokens + outputTokens + cacheCreationInputTokens + cacheReadInputTokens; weeklyData.push({ week, inputTokens, outputTokens, - cacheCreationTokens, - cacheReadTokens, + cacheCreationInputTokens, + cacheReadInputTokens, totalTokens, totalCost, modelsUsed: Array.from(modelsSet), @@ -133,8 +134,8 @@ export const weeklyCommand = define({ const totals = { inputTokens: weeklyData.reduce((sum, d) => sum + d.inputTokens, 0), outputTokens: weeklyData.reduce((sum, d) => sum + d.outputTokens, 0), - cacheCreationTokens: weeklyData.reduce((sum, d) => sum + d.cacheCreationTokens, 0), - cacheReadTokens: weeklyData.reduce((sum, d) => sum + d.cacheReadTokens, 0), + cacheCreationInputTokens: weeklyData.reduce((sum, d) => sum + d.cacheCreationInputTokens, 0), + cacheReadInputTokens: weeklyData.reduce((sum, d) => sum + d.cacheReadInputTokens, 0), totalTokens: weeklyData.reduce((sum, d) => sum + d.totalTokens, 0), totalCost: weeklyData.reduce((sum, d) => sum + d.totalCost, 0), }; @@ -183,8 +184,8 @@ export const weeklyCommand = define({ formatModelsDisplayMultiline(data.modelsUsed), formatNumber(data.inputTokens), formatNumber(data.outputTokens), - formatNumber(data.cacheCreationTokens), - formatNumber(data.cacheReadTokens), + formatNumber(data.cacheCreationInputTokens), + formatNumber(data.cacheReadInputTokens), formatNumber(data.totalTokens), formatCurrency(data.totalCost), ]); @@ -196,8 +197,8 @@ export const weeklyCommand = define({ '', pc.yellow(formatNumber(totals.inputTokens)), pc.yellow(formatNumber(totals.outputTokens)), - pc.yellow(formatNumber(totals.cacheCreationTokens)), - pc.yellow(formatNumber(totals.cacheReadTokens)), + pc.yellow(formatNumber(totals.cacheCreationInputTokens)), + pc.yellow(formatNumber(totals.cacheReadInputTokens)), pc.yellow(formatNumber(totals.totalTokens)), pc.yellow(formatCurrency(totals.totalCost)), ]); From 4b4f921c9a90004365784ab498e059d57ad9b2d4 Mon Sep 17 00:00:00 2001 From: Vedang Manerikar Date: Thu, 5 Feb 2026 11:30:23 +0530 Subject: [PATCH 17/22] fix(ccusage): honor config jq for session output Refs: #832 --- apps/ccusage/src/commands/session.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/ccusage/src/commands/session.ts b/apps/ccusage/src/commands/session.ts index 3c423792..21c8e0e1 100644 --- a/apps/ccusage/src/commands/session.ts +++ b/apps/ccusage/src/commands/session.ts @@ -143,7 +143,7 @@ export const sessionCommand = define({ cacheReadTokens: 0, totalCost: 0, }; - const jsonResult = await renderSessionJsonOutput([], totals, ctx.values.jq); + const jsonResult = await renderSessionJsonOutput([], totals, mergedOptions.jq); if (Result.isFailure(jsonResult)) { logger.error(jsonResult.error.message); process.exit(1); @@ -165,7 +165,7 @@ export const sessionCommand = define({ } if (useJson) { - const jsonResult = await renderSessionJsonOutput(sessionData, totals, ctx.values.jq); + const jsonResult = await renderSessionJsonOutput(sessionData, totals, mergedOptions.jq); if (Result.isFailure(jsonResult)) { logger.error(jsonResult.error.message); process.exit(1); From ce9a6ed951f975810714e65c5751c2ea404b0f8c Mon Sep 17 00:00:00 2001 From: Vedang Manerikar Date: Thu, 5 Feb 2026 11:36:40 +0530 Subject: [PATCH 18/22] test(codex): use valid model keys in fixtures Refs: #833 --- apps/codex/src/commands/monthly.ts | 2 +- apps/codex/src/daily-report.ts | 14 +++++++------- apps/codex/src/monthly-report.ts | 14 +++++++------- apps/codex/src/session-report.ts | 12 ++++++------ 4 files changed, 21 insertions(+), 21 deletions(-) diff --git a/apps/codex/src/commands/monthly.ts b/apps/codex/src/commands/monthly.ts index d3cadfdd..c7080dbd 100644 --- a/apps/codex/src/commands/monthly.ts +++ b/apps/codex/src/commands/monthly.ts @@ -257,7 +257,7 @@ if (import.meta.vitest != null) { totalCost: 1.5, costUSD: 99, models: { - 'gpt-5': { + 'claude-sonnet-4-20250514': { inputTokens: 100, cacheCreationTokens: 0, cacheReadTokens: 0, diff --git a/apps/codex/src/daily-report.ts b/apps/codex/src/daily-report.ts index 158031ec..40d2d880 100644 --- a/apps/codex/src/daily-report.ts +++ b/apps/codex/src/daily-report.ts @@ -136,11 +136,11 @@ if (import.meta.vitest != null) { it('aggregates events by day and calculates costs', async () => { const pricing = new Map([ [ - 'gpt-5', + 'claude-sonnet-4-20250514', { inputCostPerMToken: 1.25, cachedInputCostPerMToken: 0.125, outputCostPerMToken: 10 }, ], [ - 'gpt-5-mini', + 'claude-opus-4-20250514', { inputCostPerMToken: 0.6, cachedInputCostPerMToken: 0.06, outputCostPerMToken: 2 }, ], ]); @@ -158,7 +158,7 @@ if (import.meta.vitest != null) { { sessionId: 'session-1', timestamp: '2025-09-11T03:00:00.000Z', - model: 'gpt-5', + model: 'claude-sonnet-4-20250514', inputTokens: 1_000, cacheCreationTokens: 0, cacheReadTokens: 200, @@ -170,7 +170,7 @@ if (import.meta.vitest != null) { { sessionId: 'session-1', timestamp: '2025-09-11T05:00:00.000Z', - model: 'gpt-5-mini', + model: 'claude-opus-4-20250514', inputTokens: 400, cacheCreationTokens: 0, cacheReadTokens: 100, @@ -182,7 +182,7 @@ if (import.meta.vitest != null) { { sessionId: 'session-2', timestamp: '2025-09-12T01:00:00.000Z', - model: 'gpt-5', + model: 'claude-sonnet-4-20250514', inputTokens: 2_000, cacheCreationTokens: 0, cacheReadTokens: 0, @@ -207,8 +207,8 @@ if (import.meta.vitest != null) { expect(first.cachedInputTokens).toBe(300); expect(first.outputTokens).toBe(700); expect(first.reasoningOutputTokens).toBe(50); - // gpt-5: 800 non-cached input @ 1.25, 200 cached @ 0.125, 500 output @ 10 - // gpt-5-mini: 300 non-cached input @ 0.6, 100 cached @ 0.06, 200 output @ 2 (reasoning already included) + // claude-sonnet-4-20250514: 800 non-cached input @ 1.25, 200 cached @ 0.125, 500 output @ 10 + // claude-opus-4-20250514: 300 non-cached input @ 0.6, 100 cached @ 0.06, 200 output @ 2 (reasoning already included) const expectedCost = (800 / 1_000_000) * 1.25 + (200 / 1_000_000) * 0.125 + diff --git a/apps/codex/src/monthly-report.ts b/apps/codex/src/monthly-report.ts index a0204572..5a7048c2 100644 --- a/apps/codex/src/monthly-report.ts +++ b/apps/codex/src/monthly-report.ts @@ -137,11 +137,11 @@ if (import.meta.vitest != null) { it('aggregates events by month and calculates costs', async () => { const pricing = new Map([ [ - 'gpt-5', + 'claude-sonnet-4-20250514', { inputCostPerMToken: 1.25, cachedInputCostPerMToken: 0.125, outputCostPerMToken: 10 }, ], [ - 'gpt-5-mini', + 'claude-opus-4-20250514', { inputCostPerMToken: 0.6, cachedInputCostPerMToken: 0.06, outputCostPerMToken: 2 }, ], ]); @@ -159,7 +159,7 @@ if (import.meta.vitest != null) { { sessionId: 'session-1', timestamp: '2025-08-11T03:00:00.000Z', - model: 'gpt-5', + model: 'claude-sonnet-4-20250514', inputTokens: 1_000, cacheCreationTokens: 0, cacheReadTokens: 200, @@ -171,7 +171,7 @@ if (import.meta.vitest != null) { { sessionId: 'session-1', timestamp: '2025-08-20T05:00:00.000Z', - model: 'gpt-5-mini', + model: 'claude-opus-4-20250514', inputTokens: 400, cacheCreationTokens: 0, cacheReadTokens: 100, @@ -183,7 +183,7 @@ if (import.meta.vitest != null) { { sessionId: 'session-2', timestamp: '2025-09-12T01:00:00.000Z', - model: 'gpt-5', + model: 'claude-sonnet-4-20250514', inputTokens: 2_000, cacheCreationTokens: 0, cacheReadTokens: 0, @@ -207,8 +207,8 @@ if (import.meta.vitest != null) { expect(first.cachedInputTokens).toBe(300); expect(first.outputTokens).toBe(700); expect(first.reasoningOutputTokens).toBe(50); - // gpt-5: 800 non-cached input @ 1.25, 200 cached @ 0.125, 500 output @ 10 - // gpt-5-mini: 300 non-cached input @ 0.6, 100 cached @ 0.06, 200 output @ 2 (reasoning already included) + // claude-sonnet-4-20250514: 800 non-cached input @ 1.25, 200 cached @ 0.125, 500 output @ 10 + // claude-opus-4-20250514: 300 non-cached input @ 0.6, 100 cached @ 0.06, 200 output @ 2 (reasoning already included) const expectedCost = (800 / 1_000_000) * 1.25 + (200 / 1_000_000) * 0.125 + diff --git a/apps/codex/src/session-report.ts b/apps/codex/src/session-report.ts index f17e67a9..0dab32a8 100644 --- a/apps/codex/src/session-report.ts +++ b/apps/codex/src/session-report.ts @@ -165,11 +165,11 @@ if (import.meta.vitest != null) { it('groups events by session and calculates costs', async () => { const pricing = new Map([ [ - 'gpt-5', + 'claude-sonnet-4-20250514', { inputCostPerMToken: 1.25, cachedInputCostPerMToken: 0.125, outputCostPerMToken: 10 }, ], [ - 'gpt-5-mini', + 'claude-opus-4-20250514', { inputCostPerMToken: 0.6, cachedInputCostPerMToken: 0.06, outputCostPerMToken: 2 }, ], ]); @@ -188,7 +188,7 @@ if (import.meta.vitest != null) { { sessionId: 'session-a', timestamp: '2025-09-12T01:00:00.000Z', - model: 'gpt-5', + model: 'claude-sonnet-4-20250514', inputTokens: 1_000, cacheCreationTokens: 0, cacheReadTokens: 100, @@ -200,7 +200,7 @@ if (import.meta.vitest != null) { { sessionId: 'session-a', timestamp: '2025-09-12T02:00:00.000Z', - model: 'gpt-5-mini', + model: 'claude-opus-4-20250514', inputTokens: 400, cacheCreationTokens: 0, cacheReadTokens: 100, @@ -212,7 +212,7 @@ if (import.meta.vitest != null) { { sessionId: 'session-b', timestamp: '2025-09-11T23:30:00.000Z', - model: 'gpt-5', + model: 'claude-sonnet-4-20250514', inputTokens: 800, cacheCreationTokens: 0, cacheReadTokens: 0, @@ -239,7 +239,7 @@ if (import.meta.vitest != null) { expect(second.sessionFile).toBe('session-a'); expect(second.directory).toBe(''); expect(second.totalTokens).toBe(2_130); - expect(second.models['gpt-5']?.totalTokens).toBe(1_500); + expect(second.models['claude-sonnet-4-20250514']?.totalTokens).toBe(1_500); const expectedCost = (900 / 1_000_000) * 1.25 + (100 / 1_000_000) * 0.125 + From f5c38f8a67023ab3f4532131a42d83e391abb5d4 Mon Sep 17 00:00:00 2001 From: Vedang Manerikar Date: Thu, 5 Feb 2026 11:38:49 +0530 Subject: [PATCH 19/22] fix(codex): use totalCost for daily/session totals Refs: #833 --- apps/codex/src/commands/daily.ts | 8 ++++---- apps/codex/src/commands/session.ts | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/codex/src/commands/daily.ts b/apps/codex/src/commands/daily.ts index c66b675f..0172e3d4 100644 --- a/apps/codex/src/commands/daily.ts +++ b/apps/codex/src/commands/daily.ts @@ -161,7 +161,7 @@ export const dailyCommand = define({ reasoningTokens: 0, cacheReadTokens: 0, totalTokens: 0, - costUSD: 0, + totalCost: 0, }; for (const row of rows) { @@ -171,7 +171,7 @@ export const dailyCommand = define({ totalsForDisplay.reasoningTokens += split.reasoningTokens; totalsForDisplay.cacheReadTokens += split.cacheReadTokens; totalsForDisplay.totalTokens += row.totalTokens; - totalsForDisplay.costUSD += row.costUSD; + totalsForDisplay.totalCost += row.totalCost; table.push([ row.date, @@ -181,7 +181,7 @@ export const dailyCommand = define({ formatNumber(split.reasoningTokens), formatNumber(split.cacheReadTokens), formatNumber(row.totalTokens), - formatCurrency(row.costUSD), + formatCurrency(row.totalCost), ]); } @@ -194,7 +194,7 @@ export const dailyCommand = define({ pc.yellow(formatNumber(totalsForDisplay.reasoningTokens)), pc.yellow(formatNumber(totalsForDisplay.cacheReadTokens)), pc.yellow(formatNumber(totalsForDisplay.totalTokens)), - pc.yellow(formatCurrency(totalsForDisplay.costUSD)), + pc.yellow(formatCurrency(totalsForDisplay.totalCost)), ]); log(table.toString()); diff --git a/apps/codex/src/commands/session.ts b/apps/codex/src/commands/session.ts index f0846b9b..4aea83fc 100644 --- a/apps/codex/src/commands/session.ts +++ b/apps/codex/src/commands/session.ts @@ -181,7 +181,7 @@ export const sessionCommand = define({ reasoningTokens: 0, cacheReadTokens: 0, totalTokens: 0, - costUSD: 0, + totalCost: 0, }; for (const row of rows) { @@ -191,7 +191,7 @@ export const sessionCommand = define({ totalsForDisplay.reasoningTokens += split.reasoningTokens; totalsForDisplay.cacheReadTokens += split.cacheReadTokens; totalsForDisplay.totalTokens += row.totalTokens; - totalsForDisplay.costUSD += row.costUSD; + totalsForDisplay.totalCost += row.totalCost; const dateKey = toDateKey(row.lastActivity, ctx.values.timezone); const displayDate = formatDisplayDate(dateKey, ctx.values.locale, ctx.values.timezone); @@ -209,7 +209,7 @@ export const sessionCommand = define({ formatNumber(split.reasoningTokens), formatNumber(split.cacheReadTokens), formatNumber(row.totalTokens), - formatCurrency(row.costUSD), + formatCurrency(row.totalCost), formatDisplayDateTime(row.lastActivity, ctx.values.locale, ctx.values.timezone), ]); } @@ -225,7 +225,7 @@ export const sessionCommand = define({ pc.yellow(formatNumber(totalsForDisplay.reasoningTokens)), pc.yellow(formatNumber(totalsForDisplay.cacheReadTokens)), pc.yellow(formatNumber(totalsForDisplay.totalTokens)), - pc.yellow(formatCurrency(totalsForDisplay.costUSD)), + pc.yellow(formatCurrency(totalsForDisplay.totalCost)), '', ]); From 88ee7a25f4f084894e671d354772ca3f5f5f297d Mon Sep 17 00:00:00 2001 From: Vedang Manerikar Date: Thu, 5 Feb 2026 11:39:57 +0530 Subject: [PATCH 20/22] refactor(codex): drop redundant cachedInputTokens Refs: #833 --- apps/codex/src/_types.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/codex/src/_types.ts b/apps/codex/src/_types.ts index 37c1b4a4..53e2e1a0 100644 --- a/apps/codex/src/_types.ts +++ b/apps/codex/src/_types.ts @@ -17,7 +17,6 @@ export type TokenUsageEvent = TokenUsageDelta & { export type ModelUsage = TokenUsageDelta & { isFallback?: boolean; - cachedInputTokens?: number; // Legacy field, now split into cacheCreationTokens + cacheReadTokens }; export type DailyUsageSummary = { From 575cae9347cdcad4e18ce195516ce3e87e20c6bf Mon Sep 17 00:00:00 2001 From: Vedang Manerikar Date: Thu, 5 Feb 2026 11:41:46 +0530 Subject: [PATCH 21/22] refactor(pi): tidy totals handling Refs: #833 --- apps/pi/src/commands/daily.ts | 7 +++++-- apps/pi/src/commands/session.ts | 29 +++++++++++++---------------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/apps/pi/src/commands/daily.ts b/apps/pi/src/commands/daily.ts index 6b89ab33..7bb7acc0 100644 --- a/apps/pi/src/commands/daily.ts +++ b/apps/pi/src/commands/daily.ts @@ -91,10 +91,13 @@ export const dailyCommand = define({ totals.outputTokens += d.outputTokens; totals.cacheCreationTokens += d.cacheCreationTokens; totals.cacheReadTokens += d.cacheReadTokens; - totals.totalTokens += - d.inputTokens + d.outputTokens + d.cacheCreationTokens + d.cacheReadTokens; totals.totalCost += d.totalCost; } + totals.totalTokens = + totals.inputTokens + + totals.outputTokens + + totals.cacheCreationTokens + + totals.cacheReadTokens; if (ctx.values.json) { log( diff --git a/apps/pi/src/commands/session.ts b/apps/pi/src/commands/session.ts index 7749113b..50ccf0db 100644 --- a/apps/pi/src/commands/session.ts +++ b/apps/pi/src/commands/session.ts @@ -12,6 +12,17 @@ import { define } from 'gunshi'; import { loadPiAgentSessionData } from '../data-loader.ts'; import { log, logger } from '../logger.ts'; +function createEmptyTotals () { + return { + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + totalTokens: 0, + totalCost: 0, +} +} + export const sessionCommand = define({ name: 'session', description: 'Show pi-agent usage by session', @@ -63,14 +74,7 @@ export const sessionCommand = define({ if (piData.length === 0) { if (ctx.values.json) { - const totals = { - inputTokens: 0, - outputTokens: 0, - cacheCreationTokens: 0, - cacheReadTokens: 0, - totalTokens: 0, - totalCost: 0, - }; + const totals = createEmptyTotals(); log(JSON.stringify({ sessions: [], totals }, null, 2)); } else { logger.warn('No usage data found.'); @@ -78,14 +82,7 @@ export const sessionCommand = define({ process.exit(0); } - const totals = { - inputTokens: 0, - outputTokens: 0, - cacheCreationTokens: 0, - cacheReadTokens: 0, - totalTokens: 0, - totalCost: 0, - }; + const totals = createEmptyTotals(); for (const d of piData) { totals.inputTokens += d.inputTokens; From 2bdf0226bd94c1a134649c6c2891d9418d3c1e1b Mon Sep 17 00:00:00 2001 From: Vedang Manerikar Date: Thu, 5 Feb 2026 11:50:25 +0530 Subject: [PATCH 22/22] docs(ccusage): add missing JSDoc coverage Refs: #833 --- apps/ccusage/src/_macro.ts | 4 ++ apps/ccusage/src/_types.ts | 69 +++++++++++++++++++++++++ apps/ccusage/src/_utils.ts | 4 ++ apps/ccusage/src/commands/blocks.ts | 3 ++ apps/ccusage/src/commands/daily.ts | 3 ++ apps/ccusage/src/commands/index.ts | 3 ++ apps/ccusage/src/commands/monthly.ts | 3 ++ apps/ccusage/src/commands/session.ts | 3 ++ apps/ccusage/src/commands/statusline.ts | 3 ++ apps/ccusage/src/commands/weekly.ts | 3 ++ apps/ccusage/src/data-loader.ts | 13 +++++ 11 files changed, 111 insertions(+) diff --git a/apps/ccusage/src/_macro.ts b/apps/ccusage/src/_macro.ts index 298b2cfe..260ccdfd 100644 --- a/apps/ccusage/src/_macro.ts +++ b/apps/ccusage/src/_macro.ts @@ -13,6 +13,10 @@ function isClaudeModel(modelName: string, _pricing: LiteLLMModelPricing): boolea ); } +/** + * Fetch and cache Claude model pricing data from LiteLLM. + * @returns Pricing dataset filtered to Claude models + */ export async function prefetchClaudePricing(): Promise> { try { const dataset = await fetchLiteLLMPricingDataset(); diff --git a/apps/ccusage/src/_types.ts b/apps/ccusage/src/_types.ts index dd85e11d..4ede8403 100644 --- a/apps/ccusage/src/_types.ts +++ b/apps/ccusage/src/_types.ts @@ -12,18 +12,27 @@ export const modelNameSchema = v.pipe( v.brand('ModelName'), ); +/** + * Schema for Claude session identifiers. + */ export const sessionIdSchema = v.pipe( v.string(), v.minLength(1, 'Session ID cannot be empty'), v.brand('SessionId'), ); +/** + * Schema for API request identifiers. + */ export const requestIdSchema = v.pipe( v.string(), v.minLength(1, 'Request ID cannot be empty'), v.brand('RequestId'), ); +/** + * Schema for Claude message identifiers. + */ export const messageIdSchema = v.pipe( v.string(), v.minLength(1, 'Message ID cannot be empty'), @@ -32,6 +41,9 @@ export const messageIdSchema = v.pipe( // Date and timestamp schemas const isoTimestampRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/; +/** + * Schema for ISO-8601 timestamps stored in usage entries. + */ export const isoTimestampSchema = v.pipe( v.string(), v.regex(isoTimestampRegex, 'Invalid ISO timestamp'), @@ -39,12 +51,18 @@ export const isoTimestampSchema = v.pipe( ); const yyyymmddRegex = /^\d{4}-\d{2}-\d{2}$/; +/** + * Schema for daily usage date keys (YYYY-MM-DD). + */ export const dailyDateSchema = v.pipe( v.string(), v.regex(yyyymmddRegex, 'Date must be in YYYY-MM-DD format'), v.brand('DailyDate'), ); +/** + * Schema for activity date values (YYYY-MM-DD). + */ export const activityDateSchema = v.pipe( v.string(), v.regex(yyyymmddRegex, 'Date must be in YYYY-MM-DD format'), @@ -52,12 +70,18 @@ export const activityDateSchema = v.pipe( ); const yyyymmRegex = /^\d{4}-\d{2}$/; +/** + * Schema for monthly usage date keys (YYYY-MM). + */ export const monthlyDateSchema = v.pipe( v.string(), v.regex(yyyymmRegex, 'Date must be in YYYY-MM format'), v.brand('MonthlyDate'), ); +/** + * Schema for weekly usage date keys (week start date). + */ export const weeklyDateSchema = v.pipe( v.string(), v.regex(yyyymmddRegex, 'Date must be in YYYY-MM-DD format'), @@ -65,6 +89,9 @@ export const weeklyDateSchema = v.pipe( ); const filterDateRegex = /^\d{8}$/; +/** + * Schema for CLI filter dates in YYYYMMDD format. + */ export const filterDateSchema = v.pipe( v.string(), v.regex(filterDateRegex, 'Date must be in YYYYMMDD format'), @@ -72,6 +99,9 @@ export const filterDateSchema = v.pipe( ); // Other domain-specific schemas +/** + * Schema for local project paths used to group sessions. + */ export const projectPathSchema = v.pipe( v.string(), v.minLength(1, 'Project path cannot be empty'), @@ -79,6 +109,9 @@ export const projectPathSchema = v.pipe( ); const versionRegex = /^\d+\.\d+\.\d+/; +/** + * Schema for Claude Code version strings. + */ export const versionSchema = v.pipe( v.string(), v.regex(versionRegex, 'Invalid version format'), @@ -107,22 +140,58 @@ export type Version = v.InferOutput; * These functions should be used when converting plain strings to branded types */ export const createModelName = (value: string): ModelName => v.parse(modelNameSchema, value); +/** + * Parse and brand a session identifier string. + */ export const createSessionId = (value: string): SessionId => v.parse(sessionIdSchema, value); +/** + * Parse and brand a request identifier string. + */ export const createRequestId = (value: string): RequestId => v.parse(requestIdSchema, value); +/** + * Parse and brand a message identifier string. + */ export const createMessageId = (value: string): MessageId => v.parse(messageIdSchema, value); +/** + * Parse and brand an ISO timestamp string. + */ export function createISOTimestamp(value: string): ISOTimestamp { return v.parse(isoTimestampSchema, value); } +/** + * Parse and brand a daily date (YYYY-MM-DD). + */ export const createDailyDate = (value: string): DailyDate => v.parse(dailyDateSchema, value); +/** + * Parse and brand an activity date (YYYY-MM-DD). + */ export function createActivityDate(value: string): ActivityDate { return v.parse(activityDateSchema, value); } +/** + * Parse and brand a monthly date (YYYY-MM). + */ export const createMonthlyDate = (value: string): MonthlyDate => v.parse(monthlyDateSchema, value); +/** + * Parse and brand a weekly date (week start). + */ export const createWeeklyDate = (value: string): WeeklyDate => v.parse(weeklyDateSchema, value); +/** + * Parse and brand a filter date (YYYYMMDD). + */ export const createFilterDate = (value: string): FilterDate => v.parse(filterDateSchema, value); +/** + * Parse and brand a project path string. + */ export const createProjectPath = (value: string): ProjectPath => v.parse(projectPathSchema, value); +/** + * Parse and brand a Claude Code version string. + */ export const createVersion = (value: string): Version => v.parse(versionSchema, value); +/** + * Parse a bucket key into a weekly or monthly date bucket. + */ export function createBucket(value: string): Bucket { const weeklyResult = v.safeParse(weeklyDateSchema, value); if (weeklyResult.success) { diff --git a/apps/ccusage/src/_utils.ts b/apps/ccusage/src/_utils.ts index f412cc80..e2ae49a6 100644 --- a/apps/ccusage/src/_utils.ts +++ b/apps/ccusage/src/_utils.ts @@ -2,6 +2,10 @@ import { stat, utimes, writeFile } from 'node:fs/promises'; import { Result } from '@praha/byethrow'; import { createFixture } from 'fs-fixture'; +/** + * Exhaustive-check helper for unreachable code paths. + * @param value - Value that should never occur + */ export function unreachable(value: never): never { throw new Error(`Unreachable code reached with value: ${value as any}`); } diff --git a/apps/ccusage/src/commands/blocks.ts b/apps/ccusage/src/commands/blocks.ts index 792e745f..907d600c 100644 --- a/apps/ccusage/src/commands/blocks.ts +++ b/apps/ccusage/src/commands/blocks.ts @@ -117,6 +117,9 @@ function parseTokenLimit(value: string | undefined, maxFromAll: number): number return Number.isNaN(limit) ? undefined : limit; } +/** + * CLI command for 5-hour billing block reports. + */ export const blocksCommand = define({ name: 'blocks', description: 'Show usage report grouped by session billing blocks', diff --git a/apps/ccusage/src/commands/daily.ts b/apps/ccusage/src/commands/daily.ts index 9e124fc2..883b5e9f 100644 --- a/apps/ccusage/src/commands/daily.ts +++ b/apps/ccusage/src/commands/daily.ts @@ -21,6 +21,9 @@ import { loadDailyUsageData } from '../data-loader.ts'; import { detectMismatches, printMismatchReport } from '../debug.ts'; import { log, logger } from '../logger.ts'; +/** + * CLI command for daily usage reports. + */ export const dailyCommand = define({ name: 'daily', description: 'Show usage report grouped by date', diff --git a/apps/ccusage/src/commands/index.ts b/apps/ccusage/src/commands/index.ts index 75285a84..35359031 100644 --- a/apps/ccusage/src/commands/index.ts +++ b/apps/ccusage/src/commands/index.ts @@ -48,6 +48,9 @@ for (const [name, command] of subCommandUnion) { */ const mainCommand = dailyCommand; +/** + * Execute the ccusage CLI with the configured subcommands. + */ export async function run(): Promise { // When invoked through npx, the binary name might be passed as the first argument // Filter it out if it matches the expected binary name diff --git a/apps/ccusage/src/commands/monthly.ts b/apps/ccusage/src/commands/monthly.ts index 987c6e1d..8e9b41b5 100644 --- a/apps/ccusage/src/commands/monthly.ts +++ b/apps/ccusage/src/commands/monthly.ts @@ -19,6 +19,9 @@ import { loadMonthlyUsageData } from '../data-loader.ts'; import { detectMismatches, printMismatchReport } from '../debug.ts'; import { log, logger } from '../logger.ts'; +/** + * CLI command for monthly usage reports. + */ export const monthlyCommand = define({ name: 'monthly', description: 'Show usage report grouped by month', diff --git a/apps/ccusage/src/commands/session.ts b/apps/ccusage/src/commands/session.ts index 21c8e0e1..4d3c2989 100644 --- a/apps/ccusage/src/commands/session.ts +++ b/apps/ccusage/src/commands/session.ts @@ -83,6 +83,9 @@ async function renderSessionJsonOutput( return Result.succeed(JSON.stringify(jsonOutput, null, 2)); } +/** + * CLI command for session-based usage reports. + */ export const sessionCommand = define({ name: 'session', description: 'Show usage report grouped by conversation session', diff --git a/apps/ccusage/src/commands/statusline.ts b/apps/ccusage/src/commands/statusline.ts index f5a6af2d..ff43f254 100644 --- a/apps/ccusage/src/commands/statusline.ts +++ b/apps/ccusage/src/commands/statusline.ts @@ -102,6 +102,9 @@ function parseContextThreshold(value: string): number { return v.parse(contextThresholdSchema, value); } +/** + * CLI command for compact statusline output. + */ export const statuslineCommand = define({ name: 'statusline', description: diff --git a/apps/ccusage/src/commands/weekly.ts b/apps/ccusage/src/commands/weekly.ts index bb7e3abe..5c61471a 100644 --- a/apps/ccusage/src/commands/weekly.ts +++ b/apps/ccusage/src/commands/weekly.ts @@ -19,6 +19,9 @@ import { loadWeeklyUsageData } from '../data-loader.ts'; import { detectMismatches, printMismatchReport } from '../debug.ts'; import { log, logger } from '../logger.ts'; +/** + * CLI command for weekly usage reports. + */ export const weeklyCommand = define({ name: 'weekly', description: 'Show usage report grouped by week', diff --git a/apps/ccusage/src/data-loader.ts b/apps/ccusage/src/data-loader.ts index 8e22aae2..50fcaf27 100644 --- a/apps/ccusage/src/data-loader.ts +++ b/apps/ccusage/src/data-loader.ts @@ -1090,6 +1090,12 @@ export async function loadMonthlyUsageData(options?: LoadOptions): Promise { const startDay = options?.startOfWeek != null ? getDayNumber(options.startOfWeek) : getDayNumber('sunday'); @@ -1164,6 +1170,13 @@ export async function loadSessionUsageById( return { totalCost, entries }; } +/** + * Loads usage data and aggregates it into bucketed summaries. + * Buckets are determined by the provided grouping function (monthly/weekly). + * @param groupingFn - Function that maps a daily usage entry to a bucket key + * @param options - Optional configuration for loading and filtering data + * @returns Aggregated usage summaries keyed by bucket and optional project + */ export async function loadBucketUsageData( groupingFn: (data: DailyUsage) => Bucket, options?: LoadOptions,