From ac81dacb6a663a2dc63f33e3a63d74bfc40aa1fc Mon Sep 17 00:00:00 2001 From: Jack Cheng Date: Fri, 27 Mar 2026 11:37:33 +0800 Subject: [PATCH 1/2] optimize codex session loading --- apps/codex/src/commands/daily.ts | 21 +- apps/codex/src/commands/monthly.ts | 21 +- apps/codex/src/commands/session.ts | 25 +- apps/codex/src/daily-report.ts | 95 +++-- apps/codex/src/data-loader.ts | 629 ++++++++++++++++++++--------- apps/codex/src/monthly-report.ts | 96 +++-- apps/codex/src/session-report.ts | 116 +++--- 7 files changed, 661 insertions(+), 342 deletions(-) diff --git a/apps/codex/src/commands/daily.ts b/apps/codex/src/commands/daily.ts index 6dc7c6f0..cc95a616 100644 --- a/apps/codex/src/commands/daily.ts +++ b/apps/codex/src/commands/daily.ts @@ -1,3 +1,4 @@ +import type { DailyUsageSummary } from '../_types.ts'; import process from 'node:process'; import { addEmptySeparatorRow, @@ -12,7 +13,7 @@ import pc from 'picocolors'; import { DEFAULT_TIMEZONE } from '../_consts.ts'; import { sharedArgs } from '../_shared-args.ts'; import { formatModelsList, splitUsageTokens } from '../command-utils.ts'; -import { buildDailyReport } from '../daily-report.ts'; +import { accumulateDailyUsage, buildDailyReportRows } from '../daily-report.ts'; import { loadTokenUsageEvents } from '../data-loader.ts'; import { normalizeFilterDate } from '../date-utils.ts'; import { log, logger } from '../logger.ts'; @@ -41,13 +42,23 @@ export const dailyCommand = define({ process.exit(1); } - const { events, missingDirectories } = await loadTokenUsageEvents(); + const summaries = new Map(); + const { missingDirectories } = await loadTokenUsageEvents({ + since, + until, + timezone: ctx.values.timezone, + collectEvents: false, + sortEvents: false, + onEvent: (event) => { + accumulateDailyUsage(summaries, event, ctx.values.timezone); + }, + }); for (const missing of missingDirectories) { logger.warn(`Codex session directory not found: ${missing}`); } - if (events.length === 0) { + if (summaries.size === 0) { log(jsonOutput ? JSON.stringify({ daily: [], totals: null }) : 'No Codex usage data found.'); return; } @@ -56,12 +67,10 @@ export const dailyCommand = define({ offline: ctx.values.offline, }); try { - const rows = await buildDailyReport(events, { + const rows = await buildDailyReportRows(summaries, { pricingSource, timezone: ctx.values.timezone, locale: ctx.values.locale, - since, - until, }); if (rows.length === 0) { diff --git a/apps/codex/src/commands/monthly.ts b/apps/codex/src/commands/monthly.ts index 2a5abc5e..ca78aad3 100644 --- a/apps/codex/src/commands/monthly.ts +++ b/apps/codex/src/commands/monthly.ts @@ -1,3 +1,4 @@ +import type { MonthlyUsageSummary } from '../_types.ts'; import process from 'node:process'; import { addEmptySeparatorRow, @@ -15,7 +16,7 @@ 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 { buildMonthlyReport } from '../monthly-report.ts'; +import { accumulateMonthlyUsage, buildMonthlyReportRows } from '../monthly-report.ts'; import { CodexPricingSource } from '../pricing.ts'; const TABLE_COLUMN_COUNT = 8; @@ -41,13 +42,23 @@ export const monthlyCommand = define({ process.exit(1); } - const { events, missingDirectories } = await loadTokenUsageEvents(); + const summaries = new Map(); + const { missingDirectories } = await loadTokenUsageEvents({ + since, + until, + timezone: ctx.values.timezone, + collectEvents: false, + sortEvents: false, + onEvent: (event) => { + accumulateMonthlyUsage(summaries, event, ctx.values.timezone); + }, + }); for (const missing of missingDirectories) { logger.warn(`Codex session directory not found: ${missing}`); } - if (events.length === 0) { + if (summaries.size === 0) { log( jsonOutput ? JSON.stringify({ monthly: [], totals: null }) : 'No Codex usage data found.', ); @@ -58,12 +69,10 @@ export const monthlyCommand = define({ offline: ctx.values.offline, }); try { - const rows = await buildMonthlyReport(events, { + const rows = await buildMonthlyReportRows(summaries, { pricingSource, timezone: ctx.values.timezone, locale: ctx.values.locale, - since, - until, }); if (rows.length === 0) { diff --git a/apps/codex/src/commands/session.ts b/apps/codex/src/commands/session.ts index 5dbe1a7e..f536b765 100644 --- a/apps/codex/src/commands/session.ts +++ b/apps/codex/src/commands/session.ts @@ -1,3 +1,4 @@ +import type { SessionUsageSummary } from '../_types.ts'; import process from 'node:process'; import { addEmptySeparatorRow, @@ -21,7 +22,7 @@ import { } from '../date-utils.ts'; import { log, logger } from '../logger.ts'; import { CodexPricingSource } from '../pricing.ts'; -import { buildSessionReport } from '../session-report.ts'; +import { accumulateSessionUsage, buildSessionReportRows } from '../session-report.ts'; const TABLE_COLUMN_COUNT = 11; @@ -46,13 +47,23 @@ export const sessionCommand = define({ process.exit(1); } - const { events, missingDirectories } = await loadTokenUsageEvents(); + const summaries = new Map(); + const { missingDirectories } = await loadTokenUsageEvents({ + since, + until, + timezone: ctx.values.timezone, + collectEvents: false, + sortEvents: false, + onEvent: (event) => { + accumulateSessionUsage(summaries, event); + }, + }); for (const missing of missingDirectories) { logger.warn(`Codex session directory not found: ${missing}`); } - if (events.length === 0) { + if (summaries.size === 0) { log( jsonOutput ? JSON.stringify({ sessions: [], totals: null }) : 'No Codex usage data found.', ); @@ -63,13 +74,7 @@ export const sessionCommand = define({ offline: ctx.values.offline, }); try { - const rows = await buildSessionReport(events, { - pricingSource, - timezone: ctx.values.timezone, - locale: ctx.values.locale, - since, - until, - }); + const rows = await buildSessionReportRows(summaries, { pricingSource }); if (rows.length === 0) { log( diff --git a/apps/codex/src/daily-report.ts b/apps/codex/src/daily-report.ts index c44a34a9..0e6aaac9 100644 --- a/apps/codex/src/daily-report.ts +++ b/apps/codex/src/daily-report.ts @@ -17,6 +17,8 @@ export type DailyReportOptions = { pricingSource: PricingSource; }; +export type DailySummaries = Map; + function createSummary(date: string, initialTimestamp: string): DailyUsageSummary { return { date, @@ -31,46 +33,46 @@ function createSummary(date: string, initialTimestamp: string): DailyUsageSummar }; } -export async function buildDailyReport( - events: TokenUsageEvent[], - options: DailyReportOptions, -): 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(); +export function accumulateDailyUsage( + summaries: DailySummaries, + event: TokenUsageEvent, + timezone?: string, +): void { + const modelName = event.model?.trim(); + if (modelName == null || modelName === '') { + return; + } - for (const event of events) { - const modelName = event.model?.trim(); - if (modelName == null || modelName === '') { - continue; - } + const dateKey = toDateKey(event.timestamp, timezone); + const summary = summaries.get(dateKey) ?? createSummary(dateKey, event.timestamp); + if (!summaries.has(dateKey)) { + summaries.set(dateKey, summary); + } - const dateKey = toDateKey(event.timestamp, timezone); - if (!isWithinRange(dateKey, since, until)) { - continue; - } + 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 summary = summaries.get(dateKey) ?? createSummary(dateKey, event.timestamp); - if (!summaries.has(dateKey)) { - summaries.set(dateKey, summary); - } +export async function buildDailyReportRows( + summaries: DailySummaries, + options: Omit, +): Promise { + const locale = options.locale; + const timezone = options.timezone; + const pricingSource = options.pricingSource; - 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; - } + if (summaries.size === 0) { + return []; } const uniqueModels = new Set(); @@ -86,7 +88,6 @@ export async function buildDailyReport( } const rows: DailyReportRow[] = []; - const sortedSummaries = Array.from(summaries.values()).sort((a, b) => a.date.localeCompare(b.date), ); @@ -121,6 +122,26 @@ export async function buildDailyReport( return rows; } +export async function buildDailyReport( + events: TokenUsageEvent[], + options: DailyReportOptions, +): Promise { + const timezone = options.timezone; + const since = options.since; + const until = options.until; + const summaries: DailySummaries = new Map(); + + for (const event of events) { + const dateKey = toDateKey(event.timestamp, timezone); + if (!isWithinRange(dateKey, since, until)) { + continue; + } + accumulateDailyUsage(summaries, event, timezone); + } + + return buildDailyReportRows(summaries, options); +} + if (import.meta.vitest != null) { describe('buildDailyReport', () => { it('aggregates events by day and calculates costs', async () => { diff --git a/apps/codex/src/data-loader.ts b/apps/codex/src/data-loader.ts index ef23a8f5..ce987314 100644 --- a/apps/codex/src/data-loader.ts +++ b/apps/codex/src/data-loader.ts @@ -1,17 +1,19 @@ +import type { Stats } from 'node:fs'; import type { TokenUsageDelta, TokenUsageEvent } from './_types.ts'; -import { readFile, stat } from 'node:fs/promises'; +import { createReadStream } from 'node:fs'; +import { stat, utimes } from 'node:fs/promises'; import path from 'node:path'; import process from 'node:process'; -import { Result } from '@praha/byethrow'; +import { createInterface } from 'node:readline'; 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 { isWithinRange, toDateKey } from './date-utils.ts'; import { logger } from './logger.ts'; type RawUsage = { @@ -22,6 +24,25 @@ type RawUsage = { total_tokens: number; }; +type JsonRecord = Record; + +type SessionParseState = { + previousTotals: RawUsage | null; + currentModel?: string; + currentModelIsFallback: boolean; + legacyFallbackUsed: boolean; +}; + +type SessionFileCandidate = { + file: string; + relativeSessionPath: string; + sessionId: string; +}; + +function isRecord(value: unknown): value is JsonRecord { + return value != null && typeof value === 'object' && !Array.isArray(value); +} + function ensureNumber(value: unknown): number { return typeof value === 'number' && Number.isFinite(value) ? value : 0; } @@ -40,16 +61,15 @@ function ensureNumber(value: unknown): number { * `input + output` (reasoning is treated as part of output, not an extra charge). */ function normalizeRawUsage(value: unknown): RawUsage | null { - if (value == null || typeof value !== 'object') { + if (!isRecord(value)) { return null; } - const record = value as Record; - const input = ensureNumber(record.input_tokens); - const cached = ensureNumber(record.cached_input_tokens ?? record.cache_read_input_tokens); - const output = ensureNumber(record.output_tokens); - const reasoning = ensureNumber(record.reasoning_output_tokens); - const total = ensureNumber(record.total_tokens); + const input = ensureNumber(value.input_tokens); + const cached = ensureNumber(value.cached_input_tokens ?? value.cache_read_input_tokens); + const output = ensureNumber(value.output_tokens); + const reasoning = ensureNumber(value.reasoning_output_tokens); + const total = ensureNumber(value.total_tokens); return { input_tokens: input, @@ -101,69 +121,42 @@ function convertToDelta(raw: RawUsage): TokenUsageDelta { }; } -const recordSchema = v.record(v.string(), v.unknown()); const LEGACY_FALLBACK_MODEL = 'gpt-5'; -const entrySchema = v.object({ - type: v.string(), - payload: v.optional(v.unknown()), - timestamp: v.optional(v.string()), -}); +function extractModelMetadata(value: unknown): string | undefined { + if (!isRecord(value)) { + return undefined; + } -const tokenCountPayloadSchema = v.object({ - type: v.literal('token_count'), - info: v.optional(recordSchema), -}); + return asNonEmptyString(value.model); +} -function extractModel(value: unknown): string | undefined { - const parsed = v.safeParse(recordSchema, value); - if (!parsed.success) { +function extractModel(value: unknown, infoOverride?: JsonRecord): string | undefined { + if (!isRecord(value)) { return undefined; } - const payload = parsed.output; - - const infoCandidate = payload.info; - if (infoCandidate != null) { - const infoParsed = v.safeParse(recordSchema, infoCandidate); - if (infoParsed.success) { - const info = infoParsed.output; - const directCandidates = [info.model, info.model_name]; - for (const candidate of directCandidates) { - const model = asNonEmptyString(candidate); - if (model != null) { - return model; - } + const info = infoOverride ?? (isRecord(value.info) ? value.info : undefined); + if (info != null) { + for (const candidate of [info.model, info.model_name]) { + const model = asNonEmptyString(candidate); + if (model != null) { + return model; } + } - if (info.metadata != null) { - const metadataParsed = v.safeParse(recordSchema, info.metadata); - if (metadataParsed.success) { - const model = asNonEmptyString(metadataParsed.output.model); - if (model != null) { - return model; - } - } - } + const metadataModel = extractModelMetadata(info.metadata); + if (metadataModel != null) { + return metadataModel; } } - const fallbackModel = asNonEmptyString(payload.model); + const fallbackModel = asNonEmptyString(value.model); if (fallbackModel != null) { return fallbackModel; } - if (payload.metadata != null) { - const metadataParsed = v.safeParse(recordSchema, payload.metadata); - if (metadataParsed.success) { - const model = asNonEmptyString(metadataParsed.output.model); - if (model != null) { - return model; - } - } - } - - return undefined; + return extractModelMetadata(value.metadata); } function asNonEmptyString(value: unknown): string | undefined { @@ -175,8 +168,205 @@ function asNonEmptyString(value: unknown): string | undefined { return trimmed === '' ? undefined : trimmed; } +function dateToDateKey(value: Date, timezone?: string): string | undefined { + if (!(value instanceof Date) || Number.isNaN(value.getTime())) { + return undefined; + } + + return toDateKey(value.toISOString(), timezone); +} + +function sessionPathDateKey(relativeSessionPath: string): string | undefined { + const match = relativeSessionPath.match(/(?:^|\/)(\d{4})\/(\d{2})\/(\d{2})\//); + if (match == null) { + return undefined; + } + + const [, year, month, day] = match; + return `${year}-${month}-${day}`; +} + +function shouldReadSessionFile( + relativeSessionPath: string, + fileStats: Stats, + since?: string, + until?: string, + timezone?: string, +): boolean { + if (since == null && until == null) { + return true; + } + + const endDateKey = dateToDateKey(fileStats.mtime, timezone); + if (since != null && endDateKey != null && endDateKey < since) { + return false; + } + + const startDateKey = + sessionPathDateKey(relativeSessionPath) ?? dateToDateKey(fileStats.birthtime, timezone); + if (until != null && startDateKey != null && startDateKey > until) { + return false; + } + + return true; +} + +function isRelevantLogLine(line: string): boolean { + return line.includes('"type"') && (line.includes('turn_context') || line.includes('event_msg')); +} + +function isEventWithinRange( + timestamp: string, + since?: string, + until?: string, + timezone?: string, +): boolean { + if (since == null && until == null) { + return true; + } + + return isWithinRange(toDateKey(timestamp, timezone), since, until); +} + +function parseTokenUsageEvent( + sessionId: string, + line: string, + state: SessionParseState, +): TokenUsageEvent | undefined { + const trimmed = line.trim(); + if (trimmed === '' || !isRelevantLogLine(trimmed)) { + return undefined; + } + + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + return undefined; + } + + if (!isRecord(parsed)) { + return undefined; + } + + const entryType = asNonEmptyString(parsed.type); + if (entryType == null) { + return undefined; + } + + const payload = isRecord(parsed.payload) ? parsed.payload : undefined; + if (entryType === 'turn_context') { + const contextModel = extractModel(payload); + if (contextModel != null) { + state.currentModel = contextModel; + state.currentModelIsFallback = false; + } + return undefined; + } + + if (entryType !== 'event_msg' || payload == null) { + return undefined; + } + + if (asNonEmptyString(payload.type) !== 'token_count') { + return undefined; + } + + const timestamp = asNonEmptyString(parsed.timestamp); + if (timestamp == null) { + return undefined; + } + + const info = isRecord(payload.info) ? payload.info : undefined; + const lastUsage = normalizeRawUsage(info?.last_token_usage); + const totalUsage = normalizeRawUsage(info?.total_token_usage); + + let raw = lastUsage; + if (raw == null && totalUsage != null) { + raw = subtractRawUsage(totalUsage, state.previousTotals); + } + + if (totalUsage != null) { + state.previousTotals = totalUsage; + } + + if (raw == null) { + return undefined; + } + + const delta = convertToDelta(raw); + if ( + delta.inputTokens === 0 && + delta.cachedInputTokens === 0 && + delta.outputTokens === 0 && + delta.reasoningOutputTokens === 0 + ) { + return undefined; + } + + const extractedModel = extractModel(payload, info); + let isFallbackModel = false; + if (extractedModel != null) { + state.currentModel = extractedModel; + state.currentModelIsFallback = false; + } + + let model = extractedModel ?? state.currentModel; + if (model == null) { + model = LEGACY_FALLBACK_MODEL; + isFallbackModel = true; + state.legacyFallbackUsed = true; + state.currentModel = model; + state.currentModelIsFallback = true; + } else if (extractedModel == null && state.currentModelIsFallback) { + isFallbackModel = true; + } + + const event: TokenUsageEvent = { + sessionId, + timestamp, + model, + inputTokens: delta.inputTokens, + cachedInputTokens: delta.cachedInputTokens, + outputTokens: delta.outputTokens, + reasoningOutputTokens: delta.reasoningOutputTokens, + totalTokens: delta.totalTokens, + }; + + if (isFallbackModel) { + // Surface the fallback so both table + JSON outputs can annotate pricing that was + // inferred rather than sourced from the log metadata. + event.isFallbackModel = true; + } + + return event; +} + +async function listSessionFiles(directoryPath: string): Promise { + const files = await glob(SESSION_GLOB, { + cwd: directoryPath, + absolute: true, + }); + + return files.map((file) => { + const relativeSessionPath = path.relative(directoryPath, file); + const normalizedSessionPath = relativeSessionPath.split(path.sep).join('/'); + return { + file, + relativeSessionPath: normalizedSessionPath, + sessionId: normalizedSessionPath.replace(/\.jsonl$/i, ''), + }; + }); +} + export type LoadOptions = { sessionDirs?: string[]; + since?: string; + until?: string; + timezone?: string; + onEvent?: (event: TokenUsageEvent) => void | Promise; + collectEvents?: boolean; + sortEvents?: boolean; }; export type LoadResult = { @@ -198,166 +388,88 @@ export async function loadTokenUsageEvents(options: LoadOptions = {}): Promise error, - }); - if (Result.isFailure(statResult)) { + let directoryStats: Stats; + try { + directoryStats = await stat(directoryPath); + } catch { missingDirectories.push(directoryPath); continue; } - if (!statResult.value.isDirectory()) { + if (!directoryStats.isDirectory()) { missingDirectories.push(directoryPath); continue; } - const files = await glob(SESSION_GLOB, { - cwd: directoryPath, - absolute: true, - }); - - for (const file of files) { - const relativeSessionPath = path.relative(directoryPath, file); - const normalizedSessionPath = relativeSessionPath.split(path.sep).join('/'); - const sessionId = normalizedSessionPath.replace(/\.jsonl$/i, ''); - const fileContentResult = await Result.try({ - try: readFile(file, 'utf8'), - catch: (error) => error, - }); - - if (Result.isFailure(fileContentResult)) { - logger.debug('Failed to read Codex session file', fileContentResult.error); + const files = await listSessionFiles(directoryPath); + for (const { file, relativeSessionPath, sessionId } of files) { + let fileStats: Stats; + try { + fileStats = await stat(file); + } catch (error) { + logger.debug('Failed to stat Codex session file', error); continue; } - let previousTotals: RawUsage | null = null; - let currentModel: string | undefined; - let currentModelIsFallback = false; - let legacyFallbackUsed = false; - const lines = fileContentResult.value.split(/\r?\n/); - for (const line of lines) { - const trimmed = line.trim(); - if (trimmed === '') { - continue; - } - - const parseLine = Result.try({ - try: () => JSON.parse(trimmed) as unknown, - catch: (error) => error, - }); - const parsedResult = parseLine(); + if ( + !shouldReadSessionFile( + relativeSessionPath, + fileStats, + options.since, + options.until, + options.timezone, + ) + ) { + continue; + } - if (Result.isFailure(parsedResult)) { - continue; - } + const state: SessionParseState = { + previousTotals: null, + currentModel: undefined, + currentModelIsFallback: false, + legacyFallbackUsed: false, + }; - const entryParse = v.safeParse(entrySchema, parsedResult.value); - if (!entryParse.success) { - continue; - } - - const { type: entryType, payload, timestamp } = entryParse.output; + const lines = createInterface({ + input: createReadStream(file, { encoding: 'utf8' }), + crlfDelay: Infinity, + }); - if (entryType === 'turn_context') { - const contextPayload = v.safeParse(recordSchema, payload ?? null); - if (contextPayload.success) { - const contextModel = extractModel(contextPayload.output); - if (contextModel != null) { - currentModel = contextModel; - currentModelIsFallback = false; - } + try { + for await (const line of lines) { + const event = parseTokenUsageEvent(sessionId, line, state); + if (event == null) { + continue; } - continue; - } - - if (entryType !== 'event_msg') { - continue; - } - const tokenPayloadResult = v.safeParse(tokenCountPayloadSchema, payload ?? undefined); - if (!tokenPayloadResult.success) { - continue; - } - - if (timestamp == null) { - continue; - } - - const info = tokenPayloadResult.output.info; - const lastUsage = normalizeRawUsage(info?.last_token_usage); - const totalUsage = normalizeRawUsage(info?.total_token_usage); - - let raw = lastUsage; - if (raw == null && totalUsage != null) { - raw = subtractRawUsage(totalUsage, previousTotals); - } - - if (totalUsage != null) { - previousTotals = totalUsage; - } - - if (raw == null) { - continue; - } - - const delta = convertToDelta(raw); - if ( - delta.inputTokens === 0 && - delta.cachedInputTokens === 0 && - delta.outputTokens === 0 && - delta.reasoningOutputTokens === 0 - ) { - continue; - } - - const payloadRecordResult = v.safeParse(recordSchema, payload ?? undefined); - const extractionSource = payloadRecordResult.success - ? Object.assign({}, payloadRecordResult.output, { info }) - : { info }; - const extractedModel = extractModel(extractionSource); - let isFallbackModel = false; - if (extractedModel != null) { - currentModel = extractedModel; - currentModelIsFallback = false; - } + if ( + !isEventWithinRange(event.timestamp, options.since, options.until, options.timezone) + ) { + continue; + } - let model = extractedModel ?? currentModel; - if (model == null) { - model = LEGACY_FALLBACK_MODEL; - isFallbackModel = true; - legacyFallbackUsed = true; - currentModel = model; - currentModelIsFallback = true; - } else if (extractedModel == null && currentModelIsFallback) { - isFallbackModel = true; - } + if (collectEvents) { + events.push(event); + } - const event: TokenUsageEvent = { - sessionId, - timestamp, - model, - inputTokens: delta.inputTokens, - cachedInputTokens: delta.cachedInputTokens, - outputTokens: delta.outputTokens, - reasoningOutputTokens: delta.reasoningOutputTokens, - totalTokens: delta.totalTokens, - }; - - if (isFallbackModel) { - // Surface the fallback so both table + JSON outputs can annotate pricing that was - // inferred rather than sourced from the log metadata. - event.isFallbackModel = true; + if (options.onEvent != null) { + await options.onEvent(event); + } } - - events.push(event); + } catch (error) { + logger.debug('Failed to stream Codex session file', error); + continue; + } finally { + lines.close(); } - if (legacyFallbackUsed) { + if (state.legacyFallbackUsed) { logger.debug('Legacy Codex session lacked model metadata; applied fallback', { file, model: LEGACY_FALLBACK_MODEL, @@ -366,7 +478,9 @@ export async function loadTokenUsageEvents(options: LoadOptions = {}): Promise new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()); + if (sortEvents && events.length > 1) { + events.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()); + } return { events, missingDirectories }; } @@ -484,5 +598,134 @@ if (import.meta.vitest != null) { expect(events[0]!.model).toBe('gpt-5'); expect(events[0]!.isFallbackModel).toBe(true); }); + + it('supports streaming callbacks without collecting events', async () => { + await using fixture = await createFixture({ + sessions: { + 'streamed.jsonl': [ + JSON.stringify({ + timestamp: '2025-09-15T13:00:00.000Z', + type: 'turn_context', + payload: { + model: 'gpt-5', + }, + }), + JSON.stringify({ + timestamp: '2025-09-15T13:00:01.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + info: { + last_token_usage: { + input_tokens: 10, + cached_input_tokens: 0, + output_tokens: 5, + reasoning_output_tokens: 0, + total_tokens: 15, + }, + }, + }, + }), + ].join('\n'), + }, + }); + + const seen: TokenUsageEvent[] = []; + const { events } = await loadTokenUsageEvents({ + sessionDirs: [fixture.getPath('sessions')], + collectEvents: false, + sortEvents: false, + onEvent: (event) => { + seen.push(event); + }, + }); + + expect(events).toEqual([]); + expect(seen).toHaveLength(1); + expect(seen[0]!.totalTokens).toBe(15); + }); + + it('skips files that cannot overlap the requested date range', async () => { + await using fixture = await createFixture({ + sessions: { + '2025': { + '09': { + '10': { + 'old.jsonl': [ + JSON.stringify({ + timestamp: '2025-09-10T08:00:00.000Z', + type: 'turn_context', + payload: { + model: 'gpt-5', + }, + }), + JSON.stringify({ + timestamp: '2025-09-10T08:00:01.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + info: { + last_token_usage: { + input_tokens: 1, + cached_input_tokens: 0, + output_tokens: 1, + reasoning_output_tokens: 0, + total_tokens: 2, + }, + }, + }, + }), + ].join('\n'), + }, + '12': { + 'new.jsonl': [ + JSON.stringify({ + timestamp: '2025-09-12T08:00:00.000Z', + type: 'turn_context', + payload: { + model: 'gpt-5-mini', + }, + }), + JSON.stringify({ + timestamp: '2025-09-12T08:00:01.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + info: { + last_token_usage: { + input_tokens: 3, + cached_input_tokens: 0, + output_tokens: 2, + reasoning_output_tokens: 0, + total_tokens: 5, + }, + }, + }, + }), + ].join('\n'), + }, + }, + }, + }, + }); + + const oldPath = fixture.getPath('sessions/2025/09/10/old.jsonl'); + await utimes( + oldPath, + new Date('2025-09-10T08:00:01.000Z'), + new Date('2025-09-10T08:00:01.000Z'), + ); + + const { events } = await loadTokenUsageEvents({ + sessionDirs: [fixture.getPath('sessions')], + since: '2025-09-12', + until: '2025-09-12', + timezone: 'UTC', + }); + + expect(events).toHaveLength(1); + expect(events[0]!.sessionId).toBe('2025/09/12/new'); + expect(events[0]!.model).toBe('gpt-5-mini'); + }); }); } diff --git a/apps/codex/src/monthly-report.ts b/apps/codex/src/monthly-report.ts index b29e03ae..4f6c54f2 100644 --- a/apps/codex/src/monthly-report.ts +++ b/apps/codex/src/monthly-report.ts @@ -17,6 +17,8 @@ export type MonthlyReportOptions = { pricingSource: PricingSource; }; +export type MonthlySummaries = Map; + function createSummary(month: string, initialTimestamp: string): MonthlyUsageSummary { return { month, @@ -31,47 +33,46 @@ function createSummary(month: string, initialTimestamp: string): MonthlyUsageSum }; } -export async function buildMonthlyReport( - events: TokenUsageEvent[], - options: MonthlyReportOptions, -): 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(); +export function accumulateMonthlyUsage( + summaries: MonthlySummaries, + event: TokenUsageEvent, + timezone?: string, +): void { + const modelName = event.model?.trim(); + if (modelName == null || modelName === '') { + return; + } - for (const event of events) { - const modelName = event.model?.trim(); - if (modelName == null || modelName === '') { - continue; - } + const monthKey = toMonthKey(event.timestamp, timezone); + const summary = summaries.get(monthKey) ?? createSummary(monthKey, event.timestamp); + if (!summaries.has(monthKey)) { + summaries.set(monthKey, summary); + } - const dateKey = toDateKey(event.timestamp, timezone); - if (!isWithinRange(dateKey, since, until)) { - continue; - } + 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 monthKey = toMonthKey(event.timestamp, timezone); - const summary = summaries.get(monthKey) ?? createSummary(monthKey, event.timestamp); - if (!summaries.has(monthKey)) { - summaries.set(monthKey, summary); - } +export async function buildMonthlyReportRows( + summaries: MonthlySummaries, + options: Omit, +): Promise { + const locale = options.locale; + const timezone = options.timezone; + const pricingSource = options.pricingSource; - 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; - } + if (summaries.size === 0) { + return []; } const uniqueModels = new Set(); @@ -87,7 +88,6 @@ export async function buildMonthlyReport( } const rows: MonthlyReportRow[] = []; - const sortedSummaries = Array.from(summaries.values()).sort((a, b) => a.month.localeCompare(b.month), ); @@ -122,6 +122,26 @@ export async function buildMonthlyReport( return rows; } +export async function buildMonthlyReport( + events: TokenUsageEvent[], + options: MonthlyReportOptions, +): Promise { + const timezone = options.timezone; + const since = options.since; + const until = options.until; + const summaries: MonthlySummaries = new Map(); + + for (const event of events) { + const dateKey = toDateKey(event.timestamp, timezone); + if (!isWithinRange(dateKey, since, until)) { + continue; + } + accumulateMonthlyUsage(summaries, event, timezone); + } + + return buildMonthlyReportRows(summaries, options); +} + if (import.meta.vitest != null) { describe('buildMonthlyReport', () => { it('aggregates events by month and calculates costs', async () => { diff --git a/apps/codex/src/session-report.ts b/apps/codex/src/session-report.ts index 867d6103..3c1c0629 100644 --- a/apps/codex/src/session-report.ts +++ b/apps/codex/src/session-report.ts @@ -17,6 +17,8 @@ export type SessionReportOptions = { pricingSource: PricingSource; }; +export type SessionSummaries = Map; + function createSummary(sessionId: string, initialTimestamp: string): SessionUsageSummary { return { sessionId, @@ -32,63 +34,53 @@ function createSummary(sessionId: string, initialTimestamp: string): SessionUsag }; } -export async function buildSessionReport( - events: TokenUsageEvent[], - options: SessionReportOptions, -): Promise { - const timezone = options.timezone; - const since = options.since; - const until = options.until; - const pricingSource = options.pricingSource; - - const summaries = new Map(); - - for (const event of events) { - const rawSessionId = event.sessionId; - if (rawSessionId == null) { - continue; - } - const sessionId = rawSessionId.trim(); - if (sessionId === '') { - continue; - } - - const rawModelName = event.model; - if (rawModelName == null) { - continue; - } - const modelName = rawModelName.trim(); - if (modelName === '') { - continue; - } +export function accumulateSessionUsage(summaries: SessionSummaries, event: TokenUsageEvent): void { + const rawSessionId = event.sessionId; + if (rawSessionId == null) { + return; + } + const sessionId = rawSessionId.trim(); + if (sessionId === '') { + return; + } - const dateKey = toDateKey(event.timestamp, timezone); - if (!isWithinRange(dateKey, since, until)) { - continue; - } + const rawModelName = event.model; + if (rawModelName == null) { + return; + } + const modelName = rawModelName.trim(); + if (modelName === '') { + return; + } - const summary = summaries.get(sessionId) ?? createSummary(sessionId, event.timestamp); - if (!summaries.has(sessionId)) { - summaries.set(sessionId, summary); - } + const summary = summaries.get(sessionId) ?? createSummary(sessionId, event.timestamp); + if (!summaries.has(sessionId)) { + summaries.set(sessionId, summary); + } - addUsage(summary, event); - if (event.timestamp > summary.lastTimestamp) { - summary.lastTimestamp = event.timestamp; - } + addUsage(summary, event); + if (event.timestamp > summary.lastTimestamp) { + summary.lastTimestamp = event.timestamp; + } - 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 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; } +} + +export async function buildSessionReportRows( + summaries: SessionSummaries, + options: Pick, +): Promise { + const pricingSource = options.pricingSource; if (summaries.size === 0) { return []; @@ -150,6 +142,26 @@ export async function buildSessionReport( return rows; } +export async function buildSessionReport( + events: TokenUsageEvent[], + options: SessionReportOptions, +): Promise { + const timezone = options.timezone; + const since = options.since; + const until = options.until; + const summaries: SessionSummaries = new Map(); + + for (const event of events) { + const dateKey = toDateKey(event.timestamp, timezone); + if (!isWithinRange(dateKey, since, until)) { + continue; + } + accumulateSessionUsage(summaries, event); + } + + return buildSessionReportRows(summaries, options); +} + if (import.meta.vitest != null) { describe('buildSessionReport', () => { it('groups events by session and calculates costs', async () => { From 4208c47fa66c7dc33f9cc643a7fba28d2563d7ed Mon Sep 17 00:00:00 2001 From: Jack Cheng Date: Fri, 27 Mar 2026 12:40:24 +0800 Subject: [PATCH 2/2] deduplicate replayed usage in forked sessions --- apps/codex/src/data-loader.ts | 516 ++++++++++++++++++++++++++++++++-- 1 file changed, 497 insertions(+), 19 deletions(-) diff --git a/apps/codex/src/data-loader.ts b/apps/codex/src/data-loader.ts index ce987314..af4e1002 100644 --- a/apps/codex/src/data-loader.ts +++ b/apps/codex/src/data-loader.ts @@ -39,6 +39,18 @@ type SessionFileCandidate = { sessionId: string; }; +type SessionFileEntry = SessionFileCandidate & { + fileStats: Stats; + metadataId: string; + forkedFromId?: string; + shouldCollectEvents: boolean; +}; + +type ParsedTokenUsageResult = { + event?: TokenUsageEvent; + cumulativeTotalTokens?: number; +}; + function isRecord(value: unknown): value is JsonRecord { return value != null && typeof value === 'object' && !Array.isArray(value); } @@ -232,7 +244,7 @@ function parseTokenUsageEvent( sessionId: string, line: string, state: SessionParseState, -): TokenUsageEvent | undefined { +): ParsedTokenUsageResult | undefined { const trimmed = line.trim(); if (trimmed === '' || !isRelevantLogLine(trimmed)) { return undefined; @@ -280,6 +292,7 @@ function parseTokenUsageEvent( const info = isRecord(payload.info) ? payload.info : undefined; const lastUsage = normalizeRawUsage(info?.last_token_usage); const totalUsage = normalizeRawUsage(info?.total_token_usage); + const cumulativeTotalTokens = totalUsage?.total_tokens; let raw = lastUsage; if (raw == null && totalUsage != null) { @@ -291,7 +304,9 @@ function parseTokenUsageEvent( } if (raw == null) { - return undefined; + return { + cumulativeTotalTokens, + }; } const delta = convertToDelta(raw); @@ -301,7 +316,9 @@ function parseTokenUsageEvent( delta.outputTokens === 0 && delta.reasoningOutputTokens === 0 ) { - return undefined; + return { + cumulativeTotalTokens, + }; } const extractedModel = extractModel(payload, info); @@ -339,7 +356,10 @@ function parseTokenUsageEvent( event.isFallbackModel = true; } - return event; + return { + event, + cumulativeTotalTokens, + }; } async function listSessionFiles(directoryPath: string): Promise { @@ -348,7 +368,8 @@ async function listSessionFiles(directoryPath: string): Promise { + return files + .map((file) => { const relativeSessionPath = path.relative(directoryPath, file); const normalizedSessionPath = relativeSessionPath.split(path.sep).join('/'); return { @@ -356,7 +377,122 @@ async function listSessionFiles(directoryPath: string): Promise a.relativeSessionPath.localeCompare(b.relativeSessionPath)); +} + +async function readSessionFileMetadata( + candidate: SessionFileCandidate, + fileStats: Stats, + shouldCollectEvents: boolean, +): Promise { + let metadataId = candidate.sessionId; + let forkedFromId: string | undefined; + + const lines = createInterface({ + input: createReadStream(candidate.file, { encoding: 'utf8' }), + crlfDelay: Infinity, }); + + try { + for await (const line of lines) { + const trimmed = line.trim(); + if (trimmed === '') { + continue; + } + + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + continue; + } + + if (!isRecord(parsed) || asNonEmptyString(parsed.type) !== 'session_meta') { + continue; + } + + const payload = isRecord(parsed.payload) ? parsed.payload : undefined; + const id = asNonEmptyString(payload?.id); + if (id != null) { + metadataId = id; + } + + forkedFromId = asNonEmptyString(payload?.forked_from_id); + break; + } + } catch (error) { + logger.debug('Failed to read Codex session metadata', error); + } finally { + lines.close(); + } + + return { + ...candidate, + fileStats, + metadataId, + forkedFromId, + shouldCollectEvents, + }; +} + +function collectRequiredSessionIds( + entries: SessionFileEntry[], + entriesByMetadataId: Map, +): Set { + const required = new Set(); + + for (const entry of entries) { + if (!entry.shouldCollectEvents) { + continue; + } + + let current: SessionFileEntry | undefined = entry; + while (current != null && !required.has(current.metadataId)) { + required.add(current.metadataId); + current = + current.forkedFromId != null ? entriesByMetadataId.get(current.forkedFromId) : undefined; + } + } + + return required; +} + +function orderSessionEntries( + entries: SessionFileEntry[], + requiredIds: Set, + entriesByMetadataId: Map, +): SessionFileEntry[] { + const ordered: SessionFileEntry[] = []; + const visiting = new Set(); + const visited = new Set(); + + const visit = (entry: SessionFileEntry): void => { + if (!requiredIds.has(entry.metadataId) || visited.has(entry.metadataId)) { + return; + } + + if (visiting.has(entry.metadataId)) { + return; + } + + visiting.add(entry.metadataId); + if (entry.forkedFromId != null) { + const parent = entriesByMetadataId.get(entry.forkedFromId); + if (parent != null) { + visit(parent); + } + } + visiting.delete(entry.metadataId); + visited.add(entry.metadataId); + ordered.push(entry); + }; + + for (const entry of entries) { + visit(entry); + } + + return ordered; } export type LoadOptions = { @@ -390,6 +526,7 @@ export async function loadTokenUsageEvents(options: LoadOptions = {}): Promise(); for (const dir of sessionDirs) { const directoryPath = path.resolve(dir); @@ -408,26 +545,36 @@ export async function loadTokenUsageEvents(options: LoadOptions = {}): Promise(); + for (const candidate of files) { let fileStats: Stats; try { - fileStats = await stat(file); + fileStats = await stat(candidate.file); } catch (error) { logger.debug('Failed to stat Codex session file', error); continue; } - if ( - !shouldReadSessionFile( - relativeSessionPath, - fileStats, - options.since, - options.until, - options.timezone, - ) - ) { - continue; + const shouldCollectEvents = shouldReadSessionFile( + candidate.relativeSessionPath, + fileStats, + options.since, + options.until, + options.timezone, + ); + const entry = await readSessionFileMetadata(candidate, fileStats, shouldCollectEvents); + indexedFiles.push(entry); + if (!entriesByMetadataId.has(entry.metadataId)) { + entriesByMetadataId.set(entry.metadataId, entry); } + } + + const requiredIds = collectRequiredSessionIds(indexedFiles, entriesByMetadataId); + const filesToParse = orderSessionEntries(indexedFiles, requiredIds, entriesByMetadataId); + + for (const entry of filesToParse) { + const { file, sessionId, metadataId, forkedFromId, shouldCollectEvents } = entry; const state: SessionParseState = { previousTotals: null, @@ -435,6 +582,11 @@ export async function loadTokenUsageEvents(options: LoadOptions = {}): Promise { + await using fixture = await createFixture({ + sessions: { + 'a-child.jsonl': [ + JSON.stringify({ + timestamp: '2025-09-12T10:00:00.000Z', + type: 'session_meta', + payload: { + id: 'child-session', + forked_from_id: 'parent-session', + }, + }), + JSON.stringify({ + timestamp: '2025-09-12T10:00:00.100Z', + type: 'turn_context', + payload: { + model: 'gpt-5', + }, + }), + JSON.stringify({ + timestamp: '2025-09-12T10:00:01.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + info: { + total_token_usage: { + input_tokens: 80, + cached_input_tokens: 0, + output_tokens: 20, + reasoning_output_tokens: 0, + total_tokens: 100, + }, + last_token_usage: { + input_tokens: 80, + cached_input_tokens: 0, + output_tokens: 20, + reasoning_output_tokens: 0, + total_tokens: 100, + }, + }, + }, + }), + JSON.stringify({ + timestamp: '2025-09-12T10:00:02.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + info: { + total_token_usage: { + input_tokens: 120, + cached_input_tokens: 0, + output_tokens: 30, + reasoning_output_tokens: 0, + total_tokens: 150, + }, + last_token_usage: { + input_tokens: 40, + cached_input_tokens: 0, + output_tokens: 10, + reasoning_output_tokens: 0, + total_tokens: 50, + }, + }, + }, + }), + JSON.stringify({ + timestamp: '2025-09-12T10:00:03.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + info: { + total_token_usage: { + input_tokens: 144, + cached_input_tokens: 0, + output_tokens: 36, + reasoning_output_tokens: 0, + total_tokens: 180, + }, + last_token_usage: { + input_tokens: 24, + cached_input_tokens: 0, + output_tokens: 6, + reasoning_output_tokens: 0, + total_tokens: 30, + }, + }, + }, + }), + ].join('\n'), + 'z-parent.jsonl': [ + JSON.stringify({ + timestamp: '2025-09-12T09:00:00.000Z', + type: 'session_meta', + payload: { + id: 'parent-session', + }, + }), + JSON.stringify({ + timestamp: '2025-09-12T09:00:00.100Z', + type: 'turn_context', + payload: { + model: 'gpt-5', + }, + }), + JSON.stringify({ + timestamp: '2025-09-12T09:00:01.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + info: { + total_token_usage: { + input_tokens: 80, + cached_input_tokens: 0, + output_tokens: 20, + reasoning_output_tokens: 0, + total_tokens: 100, + }, + last_token_usage: { + input_tokens: 80, + cached_input_tokens: 0, + output_tokens: 20, + reasoning_output_tokens: 0, + total_tokens: 100, + }, + }, + }, + }), + JSON.stringify({ + timestamp: '2025-09-12T09:00:02.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + info: { + total_token_usage: { + input_tokens: 120, + cached_input_tokens: 0, + output_tokens: 30, + reasoning_output_tokens: 0, + total_tokens: 150, + }, + last_token_usage: { + input_tokens: 40, + cached_input_tokens: 0, + output_tokens: 10, + reasoning_output_tokens: 0, + total_tokens: 50, + }, + }, + }, + }), + ].join('\n'), + }, + }); + + const { events } = await loadTokenUsageEvents({ + sessionDirs: [fixture.getPath('sessions')], + }); + + expect(events).toHaveLength(3); + expect(events.map((event) => [event.sessionId, event.totalTokens])).toEqual([ + ['z-parent', 100], + ['z-parent', 50], + ['a-child', 30], + ]); + }); + + it('loads fork ancestors outside the requested date range for deduplication', async () => { + await using fixture = await createFixture({ + sessions: { + '2025': { + '09': { + '10': { + 'parent.jsonl': [ + JSON.stringify({ + timestamp: '2025-09-10T09:00:00.000Z', + type: 'session_meta', + payload: { + id: 'parent-range', + }, + }), + JSON.stringify({ + timestamp: '2025-09-10T09:00:00.100Z', + type: 'turn_context', + payload: { + model: 'gpt-5', + }, + }), + JSON.stringify({ + timestamp: '2025-09-10T09:00:01.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + info: { + total_token_usage: { + input_tokens: 80, + cached_input_tokens: 0, + output_tokens: 20, + reasoning_output_tokens: 0, + total_tokens: 100, + }, + last_token_usage: { + input_tokens: 80, + cached_input_tokens: 0, + output_tokens: 20, + reasoning_output_tokens: 0, + total_tokens: 100, + }, + }, + }, + }), + ].join('\n'), + }, + '12': { + 'child.jsonl': [ + JSON.stringify({ + timestamp: '2025-09-12T10:00:00.000Z', + type: 'session_meta', + payload: { + id: 'child-range', + forked_from_id: 'parent-range', + }, + }), + JSON.stringify({ + timestamp: '2025-09-12T10:00:00.100Z', + type: 'turn_context', + payload: { + model: 'gpt-5', + }, + }), + JSON.stringify({ + timestamp: '2025-09-12T10:00:01.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + info: { + total_token_usage: { + input_tokens: 80, + cached_input_tokens: 0, + output_tokens: 20, + reasoning_output_tokens: 0, + total_tokens: 100, + }, + last_token_usage: { + input_tokens: 80, + cached_input_tokens: 0, + output_tokens: 20, + reasoning_output_tokens: 0, + total_tokens: 100, + }, + }, + }, + }), + JSON.stringify({ + timestamp: '2025-09-12T10:00:02.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + info: { + total_token_usage: { + input_tokens: 104, + cached_input_tokens: 0, + output_tokens: 26, + reasoning_output_tokens: 0, + total_tokens: 130, + }, + last_token_usage: { + input_tokens: 24, + cached_input_tokens: 0, + output_tokens: 6, + reasoning_output_tokens: 0, + total_tokens: 30, + }, + }, + }, + }), + ].join('\n'), + }, + }, + }, + }, + }); + + const parentPath = fixture.getPath('sessions/2025/09/10/parent.jsonl'); + await utimes( + parentPath, + new Date('2025-09-10T09:00:01.000Z'), + new Date('2025-09-10T09:00:01.000Z'), + ); + + const { events } = await loadTokenUsageEvents({ + sessionDirs: [fixture.getPath('sessions')], + since: '2025-09-12', + until: '2025-09-12', + timezone: 'UTC', + }); + + expect(events).toHaveLength(1); + expect(events[0]!.sessionId).toBe('2025/09/12/child'); + expect(events[0]!.totalTokens).toBe(30); + }); }); }