import { ensureNumber } from '../usage/usage-record-coercion' export type CodexUsageRawUsage = { inputTokens: number cachedInputTokens: number outputTokens: number reasoningOutputTokens: number totalTokens: number } type CodexUsageDeltaResolution = | { kind: 'event'; delta: CodexUsageRawUsage; nextTotals: CodexUsageRawUsage | null } | { kind: 'baseline'; nextTotals: CodexUsageRawUsage } export function normalizeRawUsage(value: unknown): CodexUsageRawUsage | null { if (value == null || typeof value !== 'object') { return null } const record = value as Record const inputTokens = ensureNumber(record.input_tokens) const cachedInputTokens = ensureNumber( record.cached_input_tokens ?? record.cache_read_input_tokens ) const outputTokens = ensureNumber(record.output_tokens) const reasoningOutputTokens = ensureNumber(record.reasoning_output_tokens) const totalTokens = ensureNumber(record.total_tokens) return { inputTokens, cachedInputTokens, outputTokens, reasoningOutputTokens, // Why: legacy Codex logs can omit total_tokens. Reasoning is already billed // inside output, so synthesizing input+output matches Codex pricing instead // of double-counting reasoning as another billable bucket. totalTokens: totalTokens > 0 ? totalTokens : inputTokens + outputTokens } } function subtractRawUsage( current: CodexUsageRawUsage, previous: CodexUsageRawUsage | null ): CodexUsageRawUsage { return { inputTokens: Math.max(current.inputTokens - (previous?.inputTokens ?? 0), 0), cachedInputTokens: Math.max(current.cachedInputTokens - (previous?.cachedInputTokens ?? 0), 0), outputTokens: Math.max(current.outputTokens - (previous?.outputTokens ?? 0), 0), reasoningOutputTokens: Math.max( current.reasoningOutputTokens - (previous?.reasoningOutputTokens ?? 0), 0 ), totalTokens: Math.max(current.totalTokens - (previous?.totalTokens ?? 0), 0) } } function addRawUsage(left: CodexUsageRawUsage, right: CodexUsageRawUsage): CodexUsageRawUsage { return { inputTokens: left.inputTokens + right.inputTokens, cachedInputTokens: left.cachedInputTokens + right.cachedInputTokens, outputTokens: left.outputTokens + right.outputTokens, reasoningOutputTokens: left.reasoningOutputTokens + right.reasoningOutputTokens, totalTokens: left.totalTokens + right.totalTokens } } function rawUsageEquals(left: CodexUsageRawUsage, right: CodexUsageRawUsage): boolean { return ( left.inputTokens === right.inputTokens && left.cachedInputTokens === right.cachedInputTokens && left.outputTokens === right.outputTokens && left.reasoningOutputTokens === right.reasoningOutputTokens ) } function rawUsageIsMonotonic(current: CodexUsageRawUsage, previous: CodexUsageRawUsage): boolean { return ( current.inputTokens >= previous.inputTokens && current.cachedInputTokens >= previous.cachedInputTokens && current.outputTokens >= previous.outputTokens && current.reasoningOutputTokens >= previous.reasoningOutputTokens ) } function rawUsageMagnitude(usage: CodexUsageRawUsage): number { return ( usage.inputTokens + usage.cachedInputTokens + usage.outputTokens + usage.reasoningOutputTokens ) } function looksLikeStaleRegression( current: CodexUsageRawUsage, previous: CodexUsageRawUsage, last: CodexUsageRawUsage ): boolean { const previousTotal = rawUsageMagnitude(previous) const currentTotal = rawUsageMagnitude(current) const lastTotal = rawUsageMagnitude(last) if (previousTotal <= 0 || currentTotal <= 0 || lastTotal <= 0) { return false } return currentTotal * 100 >= previousTotal * 98 || currentTotal + lastTotal * 2 >= previousTotal } export function resolveCodexUsageDelta( totalUsage: CodexUsageRawUsage | null, lastUsage: CodexUsageRawUsage | null, previousTotals: CodexUsageRawUsage | null ): CodexUsageDeltaResolution | null { if (totalUsage && lastUsage && previousTotals) { if (rawUsageEquals(totalUsage, previousTotals)) { return null } if ( !rawUsageIsMonotonic(totalUsage, previousTotals) && looksLikeStaleRegression(totalUsage, previousTotals, lastUsage) ) { return null } // Why: Codex totals are mutable snapshots after compaction/resume. The // last_token_usage payload is the billable increment; totals are the baseline. return { kind: 'event', delta: lastUsage, nextTotals: totalUsage } } if (totalUsage && lastUsage) { return { kind: 'event', delta: lastUsage, nextTotals: totalUsage } } if (totalUsage && previousTotals) { if (rawUsageEquals(totalUsage, previousTotals)) { return null } if (!rawUsageIsMonotonic(totalUsage, previousTotals)) { return { kind: 'baseline', nextTotals: totalUsage } } return { kind: 'event', delta: subtractRawUsage(totalUsage, previousTotals), nextTotals: totalUsage } } if (totalUsage) { return { kind: 'event', delta: totalUsage, nextTotals: totalUsage } } if (lastUsage && previousTotals) { return { kind: 'event', delta: lastUsage, nextTotals: addRawUsage(previousTotals, lastUsage) } } if (lastUsage) { return { kind: 'event', delta: lastUsage, nextTotals: null } } return null } export function buildCodexUsageEventKey( timestamp: string, totalUsage: CodexUsageRawUsage | null, lastUsage: CodexUsageRawUsage | null ): string { // Why: fork/resume copies token_count records byte-for-byte into a new // rollout file, but session_meta.id is often rewritten to the new session. // Key only on the raw record fields (timestamp + usage tuples) so the copy // matches the original regardless of surrounding parse context / session id. const tupleOf = (usage: CodexUsageRawUsage | null): string => usage ? [ usage.inputTokens, usage.cachedInputTokens, usage.outputTokens, usage.reasoningOutputTokens, usage.totalTokens ].join(',') : '' return [timestamp, tupleOf(totalUsage), tupleOf(lastUsage)].join('|') }