diff --git a/apps/codex/README.md b/apps/codex/README.md index b4434607..4930cc70 100644 --- a/apps/codex/README.md +++ b/apps/codex/README.md @@ -60,6 +60,12 @@ npx @ccusage/codex@latest daily # Date range filtering npx @ccusage/codex@latest daily --since 20250911 --until 20250917 +# Multiple Codex homes (multi-account aggregation) +npx @ccusage/codex@latest monthly --codex-home ~/.codex-work,~/.codex-personal + +# Multiple Codex homes with explicit account labels + per-account rows +npx @ccusage/codex@latest monthly --codex-home work=~/.codex-work,personal=~/.codex-personal --by-account + # JSON output for scripting npx @ccusage/codex@latest daily --json @@ -75,8 +81,8 @@ npx @ccusage/codex@latest sessions Useful environment variables: -- `CODEX_HOME` – override the root directory that contains Codex session folders -- `LOG_LEVEL` – controla consola log verbosity (0 silent … 5 trace) +- `CODEX_HOME` – override Codex home(s). Supports single path or comma-separated list for multi-account aggregation (e.g. `work=~/.codex-work,personal=~/.codex-personal`) +- `LOG_LEVEL` – controls console log verbosity (0 silent … 5 trace) ℹ️ The CLI now relies on the model metadata recorded in each `turn_context`. Sessions emitted during early September 2025 that lack this metadata are skipped to avoid mispricing. Newer builds of the Codex CLI restore the model field, and aliases such as `gpt-5-codex` automatically resolve to the correct LiteLLM pricing entry. 📦 For legacy JSONL files that never emitted `turn_context` metadata, the CLI falls back to treating the tokens as `gpt-5` so that usage still appears in reports (pricing is therefore approximate for those sessions). In JSON output you will also see `"isFallback": true` on those model entries. diff --git a/apps/codex/src/_shared-args.ts b/apps/codex/src/_shared-args.ts index 4617e700..ec60a507 100644 --- a/apps/codex/src/_shared-args.ts +++ b/apps/codex/src/_shared-args.ts @@ -18,6 +18,16 @@ export const sharedArgs = { short: 'u', description: 'Filter until date (inclusive)', }, + codexHome: { + type: 'string', + description: + 'Codex home path(s). Accept comma-separated values and optional label=path entries for multi-account usage', + }, + byAccount: { + type: 'boolean', + description: 'Group report rows by account when multiple Codex homes are configured', + default: false, + }, timezone: { type: 'string', short: 'z', diff --git a/apps/codex/src/_types.ts b/apps/codex/src/_types.ts index 3540e218..673729d0 100644 --- a/apps/codex/src/_types.ts +++ b/apps/codex/src/_types.ts @@ -6,9 +6,15 @@ export type TokenUsageDelta = { totalTokens: number; }; +export type SessionSource = { + account: string; + directory: string; +}; + export type TokenUsageEvent = TokenUsageDelta & { timestamp: string; sessionId: string; + account?: string; model?: string; isFallbackModel?: boolean; }; @@ -19,6 +25,7 @@ export type ModelUsage = TokenUsageDelta & { export type DailyUsageSummary = { date: string; + account?: string; firstTimestamp: string; costUSD: number; models: Map; @@ -26,6 +33,7 @@ export type DailyUsageSummary = { export type MonthlyUsageSummary = { month: string; + account?: string; firstTimestamp: string; costUSD: number; models: Map; @@ -33,6 +41,7 @@ export type MonthlyUsageSummary = { export type SessionUsageSummary = { sessionId: string; + account?: string; firstTimestamp: string; lastTimestamp: string; costUSD: number; @@ -56,6 +65,7 @@ export type PricingSource = { export type DailyReportRow = { date: string; + account?: string; inputTokens: number; cachedInputTokens: number; outputTokens: number; @@ -67,6 +77,7 @@ export type DailyReportRow = { export type MonthlyReportRow = { month: string; + account?: string; inputTokens: number; cachedInputTokens: number; outputTokens: number; @@ -78,6 +89,7 @@ export type MonthlyReportRow = { export type SessionReportRow = { sessionId: string; + account?: string; lastActivity: string; sessionFile: string; directory: string; diff --git a/apps/codex/src/commands/daily.ts b/apps/codex/src/commands/daily.ts index 6dc7c6f0..cad097d3 100644 --- a/apps/codex/src/commands/daily.ts +++ b/apps/codex/src/commands/daily.ts @@ -2,10 +2,8 @@ import process from 'node:process'; import { addEmptySeparatorRow, formatCurrency, - formatDateCompact, formatModelsDisplayMultiline, formatNumber, - ResponsiveTable, } from '@ccusage/terminal/table'; import { define } from 'gunshi'; import pc from 'picocolors'; @@ -17,8 +15,8 @@ import { loadTokenUsageEvents } from '../data-loader.ts'; import { normalizeFilterDate } from '../date-utils.ts'; import { log, logger } from '../logger.ts'; import { CodexPricingSource } from '../pricing.ts'; - -const TABLE_COLUMN_COUNT = 8; +import { resolveSessionSources } from '../session-sources.ts'; +import { createUsageResponsiveTable } from './usage-table.ts'; export const dailyCommand = define({ name: 'daily', @@ -41,7 +39,12 @@ export const dailyCommand = define({ process.exit(1); } - const { events, missingDirectories } = await loadTokenUsageEvents(); + const sessionSources = resolveSessionSources(ctx.values.codexHome); + const byAccount = ctx.values.byAccount === true; + const hasMultipleAccounts = sessionSources.length > 1; + const { events, missingDirectories } = await loadTokenUsageEvents({ + sessionSources, + }); for (const missing of missingDirectories) { logger.warn(`Codex session directory not found: ${missing}`); @@ -62,6 +65,7 @@ export const dailyCommand = define({ locale: ctx.values.locale, since, until, + byAccount, }); if (rows.length === 0) { @@ -111,24 +115,17 @@ export const dailyCommand = define({ `Codex Token Usage Report - Daily (Timezone: ${ctx.values.timezone ?? DEFAULT_TIMEZONE})`, ); - const table: ResponsiveTable = new ResponsiveTable({ - head: [ - 'Date', - 'Models', - 'Input', - 'Output', - 'Reasoning', - 'Cache Read', - 'Total Tokens', - 'Cost (USD)', - ], - colAligns: ['left', 'left', 'right', 'right', 'right', 'right', 'right', 'right'], - compactHead: ['Date', 'Models', 'Input', 'Output', 'Cost (USD)'], - compactColAligns: ['left', 'left', 'right', 'right', 'right'], - compactThreshold: 100, + if (hasMultipleAccounts && !byAccount) { + logger.info( + 'Aggregating usage across multiple accounts. Use --by-account to split rows by account.', + ); + } + + const includeAccountColumn = byAccount; + const { table, tableColumnCount } = createUsageResponsiveTable({ + mode: 'daily', + includeAccountColumn, forceCompact: ctx.values.compact, - style: { head: ['cyan'] }, - dateFormatter: (dateStr: string) => formatDateCompact(dateStr), }); const totalsForDisplay = { @@ -149,30 +146,58 @@ export const dailyCommand = define({ totalsForDisplay.totalTokens += row.totalTokens; totalsForDisplay.costUSD += row.costUSD; + if (includeAccountColumn) { + table.push([ + row.date, + row.account ?? 'default', + formatModelsDisplayMultiline(formatModelsList(row.models)), + formatNumber(split.inputTokens), + formatNumber(split.outputTokens), + formatNumber(split.reasoningTokens), + formatNumber(split.cacheReadTokens), + formatNumber(row.totalTokens), + formatCurrency(row.costUSD), + ]); + } else { + table.push([ + row.date, + formatModelsDisplayMultiline(formatModelsList(row.models)), + formatNumber(split.inputTokens), + formatNumber(split.outputTokens), + formatNumber(split.reasoningTokens), + formatNumber(split.cacheReadTokens), + formatNumber(row.totalTokens), + formatCurrency(row.costUSD), + ]); + } + } + + addEmptySeparatorRow(table, tableColumnCount); + if (includeAccountColumn) { table.push([ - row.date, - formatModelsDisplayMultiline(formatModelsList(row.models)), - formatNumber(split.inputTokens), - formatNumber(split.outputTokens), - formatNumber(split.reasoningTokens), - formatNumber(split.cacheReadTokens), - formatNumber(row.totalTokens), - formatCurrency(row.costUSD), + 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)), + ]); + } else { + 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)), ]); } - 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()) { diff --git a/apps/codex/src/commands/monthly.ts b/apps/codex/src/commands/monthly.ts index 2a5abc5e..eeec49af 100644 --- a/apps/codex/src/commands/monthly.ts +++ b/apps/codex/src/commands/monthly.ts @@ -2,10 +2,8 @@ import process from 'node:process'; import { addEmptySeparatorRow, formatCurrency, - formatDateCompact, formatModelsDisplayMultiline, formatNumber, - ResponsiveTable, } from '@ccusage/terminal/table'; import { define } from 'gunshi'; import pc from 'picocolors'; @@ -17,8 +15,8 @@ import { normalizeFilterDate } from '../date-utils.ts'; import { log, logger } from '../logger.ts'; import { buildMonthlyReport } from '../monthly-report.ts'; import { CodexPricingSource } from '../pricing.ts'; - -const TABLE_COLUMN_COUNT = 8; +import { resolveSessionSources } from '../session-sources.ts'; +import { createUsageResponsiveTable } from './usage-table.ts'; export const monthlyCommand = define({ name: 'monthly', @@ -41,7 +39,12 @@ export const monthlyCommand = define({ process.exit(1); } - const { events, missingDirectories } = await loadTokenUsageEvents(); + const sessionSources = resolveSessionSources(ctx.values.codexHome); + const byAccount = ctx.values.byAccount === true; + const hasMultipleAccounts = sessionSources.length > 1; + const { events, missingDirectories } = await loadTokenUsageEvents({ + sessionSources, + }); for (const missing of missingDirectories) { logger.warn(`Codex session directory not found: ${missing}`); @@ -64,6 +67,7 @@ export const monthlyCommand = define({ locale: ctx.values.locale, since, until, + byAccount, }); if (rows.length === 0) { @@ -113,24 +117,17 @@ export const monthlyCommand = define({ `Codex Token Usage Report - Monthly (Timezone: ${ctx.values.timezone ?? DEFAULT_TIMEZONE})`, ); - const table: ResponsiveTable = new ResponsiveTable({ - head: [ - 'Month', - 'Models', - 'Input', - 'Output', - 'Reasoning', - 'Cache Read', - 'Total Tokens', - 'Cost (USD)', - ], - colAligns: ['left', 'left', 'right', 'right', 'right', 'right', 'right', 'right'], - compactHead: ['Month', 'Models', 'Input', 'Output', 'Cost (USD)'], - compactColAligns: ['left', 'left', 'right', 'right', 'right'], - compactThreshold: 100, + if (hasMultipleAccounts && !byAccount) { + logger.info( + 'Aggregating usage across multiple accounts. Use --by-account to split rows by account.', + ); + } + + const includeAccountColumn = byAccount; + const { table, tableColumnCount } = createUsageResponsiveTable({ + mode: 'monthly', + includeAccountColumn, forceCompact: ctx.values.compact, - style: { head: ['cyan'] }, - dateFormatter: (dateStr: string) => formatDateCompact(dateStr), }); const totalsForDisplay = { @@ -151,30 +148,58 @@ export const monthlyCommand = define({ totalsForDisplay.totalTokens += row.totalTokens; totalsForDisplay.costUSD += row.costUSD; + if (includeAccountColumn) { + table.push([ + row.month, + row.account ?? 'default', + formatModelsDisplayMultiline(formatModelsList(row.models)), + formatNumber(split.inputTokens), + formatNumber(split.outputTokens), + formatNumber(split.reasoningTokens), + formatNumber(split.cacheReadTokens), + formatNumber(row.totalTokens), + formatCurrency(row.costUSD), + ]); + } else { + table.push([ + row.month, + formatModelsDisplayMultiline(formatModelsList(row.models)), + formatNumber(split.inputTokens), + formatNumber(split.outputTokens), + formatNumber(split.reasoningTokens), + formatNumber(split.cacheReadTokens), + formatNumber(row.totalTokens), + formatCurrency(row.costUSD), + ]); + } + } + + addEmptySeparatorRow(table, tableColumnCount); + if (includeAccountColumn) { table.push([ - row.month, - formatModelsDisplayMultiline(formatModelsList(row.models)), - formatNumber(split.inputTokens), - formatNumber(split.outputTokens), - formatNumber(split.reasoningTokens), - formatNumber(split.cacheReadTokens), - formatNumber(row.totalTokens), - formatCurrency(row.costUSD), + 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)), + ]); + } else { + 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)), ]); } - 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()) { diff --git a/apps/codex/src/commands/session.ts b/apps/codex/src/commands/session.ts index 5dbe1a7e..cfa0d25c 100644 --- a/apps/codex/src/commands/session.ts +++ b/apps/codex/src/commands/session.ts @@ -2,10 +2,8 @@ import process from 'node:process'; import { addEmptySeparatorRow, formatCurrency, - formatDateCompact, formatModelsDisplayMultiline, formatNumber, - ResponsiveTable, } from '@ccusage/terminal/table'; import { define } from 'gunshi'; import pc from 'picocolors'; @@ -22,8 +20,8 @@ import { import { log, logger } from '../logger.ts'; import { CodexPricingSource } from '../pricing.ts'; import { buildSessionReport } from '../session-report.ts'; - -const TABLE_COLUMN_COUNT = 11; +import { resolveSessionSources } from '../session-sources.ts'; +import { createUsageResponsiveTable } from './usage-table.ts'; export const sessionCommand = define({ name: 'session', @@ -46,7 +44,12 @@ export const sessionCommand = define({ process.exit(1); } - const { events, missingDirectories } = await loadTokenUsageEvents(); + const sessionSources = resolveSessionSources(ctx.values.codexHome); + const hasMultipleAccounts = sessionSources.length > 1; + const byAccount = ctx.values.byAccount === true || hasMultipleAccounts; + const { events, missingDirectories } = await loadTokenUsageEvents({ + sessionSources, + }); for (const missing of missingDirectories) { logger.warn(`Codex session directory not found: ${missing}`); @@ -69,6 +72,7 @@ export const sessionCommand = define({ locale: ctx.values.locale, since, until, + byAccount, }); if (rows.length === 0) { @@ -118,39 +122,11 @@ export const sessionCommand = define({ `Codex Token Usage Report - Sessions (Timezone: ${ctx.values.timezone ?? DEFAULT_TIMEZONE})`, ); - const table: ResponsiveTable = new ResponsiveTable({ - head: [ - 'Date', - 'Directory', - 'Session', - 'Models', - 'Input', - 'Output', - 'Reasoning', - 'Cache Read', - 'Total Tokens', - 'Cost (USD)', - 'Last Activity', - ], - colAligns: [ - 'left', - 'left', - 'left', - 'left', - 'right', - 'right', - 'right', - 'right', - 'right', - 'right', - 'left', - ], - compactHead: ['Date', 'Directory', 'Session', 'Input', 'Output', 'Cost (USD)'], - compactColAligns: ['left', 'left', 'left', 'right', 'right', 'right'], - compactThreshold: 100, + const includeAccountColumn = byAccount; + const { table, tableColumnCount } = createUsageResponsiveTable({ + mode: 'session', + includeAccountColumn, forceCompact: ctx.values.compact, - style: { head: ['cyan'] }, - dateFormatter: (dateStr: string) => formatDateCompact(dateStr), }); const totalsForDisplay = { @@ -177,42 +153,76 @@ export const sessionCommand = define({ const sessionFile = row.sessionFile; const shortSession = sessionFile.length > 8 ? `…${sessionFile.slice(-8)}` : sessionFile; + if (includeAccountColumn) { + table.push([ + displayDate, + row.account ?? 'default', + directoryDisplay, + shortSession, + formatModelsDisplayMultiline(formatModelsList(row.models)), + formatNumber(split.inputTokens), + formatNumber(split.outputTokens), + formatNumber(split.reasoningTokens), + formatNumber(split.cacheReadTokens), + formatNumber(row.totalTokens), + formatCurrency(row.costUSD), + formatDisplayDateTime(row.lastActivity, ctx.values.locale, ctx.values.timezone), + ]); + } else { + table.push([ + displayDate, + directoryDisplay, + shortSession, + formatModelsDisplayMultiline(formatModelsList(row.models)), + formatNumber(split.inputTokens), + formatNumber(split.outputTokens), + formatNumber(split.reasoningTokens), + formatNumber(split.cacheReadTokens), + formatNumber(row.totalTokens), + formatCurrency(row.costUSD), + formatDisplayDateTime(row.lastActivity, ctx.values.locale, ctx.values.timezone), + ]); + } + } + + addEmptySeparatorRow(table, tableColumnCount); + if (includeAccountColumn) { table.push([ - displayDate, - directoryDisplay, - shortSession, - formatModelsDisplayMultiline(formatModelsList(row.models)), - formatNumber(split.inputTokens), - formatNumber(split.outputTokens), - formatNumber(split.reasoningTokens), - formatNumber(split.cacheReadTokens), - formatNumber(row.totalTokens), - formatCurrency(row.costUSD), - formatDisplayDateTime(row.lastActivity, ctx.values.locale, ctx.values.timezone), + '', + '', + '', + 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)), + '', + ]); + } else { + 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)), + '', ]); } - 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 directories, cache metrics, total tokens, and last activity', + 'Expand terminal width to see details, cache metrics, total tokens, and last activity', ); } } finally { diff --git a/apps/codex/src/commands/usage-table.ts b/apps/codex/src/commands/usage-table.ts new file mode 100644 index 00000000..5f53b102 --- /dev/null +++ b/apps/codex/src/commands/usage-table.ts @@ -0,0 +1,169 @@ +import type { TableCellAlign } from '@ccusage/terminal/table'; +import { formatDateCompact, ResponsiveTable } from '@ccusage/terminal/table'; + +type UsageTableMode = 'daily' | 'monthly' | 'session'; + +type UsageTableSchema = { + head: string[]; + colAligns: TableCellAlign[]; + compactHead: string[]; + compactColAligns: TableCellAlign[]; +}; + +type CreateUsageResponsiveTableOptions = { + mode: UsageTableMode; + includeAccountColumn: boolean; + forceCompact?: boolean; +}; + +const COMPACT_THRESHOLD = 100; + +function createPeriodicUsageTableSchema( + label: 'Date' | 'Month', + includeAccountColumn: boolean, +): UsageTableSchema { + if (includeAccountColumn) { + return { + head: [ + label, + 'Account', + 'Models', + 'Input', + 'Output', + 'Reasoning', + 'Cache Read', + 'Total Tokens', + 'Cost (USD)', + ], + colAligns: ['left', 'left', 'left', 'right', 'right', 'right', 'right', 'right', 'right'], + compactHead: [label, 'Account', 'Models', 'Input', 'Output', 'Cost (USD)'], + compactColAligns: ['left', 'left', 'left', 'right', 'right', 'right'], + }; + } + + return { + head: [ + label, + 'Models', + 'Input', + 'Output', + 'Reasoning', + 'Cache Read', + 'Total Tokens', + 'Cost (USD)', + ], + colAligns: ['left', 'left', 'right', 'right', 'right', 'right', 'right', 'right'], + compactHead: [label, 'Models', 'Input', 'Output', 'Cost (USD)'], + compactColAligns: ['left', 'left', 'right', 'right', 'right'], + }; +} + +function createSessionUsageTableSchema(includeAccountColumn: boolean): UsageTableSchema { + if (includeAccountColumn) { + return { + head: [ + 'Date', + 'Account', + 'Directory', + 'Session', + 'Models', + 'Input', + 'Output', + 'Reasoning', + 'Cache Read', + 'Total Tokens', + 'Cost (USD)', + 'Last Activity', + ], + colAligns: [ + 'left', + 'left', + 'left', + 'left', + 'left', + 'right', + 'right', + 'right', + 'right', + 'right', + 'right', + 'left', + ], + compactHead: ['Date', 'Account', 'Directory', 'Session', 'Input', 'Output', 'Cost (USD)'], + compactColAligns: ['left', 'left', 'left', 'left', 'right', 'right', 'right'], + }; + } + + return { + head: [ + 'Date', + 'Directory', + 'Session', + 'Models', + 'Input', + 'Output', + 'Reasoning', + 'Cache Read', + 'Total Tokens', + 'Cost (USD)', + 'Last Activity', + ], + colAligns: [ + 'left', + 'left', + 'left', + 'left', + 'right', + 'right', + 'right', + 'right', + 'right', + 'right', + 'left', + ], + compactHead: ['Date', 'Directory', 'Session', 'Input', 'Output', 'Cost (USD)'], + compactColAligns: ['left', 'left', 'left', 'right', 'right', 'right'], + }; +} + +function getUsageTableSchema( + mode: UsageTableMode, + includeAccountColumn: boolean, +): UsageTableSchema { + switch (mode) { + case 'daily': { + return createPeriodicUsageTableSchema('Date', includeAccountColumn); + } + case 'monthly': { + return createPeriodicUsageTableSchema('Month', includeAccountColumn); + } + case 'session': { + return createSessionUsageTableSchema(includeAccountColumn); + } + default: { + throw new Error('Unsupported table mode'); + } + } +} + +export function createUsageResponsiveTable(options: CreateUsageResponsiveTableOptions): { + table: ResponsiveTable; + tableColumnCount: number; +} { + const schema = getUsageTableSchema(options.mode, options.includeAccountColumn); + const table = new ResponsiveTable({ + head: schema.head, + colAligns: schema.colAligns, + compactHead: schema.compactHead, + compactColAligns: schema.compactColAligns, + compactThreshold: COMPACT_THRESHOLD, + forceCompact: options.forceCompact, + style: { head: ['cyan'] }, + dateFormatter: (dateStr: string) => formatDateCompact(dateStr), + }); + + return { + table, + tableColumnCount: schema.head.length, + }; +} diff --git a/apps/codex/src/daily-report.ts b/apps/codex/src/daily-report.ts index c44a34a9..c55290a1 100644 --- a/apps/codex/src/daily-report.ts +++ b/apps/codex/src/daily-report.ts @@ -14,12 +14,18 @@ export type DailyReportOptions = { locale?: string; since?: string; until?: string; + byAccount?: boolean; pricingSource: PricingSource; }; -function createSummary(date: string, initialTimestamp: string): DailyUsageSummary { +function createSummary( + date: string, + initialTimestamp: string, + account?: string, +): DailyUsageSummary { return { date, + account, firstTimestamp: initialTimestamp, inputTokens: 0, cachedInputTokens: 0, @@ -39,6 +45,7 @@ export async function buildDailyReport( const locale = options.locale; const since = options.since; const until = options.until; + const byAccount = options.byAccount === true; const pricingSource = options.pricingSource; const summaries = new Map(); @@ -54,9 +61,15 @@ export async function buildDailyReport( continue; } - const summary = summaries.get(dateKey) ?? createSummary(dateKey, event.timestamp); - if (!summaries.has(dateKey)) { - summaries.set(dateKey, summary); + const accountCandidate = event.account?.trim(); + const account = + accountCandidate == null || accountCandidate === '' ? 'default' : accountCandidate; + const summaryKey = byAccount ? `${dateKey}\x00${account}` : dateKey; + const summary = + summaries.get(summaryKey) ?? + createSummary(dateKey, event.timestamp, byAccount ? account : undefined); + if (!summaries.has(summaryKey)) { + summaries.set(summaryKey, summary); } addUsage(summary, event); @@ -87,9 +100,13 @@ export async function buildDailyReport( const rows: DailyReportRow[] = []; - const sortedSummaries = Array.from(summaries.values()).sort((a, b) => - a.date.localeCompare(b.date), - ); + const sortedSummaries = Array.from(summaries.values()).sort((a, b) => { + const dateCompare = a.date.localeCompare(b.date); + if (dateCompare !== 0) { + return dateCompare; + } + return (a.account ?? '').localeCompare(b.account ?? ''); + }); for (const summary of sortedSummaries) { let cost = 0; for (const [modelName, usage] of summary.models) { @@ -108,6 +125,7 @@ export async function buildDailyReport( rows.push({ date: formatDisplayDate(summary.date, locale, timezone), + account: summary.account, inputTokens: summary.inputTokens, cachedInputTokens: summary.cachedInputTokens, outputTokens: summary.outputTokens, @@ -201,5 +219,51 @@ if (import.meta.vitest != null) { (200 / 1_000_000) * 2; expect(first.costUSD).toBeCloseTo(expectedCost, 10); }); + + it('groups by account when enabled', async () => { + const stubPricingSource: PricingSource = { + async getPricing(): Promise { + return { + inputCostPerMToken: 1, + cachedInputCostPerMToken: 0.1, + outputCostPerMToken: 10, + }; + }, + }; + + const report = await buildDailyReport( + [ + { + account: 'work', + sessionId: 'session-1', + timestamp: '2025-09-11T03:00:00.000Z', + model: 'gpt-5', + inputTokens: 1_000, + cachedInputTokens: 0, + outputTokens: 500, + reasoningOutputTokens: 0, + totalTokens: 1_500, + }, + { + account: 'personal', + sessionId: 'session-2', + timestamp: '2025-09-11T05:00:00.000Z', + model: 'gpt-5', + inputTokens: 500, + cachedInputTokens: 0, + outputTokens: 100, + reasoningOutputTokens: 0, + totalTokens: 600, + }, + ], + { + pricingSource: stubPricingSource, + byAccount: true, + }, + ); + + expect(report).toHaveLength(2); + expect(report.map((row) => row.account)).toEqual(['personal', 'work']); + }); }); } diff --git a/apps/codex/src/data-loader.ts b/apps/codex/src/data-loader.ts index ef23a8f5..494d3e08 100644 --- a/apps/codex/src/data-loader.ts +++ b/apps/codex/src/data-loader.ts @@ -1,4 +1,4 @@ -import type { TokenUsageDelta, TokenUsageEvent } from './_types.ts'; +import type { SessionSource, TokenUsageDelta, TokenUsageEvent } from './_types.ts'; import { readFile, stat } from 'node:fs/promises'; import path from 'node:path'; import process from 'node:process'; @@ -6,13 +6,9 @@ import { Result } from '@praha/byethrow'; import { createFixture } from 'fs-fixture'; import { glob } from 'tinyglobby'; import * as v from 'valibot'; -import { - CODEX_HOME_ENV, - DEFAULT_CODEX_DIR, - DEFAULT_SESSION_SUBDIR, - SESSION_GLOB, -} from './_consts.ts'; +import { CODEX_HOME_ENV, SESSION_GLOB } from './_consts.ts'; import { logger } from './logger.ts'; +import { resolveSessionSources } from './session-sources.ts'; type RawUsage = { input_tokens: number; @@ -175,8 +171,38 @@ function asNonEmptyString(value: unknown): string | undefined { return trimmed === '' ? undefined : trimmed; } +function normalizeAccountLabel(account: string | undefined, fallback: string): string { + const normalized = account?.trim(); + return normalized == null || normalized === '' ? fallback : normalized; +} + +function makeUniqueAccountLabels(accountCandidates: string[]): string[] { + const reservedLabels = new Set(accountCandidates); + const usedLabels = new Set(); + + return accountCandidates.map((base) => { + if (!usedLabels.has(base)) { + usedLabels.add(base); + return base; + } + + let suffix = 2; + for (;;) { + const candidate = `${base}-${suffix}`; + const reservedByFutureEntry = reservedLabels.has(candidate) && !usedLabels.has(candidate); + if (!usedLabels.has(candidate) && !reservedByFutureEntry) { + usedLabels.add(candidate); + return candidate; + } + + suffix += 1; + } + }); +} + export type LoadOptions = { sessionDirs?: string[]; + sessionSources?: SessionSource[]; }; export type LoadResult = { @@ -185,22 +211,47 @@ export type LoadResult = { }; export async function loadTokenUsageEvents(options: LoadOptions = {}): Promise { + const providedSources = + options.sessionSources != null && options.sessionSources.length > 0 + ? (() => { + const accountCandidates = options.sessionSources.map((source, index, allSources) => { + const fallbackAccount = allSources.length <= 1 ? 'default' : `account-${index + 1}`; + return normalizeAccountLabel(source.account, fallbackAccount); + }); + const uniqueAccounts = makeUniqueAccountLabels(accountCandidates); + return options.sessionSources.map((source, index) => ({ + account: uniqueAccounts[index]!, + directory: path.resolve(source.directory), + })); + })() + : undefined; + const providedDirs = options.sessionDirs != null && options.sessionDirs.length > 0 - ? options.sessionDirs.map((dir) => path.resolve(dir)) + ? (() => { + const entries = options.sessionDirs.map((dir, index, allDirs) => { + const directory = path.resolve(dir); + const basename = path.basename(directory); + const fallbackAccount = `account-${index + 1}`; + const account = + allDirs.length <= 1 ? 'default' : basename === '' ? fallbackAccount : basename; + return { account, directory }; + }); + const uniqueAccounts = makeUniqueAccountLabels(entries.map((entry) => entry.account)); + return entries.map((entry, index) => ({ + account: uniqueAccounts[index]!, + directory: entry.directory, + })); + })() : undefined; - const codexHomeEnv = process.env[CODEX_HOME_ENV]?.trim(); - const codexHome = - codexHomeEnv != null && codexHomeEnv !== '' ? path.resolve(codexHomeEnv) : DEFAULT_CODEX_DIR; - const defaultSessionsDir = path.join(codexHome, DEFAULT_SESSION_SUBDIR); - const sessionDirs = providedDirs ?? [defaultSessionsDir]; + const sessionSources = providedSources ?? providedDirs ?? resolveSessionSources(); const events: TokenUsageEvent[] = []; const missingDirectories: string[] = []; - for (const dir of sessionDirs) { - const directoryPath = path.resolve(dir); + for (const source of sessionSources) { + const directoryPath = path.resolve(source.directory); const statResult = await Result.try({ try: stat(directoryPath), catch: (error) => error, @@ -339,6 +390,7 @@ export async function loadTokenUsageEvents(options: LoadOptions = {}): Promise { + let originalCodexHome: string | undefined; + + beforeEach(() => { + originalCodexHome = process.env[CODEX_HOME_ENV]; + }); + + afterEach(() => { + if (originalCodexHome == null) { + delete process.env[CODEX_HOME_ENV]; + return; + } + + process.env[CODEX_HOME_ENV] = originalCodexHome; + }); + it('parses token_count events and skips entries without model metadata', async () => { await using fixture = await createFixture({ sessions: { @@ -484,5 +551,339 @@ if (import.meta.vitest != null) { expect(events[0]!.model).toBe('gpt-5'); expect(events[0]!.isFallbackModel).toBe(true); }); + + it('loads events from multiple account sources', async () => { + await using fixture = await createFixture({ + accounts: { + work: { + sessions: { + 'shared.jsonl': [ + JSON.stringify({ + timestamp: '2025-09-11T10:00:00.000Z', + type: 'turn_context', + payload: { + model: 'gpt-5', + }, + }), + JSON.stringify({ + timestamp: '2025-09-11T10:01:00.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + info: { + total_token_usage: { + input_tokens: 100, + cached_input_tokens: 0, + output_tokens: 20, + reasoning_output_tokens: 0, + total_tokens: 120, + }, + }, + }, + }), + ].join('\n'), + }, + }, + personal: { + sessions: { + 'shared.jsonl': [ + JSON.stringify({ + timestamp: '2025-09-11T11:00:00.000Z', + type: 'turn_context', + payload: { + model: 'gpt-5', + }, + }), + JSON.stringify({ + timestamp: '2025-09-11T11:01:00.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + info: { + total_token_usage: { + input_tokens: 200, + cached_input_tokens: 0, + output_tokens: 40, + reasoning_output_tokens: 0, + total_tokens: 240, + }, + }, + }, + }), + ].join('\n'), + }, + }, + }, + }); + + const { events, missingDirectories } = await loadTokenUsageEvents({ + sessionSources: [ + { + account: 'work', + directory: fixture.getPath('accounts/work/sessions'), + }, + { + account: 'personal', + directory: fixture.getPath('accounts/personal/sessions'), + }, + ], + }); + + expect(missingDirectories).toEqual([]); + expect(events).toHaveLength(2); + expect(events.map((event) => event.account)).toEqual(['work', 'personal']); + expect(events.map((event) => event.sessionId)).toEqual(['shared', 'shared']); + }); + + it('avoids collisions with explicit suffix-style account labels', async () => { + await using fixture = await createFixture({ + accounts: { + a: { + sessions: { + 'a.jsonl': [ + JSON.stringify({ + timestamp: '2025-09-11T10:00:00.000Z', + type: 'turn_context', + payload: { model: 'gpt-5' }, + }), + JSON.stringify({ + timestamp: '2025-09-11T10:01:00.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + info: { + total_token_usage: { + input_tokens: 100, + cached_input_tokens: 0, + output_tokens: 20, + reasoning_output_tokens: 0, + total_tokens: 120, + }, + }, + }, + }), + ].join('\n'), + }, + }, + b: { + sessions: { + 'b.jsonl': [ + JSON.stringify({ + timestamp: '2025-09-11T11:00:00.000Z', + type: 'turn_context', + payload: { model: 'gpt-5' }, + }), + JSON.stringify({ + timestamp: '2025-09-11T11:01:00.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + info: { + total_token_usage: { + input_tokens: 200, + cached_input_tokens: 0, + output_tokens: 40, + reasoning_output_tokens: 0, + total_tokens: 240, + }, + }, + }, + }), + ].join('\n'), + }, + }, + c: { + sessions: { + 'c.jsonl': [ + JSON.stringify({ + timestamp: '2025-09-11T12:00:00.000Z', + type: 'turn_context', + payload: { model: 'gpt-5' }, + }), + JSON.stringify({ + timestamp: '2025-09-11T12:01:00.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + info: { + total_token_usage: { + input_tokens: 300, + cached_input_tokens: 0, + output_tokens: 60, + reasoning_output_tokens: 0, + total_tokens: 360, + }, + }, + }, + }), + ].join('\n'), + }, + }, + }, + }); + + const { events, missingDirectories } = await loadTokenUsageEvents({ + sessionSources: [ + { account: 'work', directory: fixture.getPath('accounts/a/sessions') }, + { account: 'work', directory: fixture.getPath('accounts/b/sessions') }, + { account: 'work-2', directory: fixture.getPath('accounts/c/sessions') }, + ], + }); + + expect(missingDirectories).toEqual([]); + expect(events).toHaveLength(3); + expect(events.map((event) => event.account)).toEqual(['work', 'work-3', 'work-2']); + }); + + it('deduplicates account labels when session directory names collide', async () => { + await using fixture = await createFixture({ + accounts: { + first: { + sessions: { + 'first.jsonl': [ + JSON.stringify({ + timestamp: '2025-09-11T10:00:00.000Z', + type: 'turn_context', + payload: { + model: 'gpt-5', + }, + }), + JSON.stringify({ + timestamp: '2025-09-11T10:01:00.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + info: { + total_token_usage: { + input_tokens: 100, + cached_input_tokens: 0, + output_tokens: 20, + reasoning_output_tokens: 0, + total_tokens: 120, + }, + }, + }, + }), + ].join('\n'), + }, + }, + second: { + sessions: { + 'second.jsonl': [ + JSON.stringify({ + timestamp: '2025-09-11T11:00:00.000Z', + type: 'turn_context', + payload: { + model: 'gpt-5', + }, + }), + JSON.stringify({ + timestamp: '2025-09-11T11:01:00.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + info: { + total_token_usage: { + input_tokens: 200, + cached_input_tokens: 0, + output_tokens: 40, + reasoning_output_tokens: 0, + total_tokens: 240, + }, + }, + }, + }), + ].join('\n'), + }, + }, + }, + }); + + const { events, missingDirectories } = await loadTokenUsageEvents({ + sessionDirs: [ + fixture.getPath('accounts/first/sessions'), + fixture.getPath('accounts/second/sessions'), + ], + }); + + expect(missingDirectories).toEqual([]); + expect(events).toHaveLength(2); + expect(events.map((event) => event.account)).toEqual(['sessions', 'sessions-2']); + }); + + it('parses multi-account CODEX_HOME env when no options are provided', async () => { + await using fixture = await createFixture({ + homes: { + work: { + sessions: { + 'work.jsonl': [ + JSON.stringify({ + timestamp: '2025-09-12T10:00:00.000Z', + type: 'turn_context', + payload: { + model: 'gpt-5', + }, + }), + JSON.stringify({ + timestamp: '2025-09-12T10:01:00.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + info: { + total_token_usage: { + input_tokens: 100, + cached_input_tokens: 0, + output_tokens: 20, + reasoning_output_tokens: 0, + total_tokens: 120, + }, + }, + }, + }), + ].join('\n'), + }, + }, + personal: { + sessions: { + 'personal.jsonl': [ + JSON.stringify({ + timestamp: '2025-09-12T11:00:00.000Z', + type: 'turn_context', + payload: { + model: 'gpt-5', + }, + }), + JSON.stringify({ + timestamp: '2025-09-12T11:01:00.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + info: { + total_token_usage: { + input_tokens: 200, + cached_input_tokens: 0, + output_tokens: 40, + reasoning_output_tokens: 0, + total_tokens: 240, + }, + }, + }, + }), + ].join('\n'), + }, + }, + }, + }); + + process.env[CODEX_HOME_ENV] = [ + `work=${fixture.getPath('homes/work')}`, + `personal=${fixture.getPath('homes/personal')}`, + ].join(','); + + const { events, missingDirectories } = await loadTokenUsageEvents(); + expect(missingDirectories).toEqual([]); + expect(events).toHaveLength(2); + expect(events.map((event) => event.account)).toEqual(['work', 'personal']); + }); }); } diff --git a/apps/codex/src/monthly-report.ts b/apps/codex/src/monthly-report.ts index b29e03ae..81e970b6 100644 --- a/apps/codex/src/monthly-report.ts +++ b/apps/codex/src/monthly-report.ts @@ -14,12 +14,18 @@ export type MonthlyReportOptions = { locale?: string; since?: string; until?: string; + byAccount?: boolean; pricingSource: PricingSource; }; -function createSummary(month: string, initialTimestamp: string): MonthlyUsageSummary { +function createSummary( + month: string, + initialTimestamp: string, + account?: string, +): MonthlyUsageSummary { return { month, + account, firstTimestamp: initialTimestamp, inputTokens: 0, cachedInputTokens: 0, @@ -39,6 +45,7 @@ export async function buildMonthlyReport( const locale = options.locale; const since = options.since; const until = options.until; + const byAccount = options.byAccount === true; const pricingSource = options.pricingSource; const summaries = new Map(); @@ -55,9 +62,15 @@ export async function buildMonthlyReport( } const monthKey = toMonthKey(event.timestamp, timezone); - const summary = summaries.get(monthKey) ?? createSummary(monthKey, event.timestamp); - if (!summaries.has(monthKey)) { - summaries.set(monthKey, summary); + const accountCandidate = event.account?.trim(); + const account = + accountCandidate == null || accountCandidate === '' ? 'default' : accountCandidate; + const summaryKey = byAccount ? `${monthKey}\x00${account}` : monthKey; + const summary = + summaries.get(summaryKey) ?? + createSummary(monthKey, event.timestamp, byAccount ? account : undefined); + if (!summaries.has(summaryKey)) { + summaries.set(summaryKey, summary); } addUsage(summary, event); @@ -88,9 +101,13 @@ export async function buildMonthlyReport( const rows: MonthlyReportRow[] = []; - const sortedSummaries = Array.from(summaries.values()).sort((a, b) => - a.month.localeCompare(b.month), - ); + const sortedSummaries = Array.from(summaries.values()).sort((a, b) => { + const monthCompare = a.month.localeCompare(b.month); + if (monthCompare !== 0) { + return monthCompare; + } + return (a.account ?? '').localeCompare(b.account ?? ''); + }); for (const summary of sortedSummaries) { let cost = 0; for (const [modelName, usage] of summary.models) { @@ -109,6 +126,7 @@ export async function buildMonthlyReport( rows.push({ month: formatDisplayMonth(summary.month, locale, timezone), + account: summary.account, inputTokens: summary.inputTokens, cachedInputTokens: summary.cachedInputTokens, outputTokens: summary.outputTokens, @@ -201,5 +219,51 @@ if (import.meta.vitest != null) { (200 / 1_000_000) * 2; expect(first.costUSD).toBeCloseTo(expectedCost, 10); }); + + it('groups by account when enabled', async () => { + const stubPricingSource: PricingSource = { + async getPricing(): Promise { + return { + inputCostPerMToken: 1, + cachedInputCostPerMToken: 0.1, + outputCostPerMToken: 10, + }; + }, + }; + + const report = await buildMonthlyReport( + [ + { + account: 'work', + sessionId: 'session-1', + timestamp: '2025-09-11T03:00:00.000Z', + model: 'gpt-5', + inputTokens: 1_000, + cachedInputTokens: 0, + outputTokens: 500, + reasoningOutputTokens: 0, + totalTokens: 1_500, + }, + { + account: 'personal', + sessionId: 'session-2', + timestamp: '2025-09-20T05:00:00.000Z', + model: 'gpt-5', + inputTokens: 500, + cachedInputTokens: 0, + outputTokens: 100, + reasoningOutputTokens: 0, + totalTokens: 600, + }, + ], + { + pricingSource: stubPricingSource, + byAccount: true, + }, + ); + + expect(report).toHaveLength(2); + expect(report.map((row) => row.account)).toEqual(['personal', 'work']); + }); }); } diff --git a/apps/codex/src/session-report.ts b/apps/codex/src/session-report.ts index 867d6103..635d124a 100644 --- a/apps/codex/src/session-report.ts +++ b/apps/codex/src/session-report.ts @@ -14,12 +14,18 @@ export type SessionReportOptions = { locale?: string; since?: string; until?: string; + byAccount?: boolean; pricingSource: PricingSource; }; -function createSummary(sessionId: string, initialTimestamp: string): SessionUsageSummary { +function createSummary( + sessionId: string, + initialTimestamp: string, + account?: string, +): SessionUsageSummary { return { sessionId, + account, firstTimestamp: initialTimestamp, lastTimestamp: initialTimestamp, inputTokens: 0, @@ -40,6 +46,7 @@ export async function buildSessionReport( const since = options.since; const until = options.until; const pricingSource = options.pricingSource; + const byAccount = options.byAccount === true; const summaries = new Map(); @@ -67,9 +74,15 @@ export async function buildSessionReport( continue; } - const summary = summaries.get(sessionId) ?? createSummary(sessionId, event.timestamp); - if (!summaries.has(sessionId)) { - summaries.set(sessionId, summary); + const accountCandidate = event.account?.trim(); + const account = + accountCandidate == null || accountCandidate === '' ? 'default' : accountCandidate; + const summaryKey = `${account}\x00${sessionId}`; + const summary = + summaries.get(summaryKey) ?? + createSummary(sessionId, event.timestamp, byAccount ? account : undefined); + if (!summaries.has(summaryKey)) { + summaries.set(summaryKey, summary); } addUsage(summary, event); @@ -106,9 +119,17 @@ export async function buildSessionReport( modelPricing.set(modelName, await pricingSource.getPricing(modelName)); } - const sortedSummaries = Array.from(summaries.values()).sort((a, b) => - a.lastTimestamp.localeCompare(b.lastTimestamp), - ); + const sortedSummaries = Array.from(summaries.values()).sort((a, b) => { + const timestampCompare = a.lastTimestamp.localeCompare(b.lastTimestamp); + if (timestampCompare !== 0) { + return timestampCompare; + } + const accountCompare = (a.account ?? '').localeCompare(b.account ?? ''); + if (accountCompare !== 0) { + return accountCompare; + } + return a.sessionId.localeCompare(b.sessionId); + }); const rows: SessionReportRow[] = []; for (const summary of sortedSummaries) { @@ -134,6 +155,7 @@ export async function buildSessionReport( rows.push({ sessionId: summary.sessionId, + account: summary.account, lastActivity: summary.lastTimestamp, sessionFile, directory, @@ -177,6 +199,7 @@ if (import.meta.vitest != null) { [ { sessionId: 'session-a', + account: 'work', timestamp: '2025-09-12T01:00:00.000Z', model: 'gpt-5', inputTokens: 1_000, @@ -187,6 +210,7 @@ if (import.meta.vitest != null) { }, { sessionId: 'session-a', + account: 'work', timestamp: '2025-09-12T02:00:00.000Z', model: 'gpt-5-mini', inputTokens: 400, @@ -197,6 +221,7 @@ if (import.meta.vitest != null) { }, { sessionId: 'session-b', + account: 'personal', timestamp: '2025-09-11T23:30:00.000Z', model: 'gpt-5', inputTokens: 800, @@ -233,5 +258,52 @@ if (import.meta.vitest != null) { (200 / 1_000_000) * 2; expect(second.costUSD).toBeCloseTo(expectedCost, 10); }); + + it('keeps same session id separate across accounts', async () => { + const stubPricingSource: PricingSource = { + async getPricing(): Promise { + return { + inputCostPerMToken: 1, + cachedInputCostPerMToken: 0.1, + outputCostPerMToken: 10, + }; + }, + }; + + const report = await buildSessionReport( + [ + { + account: 'work', + sessionId: 'shared-session', + timestamp: '2025-09-11T10:00:00.000Z', + model: 'gpt-5', + inputTokens: 100, + cachedInputTokens: 0, + outputTokens: 20, + reasoningOutputTokens: 0, + totalTokens: 120, + }, + { + account: 'personal', + sessionId: 'shared-session', + timestamp: '2025-09-11T11:00:00.000Z', + model: 'gpt-5', + inputTokens: 200, + cachedInputTokens: 0, + outputTokens: 30, + reasoningOutputTokens: 0, + totalTokens: 230, + }, + ], + { + pricingSource: stubPricingSource, + byAccount: true, + }, + ); + + expect(report).toHaveLength(2); + expect(report.map((row) => row.account)).toEqual(['work', 'personal']); + expect(report.map((row) => row.sessionId)).toEqual(['shared-session', 'shared-session']); + }); }); } diff --git a/apps/codex/src/session-sources.ts b/apps/codex/src/session-sources.ts new file mode 100644 index 00000000..10756255 --- /dev/null +++ b/apps/codex/src/session-sources.ts @@ -0,0 +1,215 @@ +import type { SessionSource } from './_types.ts'; +import os from 'node:os'; +import path from 'node:path'; +import process from 'node:process'; +import { CODEX_HOME_ENV, DEFAULT_CODEX_DIR, DEFAULT_SESSION_SUBDIR } from './_consts.ts'; + +type ParsedCodexHome = { + account?: string; + codexHome: string; +}; + +const DEFAULT_ACCOUNT = 'default'; + +function toNonEmpty(value: string | undefined): string | undefined { + if (value == null) { + return undefined; + } + + const trimmed = value.trim(); + return trimmed === '' ? undefined : trimmed; +} + +function expandHomeDirectory(pathValue: string): string { + return pathValue.replace(/^~(?=$|[\\/])/, os.homedir()); +} + +function parseCodexHomeEntry(entry: string): ParsedCodexHome | null { + const trimmed = entry.trim(); + if (trimmed === '') { + return null; + } + + const separatorIndex = trimmed.indexOf('='); + if (separatorIndex < 0) { + return { + codexHome: trimmed, + }; + } + + const account = toNonEmpty(trimmed.slice(0, separatorIndex)); + const codexHome = toNonEmpty(trimmed.slice(separatorIndex + 1)); + if (codexHome == null) { + return null; + } + + return { + account, + codexHome, + }; +} + +function parseCodexHomes(raw: string): ParsedCodexHome[] { + return raw + .split(',') + .map(parseCodexHomeEntry) + .filter((item): item is ParsedCodexHome => item != null); +} + +function normalizeAccountLabel(base: string): string { + const normalizedBase = base.trim(); + return normalizedBase === '' ? DEFAULT_ACCOUNT : normalizedBase; +} + +function makeUniqueAccountLabels(accountBases: string[]): string[] { + const normalizedBases = accountBases.map(normalizeAccountLabel); + const reservedLabels = new Set(normalizedBases); + const usedLabels = new Set(); + + return normalizedBases.map((base) => { + if (!usedLabels.has(base)) { + usedLabels.add(base); + return base; + } + + let suffix = 2; + for (;;) { + const candidate = `${base}-${suffix}`; + const reservedByFutureEntry = reservedLabels.has(candidate) && !usedLabels.has(candidate); + if (!usedLabels.has(candidate) && !reservedByFutureEntry) { + usedLabels.add(candidate); + return candidate; + } + + suffix += 1; + } + }); +} + +function fallbackAccountLabel(codexHome: string, index: number, total: number): string { + if (total <= 1) { + return DEFAULT_ACCOUNT; + } + + const resolvedCodexHome = path.resolve(expandHomeDirectory(codexHome)); + const baseName = path.basename(resolvedCodexHome); + const normalizedBase = toNonEmpty(baseName); + return normalizedBase ?? `account-${index + 1}`; +} + +export function resolveSessionSources(codexHomeArg?: string): SessionSource[] { + const sourceText = toNonEmpty(codexHomeArg) ?? toNonEmpty(process.env[CODEX_HOME_ENV]); + const parsedHomes = + sourceText == null || sourceText === '' + ? [{ codexHome: DEFAULT_CODEX_DIR } satisfies ParsedCodexHome] + : parseCodexHomes(sourceText); + + if (parsedHomes.length === 0) { + return [ + { + account: DEFAULT_ACCOUNT, + directory: path.join(DEFAULT_CODEX_DIR, DEFAULT_SESSION_SUBDIR), + }, + ]; + } + + const accountBases = parsedHomes.map( + (entry, index) => + entry.account ?? fallbackAccountLabel(entry.codexHome, index, parsedHomes.length), + ); + const uniqueAccounts = makeUniqueAccountLabels(accountBases); + + return parsedHomes.map((entry, index) => { + const resolvedCodexHome = path.resolve(expandHomeDirectory(entry.codexHome)); + return { + account: uniqueAccounts[index]!, + directory: path.join(resolvedCodexHome, DEFAULT_SESSION_SUBDIR), + }; + }); +} + +if (import.meta.vitest != null) { + describe('resolveSessionSources', () => { + let originalCodexHome: string | undefined; + + beforeEach(() => { + originalCodexHome = process.env[CODEX_HOME_ENV]; + }); + + afterEach(() => { + if (originalCodexHome == null) { + delete process.env[CODEX_HOME_ENV]; + return; + } + + process.env[CODEX_HOME_ENV] = originalCodexHome; + }); + + it('uses default CODEX_HOME when no override is provided', () => { + delete process.env[CODEX_HOME_ENV]; + const sources = resolveSessionSources(); + expect(sources).toEqual([ + { + account: 'default', + directory: path.join(DEFAULT_CODEX_DIR, DEFAULT_SESSION_SUBDIR), + }, + ]); + }); + + it('supports multiple codex homes with automatic labels', () => { + const sources = resolveSessionSources('/tmp/codex-work,/tmp/codex-personal'); + expect(sources).toEqual([ + { + account: 'codex-work', + directory: path.resolve('/tmp/codex-work', DEFAULT_SESSION_SUBDIR), + }, + { + account: 'codex-personal', + directory: path.resolve('/tmp/codex-personal', DEFAULT_SESSION_SUBDIR), + }, + ]); + }); + + it('supports explicit account labels and deduplicates duplicates', () => { + const sources = resolveSessionSources('work=/tmp/work-a,work=/tmp/work-b'); + expect(sources).toEqual([ + { + account: 'work', + directory: path.resolve('/tmp/work-a', DEFAULT_SESSION_SUBDIR), + }, + { + account: 'work-2', + directory: path.resolve('/tmp/work-b', DEFAULT_SESSION_SUBDIR), + }, + ]); + }); + + it('avoids collisions with explicit suffix-style account labels', () => { + const sources = resolveSessionSources('work=/tmp/work-a,work=/tmp/work-b,work-2=/tmp/work-c'); + expect(sources).toEqual([ + { + account: 'work', + directory: path.resolve('/tmp/work-a', DEFAULT_SESSION_SUBDIR), + }, + { + account: 'work-3', + directory: path.resolve('/tmp/work-b', DEFAULT_SESSION_SUBDIR), + }, + { + account: 'work-2', + directory: path.resolve('/tmp/work-c', DEFAULT_SESSION_SUBDIR), + }, + ]); + }); + + it('expands tilde paths in codex homes', () => { + const sources = resolveSessionSources('work=~/.codex-work'); + expect(sources).toEqual([ + { + account: 'work', + directory: path.resolve(os.homedir(), '.codex-work', DEFAULT_SESSION_SUBDIR), + }, + ]); + }); + }); +} diff --git a/docs/guide/codex/daily.md b/docs/guide/codex/daily.md index 586dfeb0..1e1c527b 100644 --- a/docs/guide/codex/daily.md +++ b/docs/guide/codex/daily.md @@ -12,14 +12,16 @@ npx @ccusage/codex@latest daily ## Options -| Flag | Description | -| ---------------------------- | -------------------------------------------------------------- | -| `--since` / `--until` | Filter to a specific date range (YYYYMMDD or YYYY-MM-DD) | -| `--timezone` | Override timezone used for grouping (defaults to system) | -| `--locale` | Adjust date formatting locale | -| `--json` | Emit structured JSON instead of a table | -| `--offline` / `--no-offline` | Force cached LiteLLM pricing or enable live fetching | -| `--compact` | Force compact table layout (same columns as a narrow terminal) | +| Flag | Description | +| ---------------------------- | --------------------------------------------------------------------------------------- | +| `--since` / `--until` | Filter to a specific date range (YYYYMMDD or YYYY-MM-DD) | +| `--codex-home` | Override Codex home(s). Accepts comma-separated paths and optional `label=path` entries | +| `--by-account` | Split daily rows by account when multiple Codex homes are configured | +| `--timezone` | Override timezone used for grouping (defaults to system) | +| `--locale` | Adjust date formatting locale | +| `--json` | Emit structured JSON instead of a table | +| `--offline` / `--no-offline` | Force cached LiteLLM pricing or enable live fetching | +| `--compact` | Force compact table layout (same columns as a narrow terminal) | The output uses the same responsive table component as ccusage, including compact mode support and per-model token summaries. diff --git a/docs/guide/codex/index.md b/docs/guide/codex/index.md index bdb74030..0f1a385f 100644 --- a/docs/guide/codex/index.md +++ b/docs/guide/codex/index.md @@ -46,6 +46,13 @@ After adding the alias to your shell config file (`.bashrc`, `.zshrc`, or `confi The CLI reads Codex session JSONL files located under `CODEX_HOME` (defaults to `~/.codex`). Each file represents a single Codex CLI session and contains running token totals that the tool converts into per-day or per-month deltas. +For multi-account setups, `CODEX_HOME` can also be a comma-separated list of homes: + +- `~/.codex-work,~/.codex-personal` +- `work=~/.codex-work,personal=~/.codex-personal` + +Each home is resolved to its `sessions/` directory and aggregated into one report. Use `--by-account` to split rows per account in daily/monthly reports. + ## What Gets Calculated - **Token deltas** – Each `event_msg` with `payload.type === "token_count"` reports cumulative totals. The CLI subtracts the previous totals to recover per-turn token usage (input, cached input, output, reasoning, total). @@ -57,10 +64,10 @@ The CLI reads Codex session JSONL files located under `CODEX_HOME` (defaults to ## Environment Variables -| Variable | Description | -| ------------ | ------------------------------------------------------------ | -| `CODEX_HOME` | Override the root directory containing Codex session folders | -| `LOG_LEVEL` | Adjust consola verbosity (0 silent … 5 trace) | +| Variable | Description | +| ------------ | ------------------------------------------------------------------------------------------------------- | +| `CODEX_HOME` | Override Codex home(s). Supports single path or comma-separated list with optional `label=path` entries | +| `LOG_LEVEL` | Adjust console verbosity (0 silent … 5 trace) | When Codex emits a model alias (for example `gpt-5-codex`), the CLI automatically resolves it to the canonical LiteLLM pricing entry. No manual override is needed. diff --git a/docs/guide/codex/monthly.md b/docs/guide/codex/monthly.md index db804cb4..44ab1837 100644 --- a/docs/guide/codex/monthly.md +++ b/docs/guide/codex/monthly.md @@ -14,13 +14,15 @@ npx @ccusage/codex@latest monthly ## Options -| Flag | Description | -| ---------------------------- | --------------------------------------------------------------------------- | -| `--since` / `--until` | Filter to a specific date range (YYYYMMDD or YYYY-MM-DD) before aggregating | -| `--timezone` | Override the timezone used to bucket usage into months | -| `--locale` | Adjust month label formatting | -| `--json` | Emit structured JSON instead of a table | -| `--offline` / `--no-offline` | Force cached LiteLLM pricing or enable live fetching | -| `--compact` | Force compact table layout (same columns as a narrow terminal) | +| Flag | Description | +| ---------------------------- | --------------------------------------------------------------------------------------- | +| `--since` / `--until` | Filter to a specific date range (YYYYMMDD or YYYY-MM-DD) before aggregating | +| `--codex-home` | Override Codex home(s). Accepts comma-separated paths and optional `label=path` entries | +| `--by-account` | Split monthly rows by account when multiple Codex homes are configured | +| `--timezone` | Override the timezone used to bucket usage into months | +| `--locale` | Adjust month label formatting | +| `--json` | Emit structured JSON instead of a table | +| `--offline` / `--no-offline` | Force cached LiteLLM pricing or enable live fetching | +| `--compact` | Force compact table layout (same columns as a narrow terminal) | The output uses the same responsive table component as ccusage, including compact mode support, per-model token summaries, and a combined totals row. diff --git a/docs/guide/codex/session.md b/docs/guide/codex/session.md index b1d551cf..d60fdb04 100644 --- a/docs/guide/codex/session.md +++ b/docs/guide/codex/session.md @@ -2,7 +2,7 @@ The `session` command groups Codex CLI usage by individual sessions so you can spot long-running conversations, confirm last activity times, and audit model switches inside a single log. -Sessions are listed oldest-to-newest by their last activity timestamp so the output lines up with the daily and monthly views. Each row shows the activity date, the Codex session directory, and a short session identifier (last 8 characters of the log filename) alongside token and cost columns. When your terminal narrows (or `--compact` is passed) the table automatically collapses to just Date, Directory, Session, Input, Output, and Cost to stay readable. +Sessions are listed oldest-to-newest by their last activity timestamp so the output lines up with the daily and monthly views. Each row shows the activity date, the Codex session directory, and a short session identifier (last 8 characters of the log filename) alongside token and cost columns. When multiple Codex homes are configured, the report includes an account column automatically so same-named session files stay distinguishable across accounts. When your terminal narrows (or `--compact` is passed) the table automatically collapses to essential columns to stay readable. ```bash # Recommended (fastest) @@ -14,14 +14,16 @@ npx @ccusage/codex@latest session ## Options -| Flag | Description | -| ---------------------------- | ------------------------------------------------------------------------ | -| `--since` / `--until` | Filter sessions by their activity date (YYYYMMDD or YYYY-MM-DD) | -| `--timezone` | Override the timezone used for date grouping and last-activity display | -| `--locale` | Adjust locale for table and timestamp formatting | -| `--json` | Emit structured JSON (`{ sessions: [], totals: {} }`) instead of a table | -| `--offline` / `--no-offline` | Force cached LiteLLM pricing or enable live fetching | -| `--compact` | Force compact table layout (same columns as a narrow terminal) | +| Flag | Description | +| ---------------------------- | --------------------------------------------------------------------------------------- | +| `--since` / `--until` | Filter sessions by their activity date (YYYYMMDD or YYYY-MM-DD) | +| `--codex-home` | Override Codex home(s). Accepts comma-separated paths and optional `label=path` entries | +| `--by-account` | Force account column even when a single Codex home is configured | +| `--timezone` | Override the timezone used for date grouping and last-activity display | +| `--locale` | Adjust locale for table and timestamp formatting | +| `--json` | Emit structured JSON (`{ sessions: [], totals: {} }`) instead of a table | +| `--offline` / `--no-offline` | Force cached LiteLLM pricing or enable live fetching | +| `--compact` | Force compact table layout (same columns as a narrow terminal) | JSON output includes a `sessions` array with per-model breakdowns, cached token counts, `lastActivity`, and `isFallback` flags for any events that required the legacy `gpt-5` pricing fallback.