diff --git a/apps/amp/src/commands/daily.ts b/apps/amp/src/commands/daily.ts index 2c728575..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; } @@ -98,7 +109,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..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; } @@ -98,7 +112,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..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; } @@ -104,7 +115,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({ 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 16585eb9..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', @@ -82,7 +85,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/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 a825063b..4d3c2989 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,68 @@ 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; +}; + +/** + * Build the JSON output payload for session usage reports. + */ +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), + }; +} + +/** + * Render session usage JSON output, applying jq when requested. + */ +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)); +} + +/** + * CLI command for session-based usage reports. + */ export const sessionCommand = define({ name: 'session', description: 'Show usage report grouped by conversation session', @@ -76,7 +139,19 @@ export const sessionCommand = define({ if (sessionData.length === 0) { if (useJson) { - log(JSON.stringify([])); + const totals = { + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + totalCost: 0, + }; + const jsonResult = await renderSessionJsonOutput([], totals, mergedOptions.jq); + if (Result.isFailure(jsonResult)) { + logger.error(jsonResult.error.message); + process.exit(1); + } + log(jsonResult.value); } else { logger.warn('No Claude usage data found.'); } @@ -93,35 +168,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, mergedOptions.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'); @@ -192,5 +244,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. 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, diff --git a/apps/codex/src/_types.ts b/apps/codex/src/_types.ts index 3540e218..53e2e1a0 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; @@ -20,14 +22,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 +39,8 @@ export type SessionUsageSummary = { sessionId: string; firstTimestamp: string; lastTimestamp: string; - costUSD: number; + totalCost: number; + costUSD: number; // Legacy field, use totalCost instead models: Map; } & TokenUsageDelta; @@ -57,23 +62,29 @@ export type PricingSource = { export type DailyReportRow = { date: string; inputTokens: number; - cachedInputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; outputTokens: number; reasoningOutputTokens: number; totalTokens: number; - costUSD: 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; - costUSD: number; + totalCost: number; + costUSD: number; // Legacy field, use totalCost instead models: Record; + cachedInputTokens?: number; // Legacy field for backward compatibility }; export type SessionReportRow = { @@ -82,10 +93,13 @@ export type SessionReportRow = { sessionFile: string; directory: string; inputTokens: number; - cachedInputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; outputTokens: number; reasoningOutputTokens: number; totalTokens: number; - costUSD: 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 6dc7c6f0..0172e3d4 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,31 +78,42 @@ 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; } 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; - acc.costUSD += row.costUSD; + acc.totalCost += row.totalCost; return acc; }, { inputTokens: 0, - cachedInputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, outputTokens: 0, reasoningOutputTokens: 0, totalTokens: 0, - costUSD: 0, + totalCost: 0, }, ); @@ -137,7 +161,7 @@ export const dailyCommand = define({ reasoningTokens: 0, cacheReadTokens: 0, totalTokens: 0, - costUSD: 0, + totalCost: 0, }; for (const row of rows) { @@ -147,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, @@ -157,7 +181,7 @@ export const dailyCommand = define({ formatNumber(split.reasoningTokens), formatNumber(split.cacheReadTokens), formatNumber(row.totalTokens), - formatCurrency(row.costUSD), + formatCurrency(row.totalCost), ]); } @@ -170,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/monthly.ts b/apps/codex/src/commands/monthly.ts index 2a5abc5e..c7080dbd 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,45 @@ import { CodexPricingSource } from '../pricing.ts'; const TABLE_COLUMN_COUNT = 8; +type MonthlyDisplayTotals = { + inputTokens: number; + outputTokens: number; + reasoningTokens: number; + cacheReadTokens: number; + totalTokens: number; + totalCost: number; +}; + +/** + * Create a zeroed display totals accumulator for monthly table output. + */ +function createMonthlyDisplayTotals(): MonthlyDisplayTotals { + return { + inputTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + cacheReadTokens: 0, + totalTokens: 0, + totalCost: 0, + }; +} + +/** + * Update display totals using a pre-split row for monthly output. + */ +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', @@ -48,9 +88,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 +120,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; @@ -78,20 +144,22 @@ 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; - acc.costUSD += row.costUSD; + acc.totalCost += row.totalCost; return acc; }, { inputTokens: 0, - cachedInputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, outputTokens: 0, reasoningOutputTokens: 0, totalTokens: 0, - costUSD: 0, + totalCost: 0, }, ); @@ -133,23 +201,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, @@ -159,7 +215,7 @@ export const monthlyCommand = define({ formatNumber(split.reasoningTokens), formatNumber(split.cacheReadTokens), formatNumber(row.totalTokens), - formatCurrency(row.costUSD), + formatCurrency(row.totalCost), ]); } @@ -172,7 +228,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()); @@ -186,3 +242,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: { + 'claude-sonnet-4-20250514': { + 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); + }); + }); +} diff --git a/apps/codex/src/commands/session.ts b/apps/codex/src/commands/session.ts index 5dbe1a7e..4aea83fc 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,31 +83,42 @@ 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; } 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; - acc.costUSD += row.costUSD; + acc.totalCost += row.totalCost; return acc; }, { inputTokens: 0, - cachedInputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, outputTokens: 0, reasoningOutputTokens: 0, totalTokens: 0, - costUSD: 0, + totalCost: 0, }, ); @@ -159,7 +181,7 @@ export const sessionCommand = define({ reasoningTokens: 0, cacheReadTokens: 0, totalTokens: 0, - costUSD: 0, + totalCost: 0, }; for (const row of rows) { @@ -169,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); @@ -187,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), ]); } @@ -203,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)), '', ]); diff --git a/apps/codex/src/daily-report.ts b/apps/codex/src/daily-report.ts index c44a34a9..40d2d880 100644 --- a/apps/codex/src/daily-report.ts +++ b/apps/codex/src/daily-report.ts @@ -22,10 +22,12 @@ function createSummary(date: string, initialTimestamp: string): DailyUsageSummar date, firstTimestamp: initialTimestamp, inputTokens: 0, - cachedInputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, outputTokens: 0, reasoningOutputTokens: 0, totalTokens: 0, + totalCost: 0, costUSD: 0, models: new Map(), }; @@ -99,20 +101,28 @@ export async function buildDailyReport( } cost += calculateCostUSD(usage, pricing); } + summary.totalCost = cost; summary.costUSD = cost; 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, + totalCost: cost, costUSD: cost, models: rowModels, }); @@ -126,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 }, ], ]); @@ -148,8 +158,10 @@ 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, cachedInputTokens: 200, outputTokens: 500, reasoningOutputTokens: 0, @@ -158,8 +170,10 @@ 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, cachedInputTokens: 100, outputTokens: 200, reasoningOutputTokens: 50, @@ -168,8 +182,10 @@ 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, cachedInputTokens: 0, outputTokens: 800, reasoningOutputTokens: 0, @@ -187,11 +203,12 @@ 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); - // 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 + @@ -199,6 +216,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/data-loader.ts b/apps/codex/src/data-loader.ts index ef23a8f5..b1a5ed63 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, @@ -308,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: { diff --git a/apps/codex/src/monthly-report.ts b/apps/codex/src/monthly-report.ts index b29e03ae..5a7048c2 100644 --- a/apps/codex/src/monthly-report.ts +++ b/apps/codex/src/monthly-report.ts @@ -22,10 +22,12 @@ function createSummary(month: string, initialTimestamp: string): MonthlyUsageSum month, firstTimestamp: initialTimestamp, inputTokens: 0, - cachedInputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, outputTokens: 0, reasoningOutputTokens: 0, totalTokens: 0, + totalCost: 0, costUSD: 0, models: new Map(), }; @@ -100,20 +102,28 @@ export async function buildMonthlyReport( } cost += calculateCostUSD(usage, pricing); } + summary.totalCost = cost; summary.costUSD = cost; 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({ 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, + totalCost: cost, costUSD: cost, models: rowModels, }); @@ -127,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 }, ], ]); @@ -149,8 +159,10 @@ 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, cachedInputTokens: 200, outputTokens: 500, reasoningOutputTokens: 0, @@ -159,8 +171,10 @@ 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, cachedInputTokens: 100, outputTokens: 200, reasoningOutputTokens: 50, @@ -169,8 +183,10 @@ 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, cachedInputTokens: 0, outputTokens: 800, reasoningOutputTokens: 0, @@ -187,11 +203,12 @@ 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); - // 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 + @@ -199,6 +216,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..0dab32a8 100644 --- a/apps/codex/src/session-report.ts +++ b/apps/codex/src/session-report.ts @@ -23,10 +23,12 @@ 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, + totalCost: 0, costUSD: 0, models: new Map(), }; @@ -120,11 +122,16 @@ export async function buildSessionReport( } cost += calculateCostUSD(usage, pricing); } + summary.totalCost = cost; summary.costUSD = cost; 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('/'); @@ -138,10 +145,13 @@ 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, + totalCost: cost, costUSD: cost, models: rowModels, }); @@ -155,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 }, ], ]); @@ -178,8 +188,10 @@ 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, cachedInputTokens: 100, outputTokens: 500, reasoningOutputTokens: 0, @@ -188,8 +200,10 @@ 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, cachedInputTokens: 100, outputTokens: 200, reasoningOutputTokens: 30, @@ -198,8 +212,10 @@ 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, cachedInputTokens: 0, outputTokens: 300, reasoningOutputTokens: 0, @@ -223,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 + @@ -231,6 +247,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); }); }); 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; 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 61dbd538..2bb2554f 100644 --- a/apps/mcp/src/codex.ts +++ b/apps/mcp/src/codex.ts @@ -1,56 +1,75 @@ 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(), - 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 = { @@ -70,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; } @@ -104,17 +123,124 @@ async function runCodexCliJson( cliArgs.push('--no-offline'); } - return executeCliCommand(executable, cliArgs, { + return cliUtils.executeCliCommand(executable, cliArgs, { // Keep default log level to allow JSON output }); } +/** + * 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, + 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); - return codexDailyResponseSchema.parse(JSON.parse(raw)); + const parsed = parseCodexJsonOutput(raw, 'daily'); + 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)); + 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: {} }); + 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; + } + } + }); + + 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/apps/opencode/src/_shared-args.ts b/apps/opencode/src/_shared-args.ts new file mode 100644 index 00000000..07a8a196 --- /dev/null +++ b/apps/opencode/src/_shared-args.ts @@ -0,0 +1,65 @@ +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) + */ +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 { + const parsed = v.parse(filterDateSchema, value); + if (parseYYYYMMDD(parsed) == null) { + throw new Error('Date must be a valid calendar date (YYYYMMDD).'); + } + return parsed; +} + +/** + * 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; + +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/commands/daily.ts b/apps/opencode/src/commands/daily.ts index 1ad9b1a8..ca3d1a7b 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,28 +21,39 @@ 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); - const entries = await loadOpenCodeMessages(); + if (jsonOutput) { + logger.level = 0; + } + + 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 }) - : 'No OpenCode usage data found.'; - // eslint-disable-next-line no-console - console.log(output); + if (jsonOutput) { + const emptyTotals = { + inputTokens: 0, + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 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; } @@ -52,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[]; @@ -62,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), @@ -95,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), }; @@ -145,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), ]); @@ -158,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 453795c5..ee76330d 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,28 +21,39 @@ 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); - const entries = await loadOpenCodeMessages(); + if (jsonOutput) { + logger.level = 0; + } + + 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 }) - : 'No OpenCode usage data found.'; - // eslint-disable-next-line no-console - console.log(output); + if (jsonOutput) { + const emptyTotals = { + inputTokens: 0, + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 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; } @@ -52,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[]; @@ -62,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), @@ -95,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), }; @@ -145,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), ]); @@ -158,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 c36467c0..f5cf7aa5 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,31 +21,41 @@ 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); - const [entries, sessionMetadataMap] = await Promise.all([ - loadOpenCodeMessages(), - loadOpenCodeSessions(), - ]); + if (jsonOutput) { + logger.level = 0; + } + + 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) { - 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, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 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; } @@ -57,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[]; @@ -70,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; @@ -79,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); @@ -89,7 +101,8 @@ export const sessionCommand = define({ } } - const totalTokens = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens; + const totalTokens = + inputTokens + outputTokens + cacheCreationInputTokens + cacheReadInputTokens; const metadata = sessionMetadataMap.get(sessionID); @@ -99,8 +112,8 @@ export const sessionCommand = define({ parentID: metadata?.parentID ?? null, inputTokens, outputTokens, - cacheCreationTokens, - cacheReadTokens, + cacheCreationInputTokens, + cacheReadInputTokens, totalTokens, totalCost, modelsUsed: Array.from(modelsSet), @@ -113,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), }; @@ -172,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), ]); @@ -186,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), ]); @@ -197,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 = @@ -213,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)), ]); @@ -227,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 011e8204..a70d4f76 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,28 +46,39 @@ 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); - const entries = await loadOpenCodeMessages(); + if (jsonOutput) { + logger.level = 0; + } + + 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 }) - : 'No OpenCode usage data found.'; - // eslint-disable-next-line no-console - console.log(output); + if (jsonOutput) { + const emptyTotals = { + inputTokens: 0, + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 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; } @@ -77,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[]; @@ -87,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), @@ -120,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), }; @@ -170,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), ]); @@ -183,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)), ]); diff --git a/apps/opencode/src/date-utils.ts b/apps/opencode/src/date-utils.ts new file mode 100644 index 00000000..4472af76 --- /dev/null +++ b/apps/opencode/src/date-utils.ts @@ -0,0 +1,182 @@ +/** + * @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 + */ +export 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(Date.UTC(year, month, day)); + // Check if the date is valid (e.g., not Feb 30) + if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month || date.getUTCDate() !== 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.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()), + ); + + if (since != null) { + const sinceDate = parseYYYYMMDD(since); + if (sinceDate == null) { + return false; + } + const normalizedSince = new Date( + Date.UTC(sinceDate.getUTCFullYear(), sinceDate.getUTCMonth(), sinceDate.getUTCDate()), + ); + if (normalizedDate < normalizedSince) { + return false; + } + } + + if (until != null) { + const untilDate = parseYYYYMMDD(until); + if (untilDate == null) { + return false; + } + const normalizedUntil = new Date( + Date.UTC(untilDate.getUTCFullYear(), untilDate.getUTCMonth(), untilDate.getUTCDate()), + ); + 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?.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?.getUTCFullYear()).toBe(2024); + expect(date?.getUTCMonth()).toBe(1); // February + expect(date?.getUTCDate()).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); + }); + }); + }); +} diff --git a/apps/pi/src/commands/daily.ts b/apps/pi/src/commands/daily.ts index 5e372f4d..7bb7acc0 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.'); } @@ -74,6 +82,7 @@ export const dailyCommand = define({ outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, + totalTokens: 0, totalCost: 0, }; @@ -84,6 +93,11 @@ export const dailyCommand = define({ totals.cacheReadTokens += 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/monthly.ts b/apps/pi/src/commands/monthly.ts index 105c74af..e45e29ac 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.'); } @@ -74,6 +82,7 @@ export const monthlyCommand = define({ outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, + totalTokens: 0, totalCost: 0, }; @@ -85,6 +94,12 @@ export const monthlyCommand = define({ totals.totalCost += d.totalCost; } + totals.totalTokens = + totals.inputTokens + + totals.outputTokens + + totals.cacheCreationTokens + + totals.cacheReadTokens; + if (ctx.values.json) { log( JSON.stringify( diff --git a/apps/pi/src/commands/session.ts b/apps/pi/src/commands/session.ts index d3cc8432..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,26 +74,23 @@ export const sessionCommand = define({ if (piData.length === 0) { if (ctx.values.json) { - log(JSON.stringify([])); + const totals = createEmptyTotals(); + log(JSON.stringify({ sessions: [], totals }, null, 2)); } else { logger.warn('No usage data found.'); } process.exit(0); } - const totals = { - inputTokens: 0, - outputTokens: 0, - cacheCreationTokens: 0, - cacheReadTokens: 0, - totalCost: 0, - }; + const totals = createEmptyTotals(); for (const d of piData) { totals.inputTokens += d.inputTokens; 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/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))