diff --git a/apps/codex/README.md b/apps/codex/README.md index b4434607..5e98c813 100644 --- a/apps/codex/README.md +++ b/apps/codex/README.md @@ -63,6 +63,9 @@ npx @ccusage/codex@latest daily --since 20250911 --until 20250917 # JSON output for scripting npx @ccusage/codex@latest daily --json +# Weekly usage grouped by week +npx @ccusage/codex@latest weekly + # Monthly usage grouped by month npx @ccusage/codex@latest monthly @@ -86,7 +89,7 @@ Useful environment variables: - 📊 Responsive terminal tables shared with the `ccusage` CLI - 💵 Offline-first pricing cache with automatic LiteLLM refresh when needed - 🤖 Per-model token and cost aggregation, including cached token accounting -- 📅 Daily and monthly rollups with identical CLI options +- 📅 Daily, weekly, and monthly rollups with identical CLI options - 📄 JSON output for further processing or scripting ## Documentation diff --git a/apps/codex/src/_types.ts b/apps/codex/src/_types.ts index 3540e218..936ee5eb 100644 --- a/apps/codex/src/_types.ts +++ b/apps/codex/src/_types.ts @@ -31,6 +31,13 @@ export type MonthlyUsageSummary = { models: Map; } & TokenUsageDelta; +export type WeeklyUsageSummary = { + week: string; + firstTimestamp: string; + costUSD: number; + models: Map; +} & TokenUsageDelta; + export type SessionUsageSummary = { sessionId: string; firstTimestamp: string; @@ -76,6 +83,17 @@ export type MonthlyReportRow = { models: Record; }; +export type WeeklyReportRow = { + week: string; + inputTokens: number; + cachedInputTokens: number; + outputTokens: number; + reasoningOutputTokens: number; + totalTokens: number; + costUSD: number; + models: Record; +}; + export type SessionReportRow = { sessionId: string; lastActivity: string; diff --git a/apps/codex/src/commands/weekly.ts b/apps/codex/src/commands/weekly.ts new file mode 100644 index 00000000..31d2bbb8 --- /dev/null +++ b/apps/codex/src/commands/weekly.ts @@ -0,0 +1,187 @@ +import process from 'node:process'; +import { + addEmptySeparatorRow, + formatCurrency, + formatDateCompact, + formatModelsDisplayMultiline, + formatNumber, + ResponsiveTable, +} from '@ccusage/terminal/table'; +import { define } from 'gunshi'; +import pc from 'picocolors'; +import { DEFAULT_TIMEZONE } from '../_consts.ts'; +import { sharedArgs } from '../_shared-args.ts'; +import { formatModelsList, splitUsageTokens } from '../command-utils.ts'; +import { loadTokenUsageEvents } from '../data-loader.ts'; +import { normalizeFilterDate } from '../date-utils.ts'; +import { log, logger } from '../logger.ts'; +import { CodexPricingSource } from '../pricing.ts'; +import { buildWeeklyReport } from '../weekly-report.ts'; + +const TABLE_COLUMN_COUNT = 8; + +export const weeklyCommand = define({ + name: 'weekly', + description: 'Show Codex token usage grouped by week', + args: sharedArgs, + async run(ctx) { + const jsonOutput = Boolean(ctx.values.json); + if (jsonOutput) { + logger.level = 0; + } + + let since: string | undefined; + let until: string | undefined; + + try { + since = normalizeFilterDate(ctx.values.since); + until = normalizeFilterDate(ctx.values.until); + } catch (error) { + logger.error(String(error)); + process.exit(1); + } + + const { events, missingDirectories } = await loadTokenUsageEvents(); + + for (const missing of missingDirectories) { + logger.warn(`Codex session directory not found: ${missing}`); + } + + if (events.length === 0) { + log(jsonOutput ? JSON.stringify({ weekly: [], totals: null }) : 'No Codex usage data found.'); + return; + } + + const pricingSource = new CodexPricingSource({ + offline: ctx.values.offline, + }); + + try { + const rows = await buildWeeklyReport(events, { + pricingSource, + timezone: ctx.values.timezone, + locale: ctx.values.locale, + since, + until, + }); + + if (rows.length === 0) { + log( + jsonOutput + ? JSON.stringify({ weekly: [], totals: null }) + : 'No Codex usage data found for provided filters.', + ); + return; + } + + const totals = rows.reduce( + (acc, row) => { + acc.inputTokens += row.inputTokens; + acc.cachedInputTokens += row.cachedInputTokens; + acc.outputTokens += row.outputTokens; + acc.reasoningOutputTokens += row.reasoningOutputTokens; + acc.totalTokens += row.totalTokens; + acc.costUSD += row.costUSD; + return acc; + }, + { + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 0, + costUSD: 0, + }, + ); + + if (jsonOutput) { + log( + JSON.stringify( + { + weekly: rows, + totals, + }, + null, + 2, + ), + ); + return; + } + + logger.box( + `Codex Token Usage Report - Weekly (Timezone: ${ctx.values.timezone ?? DEFAULT_TIMEZONE})`, + ); + + const table: ResponsiveTable = new ResponsiveTable({ + head: [ + 'Week', + 'Models', + 'Input', + 'Output', + 'Reasoning', + 'Cache Read', + 'Total Tokens', + 'Cost (USD)', + ], + colAligns: ['left', 'left', 'right', 'right', 'right', 'right', 'right', 'right'], + compactHead: ['Week', 'Models', 'Input', 'Output', 'Cost (USD)'], + compactColAligns: ['left', 'left', 'right', 'right', 'right'], + compactThreshold: 100, + forceCompact: ctx.values.compact, + style: { head: ['cyan'] }, + dateFormatter: (dateStr: string) => formatDateCompact(dateStr), + }); + + const totalsForDisplay = { + inputTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + cacheReadTokens: 0, + totalTokens: 0, + costUSD: 0, + }; + + 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; + + table.push([ + row.week, + formatModelsDisplayMultiline(formatModelsList(row.models)), + formatNumber(split.inputTokens), + formatNumber(split.outputTokens), + formatNumber(split.reasoningTokens), + formatNumber(split.cacheReadTokens), + formatNumber(row.totalTokens), + formatCurrency(row.costUSD), + ]); + } + + addEmptySeparatorRow(table, TABLE_COLUMN_COUNT); + table.push([ + pc.yellow('Total'), + '', + pc.yellow(formatNumber(totalsForDisplay.inputTokens)), + pc.yellow(formatNumber(totalsForDisplay.outputTokens)), + pc.yellow(formatNumber(totalsForDisplay.reasoningTokens)), + pc.yellow(formatNumber(totalsForDisplay.cacheReadTokens)), + pc.yellow(formatNumber(totalsForDisplay.totalTokens)), + pc.yellow(formatCurrency(totalsForDisplay.costUSD)), + ]); + + log(table.toString()); + + if (table.isCompactMode()) { + logger.info('\nRunning in Compact Mode'); + logger.info('Expand terminal width to see cache metrics and total tokens'); + } + } finally { + pricingSource[Symbol.dispose](); + } + }, +}); diff --git a/apps/codex/src/date-utils.ts b/apps/codex/src/date-utils.ts index 8592c933..294b1e03 100644 --- a/apps/codex/src/date-utils.ts +++ b/apps/codex/src/date-utils.ts @@ -53,6 +53,17 @@ export function isWithinRange(dateKey: string, since?: string, until?: string): return true; } +export function toWeekKey(timestamp: string, timezone?: string): string { + const dateKey = toDateKey(timestamp, timezone); + const [yearStr = '0', monthStr = '1', dayStr = '1'] = dateKey.split('-'); + const year = Number.parseInt(yearStr, 10); + const month = Number.parseInt(monthStr, 10); + const day = Number.parseInt(dayStr, 10); + const date = new Date(Date.UTC(year, month - 1, day)); + date.setUTCDate(date.getUTCDate() - date.getUTCDay()); + return date.toISOString().slice(0, 10); +} + export function formatDisplayDate(dateKey: string, locale?: string, _timezone?: string): string { // dateKey is already computed for the target timezone via toDateKey(). // Treat it as a plain calendar date and avoid shifting it by applying a timezone. diff --git a/apps/codex/src/run.ts b/apps/codex/src/run.ts index d6b05386..db750bb4 100644 --- a/apps/codex/src/run.ts +++ b/apps/codex/src/run.ts @@ -4,9 +4,11 @@ import { description, name, version } from '../package.json'; import { dailyCommand } from './commands/daily.ts'; import { monthlyCommand } from './commands/monthly.ts'; import { sessionCommand } from './commands/session.ts'; +import { weeklyCommand } from './commands/weekly.ts'; const subCommands = new Map([ ['daily', dailyCommand], + ['weekly', weeklyCommand], ['monthly', monthlyCommand], ['session', sessionCommand], ]); diff --git a/apps/codex/src/weekly-report.ts b/apps/codex/src/weekly-report.ts new file mode 100644 index 00000000..1adfdefc --- /dev/null +++ b/apps/codex/src/weekly-report.ts @@ -0,0 +1,192 @@ +import type { + ModelUsage, + PricingSource, + TokenUsageEvent, + WeeklyReportRow, + WeeklyUsageSummary, +} from './_types.ts'; +import { formatDisplayDate, isWithinRange, toDateKey, toWeekKey } from './date-utils.ts'; +import { addUsage, calculateCostUSD, createEmptyUsage } from './token-utils.ts'; + +export type WeeklyReportOptions = { + timezone?: string; + locale?: string; + since?: string; + until?: string; + pricingSource: PricingSource; +}; + +function createSummary(week: string, initialTimestamp: string): WeeklyUsageSummary { + return { + week, + firstTimestamp: initialTimestamp, + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 0, + costUSD: 0, + models: new Map(), + }; +} + +export async function buildWeeklyReport( + events: TokenUsageEvent[], + options: WeeklyReportOptions, +): Promise { + const timezone = options.timezone; + const locale = options.locale; + const since = options.since; + const until = options.until; + const pricingSource = options.pricingSource; + + const summaries = new Map(); + + for (const event of events) { + const modelName = event.model?.trim(); + if (modelName == null || modelName === '') { + continue; + } + + const dateKey = toDateKey(event.timestamp, timezone); + if (!isWithinRange(dateKey, since, until)) { + continue; + } + + const weekKey = toWeekKey(event.timestamp, timezone); + const summary = summaries.get(weekKey) ?? createSummary(weekKey, event.timestamp); + if (!summaries.has(weekKey)) { + summaries.set(weekKey, summary); + } + + addUsage(summary, event); + const modelUsage: ModelUsage = summary.models.get(modelName) ?? { + ...createEmptyUsage(), + isFallback: false, + }; + if (!summary.models.has(modelName)) { + summary.models.set(modelName, modelUsage); + } + addUsage(modelUsage, event); + if (event.isFallbackModel === true) { + modelUsage.isFallback = true; + } + } + + const uniqueModels = new Set(); + for (const summary of summaries.values()) { + for (const modelName of summary.models.keys()) { + uniqueModels.add(modelName); + } + } + + const modelPricing = new Map>>(); + for (const modelName of uniqueModels) { + modelPricing.set(modelName, await pricingSource.getPricing(modelName)); + } + + const rows: WeeklyReportRow[] = []; + const sortedSummaries = Array.from(summaries.values()).sort((a, b) => + a.week.localeCompare(b.week), + ); + + for (const summary of sortedSummaries) { + let cost = 0; + for (const [modelName, usage] of summary.models) { + const pricing = modelPricing.get(modelName); + if (pricing == null) { + continue; + } + cost += calculateCostUSD(usage, pricing); + } + summary.costUSD = cost; + + const rowModels: Record = {}; + for (const [modelName, usage] of summary.models) { + rowModels[modelName] = { ...usage }; + } + + rows.push({ + week: formatDisplayDate(summary.week, locale, timezone), + inputTokens: summary.inputTokens, + cachedInputTokens: summary.cachedInputTokens, + outputTokens: summary.outputTokens, + reasoningOutputTokens: summary.reasoningOutputTokens, + totalTokens: summary.totalTokens, + costUSD: cost, + models: rowModels, + }); + } + + return rows; +} + +if (import.meta.vitest != null) { + describe('buildWeeklyReport', () => { + it('aggregates events by week and calculates costs', async () => { + const pricing = new Map([ + [ + 'gpt-5', + { inputCostPerMToken: 1.25, cachedInputCostPerMToken: 0.125, outputCostPerMToken: 10 }, + ], + [ + 'gpt-5-mini', + { inputCostPerMToken: 0.6, cachedInputCostPerMToken: 0.06, outputCostPerMToken: 2 }, + ], + ]); + const stubPricingSource: PricingSource = { + async getPricing(model: string) { + const value = pricing.get(model); + if (value == null) { + throw new Error(`Missing pricing for ${model}`); + } + return value; + }, + }; + const report = await buildWeeklyReport( + [ + { + sessionId: 'session-1', + timestamp: '2025-09-14T03:00:00.000Z', + model: 'gpt-5', + inputTokens: 1_000, + cachedInputTokens: 200, + outputTokens: 500, + reasoningOutputTokens: 0, + totalTokens: 1_500, + }, + { + sessionId: 'session-1', + timestamp: '2025-09-19T05:00:00.000Z', + model: 'gpt-5-mini', + inputTokens: 400, + cachedInputTokens: 100, + outputTokens: 200, + reasoningOutputTokens: 50, + totalTokens: 750, + }, + ], + { + pricingSource: stubPricingSource, + since: '2025-09-14', + until: '2025-09-20', + }, + ); + + expect(report).toHaveLength(1); + const week = report[0]!; + expect(week.inputTokens).toBe(1_400); + expect(week.cachedInputTokens).toBe(300); + expect(week.outputTokens).toBe(700); + expect(week.reasoningOutputTokens).toBe(50); + const expectedCost = + (800 / 1_000_000) * 1.25 + + (200 / 1_000_000) * 0.125 + + (500 / 1_000_000) * 10 + + (300 / 1_000_000) * 0.6 + + (100 / 1_000_000) * 0.06 + + (200 / 1_000_000) * 2; + expect(week.costUSD).toBeCloseTo(expectedCost, 10); + }); + }); +}