Files
orca/src/main/codex-usage/codex-usage-token-delta.ts
T
Neil 97b71c2285 refactor(usage): split AI-usage scanners and stores under the max-lines budget (#14668)
The three usage scanners and their stores, plus the renderer usage-overview
model, each carried a file-level `eslint-disable max-lines` and had grown to
338-769 counted lines against a 300-line budget. AGENTS.md calls for splitting
rather than suppressing, and config/max-lines-baseline.txt is a shrink-only
ratchet, so this removes all seven suppressions and prunes their entries
(341 -> 334).

Each file is cut along the seams it already had -- and that several of the
suppression comments named out loud: filesystem discovery / record parsing /
attribution / aggregation for the scanners, and pricing policy / scope filters /
rollups / session rows / automation attribution for the stores.

Pure move, no behavior change. Code is relocated verbatim; the only edits are
import plumbing and, where a private class method became a free function, the
mechanical `this.state` -> `state` parameter threading. Every converted call
site passes `this.state` at call time and the automation path takes a live
`getState: () => this.state` getter, so no state is snapshotted. No barrel
exports: each new module owns real logic and importers point at the owner.

Verified: oxlint clean, ratchet passes, typecheck clean, full unit suite green
(remaining failures are pre-existing load flakes in untouched files, each green
when re-run serially), no import cycles among the 64 affected modules, and a
statement-level diff of every split confirms the moves are verbatim.
2026-08-15 18:33:33 -07:00

179 lines
6.0 KiB
TypeScript

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<string, unknown>
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('|')
}