Skip to content

Commit 137fe2a

Browse files
committed
fix(cli): drop Codex last_token_usage repeats the cumulative does not back
Codex re-emits a last_token_usage snapshot when only the metadata around it changed (rate limits, service tier). The existing dedup only caught consecutive identical records, so a snapshot re-emitted after other token_count events was counted as a second turn. Treat total_token_usage as the authority: when the cumulative has not advanced, no tokens were spent and the snapshot is a repeat. Track the baseline as optional so the first event of a file is never mistaken for a repeat of a zeroed one. Claude-Session: https://claude.ai/code/session_019u3RKiNJY1sRknveHJy9iJ
1 parent cab4de2 commit 137fe2a

2 files changed

Lines changed: 80 additions & 9 deletions

File tree

‎packages/cli/src/adapters/codex.ts‎

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,10 @@ async function parseCodexSessionFile(
9696
// carry only info.total_token_usage (a cumulative), with no per-turn
9797
// last_token_usage. For those we derive the turn's usage as current-minus-previous
9898
// cumulative — so we must track the last cumulative we saw. Mirrors ccusage's
99-
// previous_totals in subtract_codex_raw_usage.
100-
let previousTotals: CodexCumulative = { input: 0, cached: 0, output: 0, reasoning: 0, total: 0 }
99+
// previous_totals in subtract_codex_raw_usage. `undefined` means no cumulative
100+
// has been seen yet, which is distinct from an all-zero one: the first event of
101+
// a file must never look like a repeat of the baseline (ccusage's Option).
102+
let previousTotals: CodexCumulative | undefined
101103
const pendingToolCalls = new Map<string, { tool: string, startedAt: string, turnId: string | undefined }>()
102104
// Per-turn last activity timestamp and turn start timestamp. When we close a
103105
// turn implicitly (next user_message, or EOF fallback), turn.completed must
@@ -311,7 +313,17 @@ async function parseCodexSessionFile(
311313
// events that carry only a cumulative total are counted instead of dropped.
312314
const totalUsage = objectField(info, 'total_token_usage')
313315
const hasTotal = Object.keys(totalUsage).length > 0
314-
const usage = tokenUsageFromPayload(payload)
316+
// Codex re-emits a last_token_usage snapshot when only metadata around it
317+
// changed (rate limits, service tier). The cumulative is the authority: if
318+
// total_token_usage did not advance, no new tokens were spent, so the
319+
// snapshot is a repeat of one already counted. The lastTokenUsageKey dedup
320+
// below only catches *consecutive* repeats; this also catches re-emissions
321+
// separated by other token_count events. Mirrors ccusage's
322+
// cumulative_advanced filter (adapter/codex/parser.rs).
323+
const cumulativeAdvanced = !hasTotal
324+
|| previousTotals === undefined
325+
|| !sameCodexCumulative(previousTotals, readCodexCumulative(totalUsage))
326+
const usage = (cumulativeAdvanced ? tokenUsageFromPayload(payload) : undefined)
315327
?? (hasTotal ? codexUsageDelta(totalUsage, previousTotals, info) : undefined)
316328
// Advance the baseline from every total_token_usage we see — including
317329
// replayed events we skip — so the first real event's delta is measured
@@ -829,20 +841,28 @@ function readCodexCumulative(usage: Record<string, unknown>): CodexCumulative {
829841
}
830842
}
831843

844+
function sameCodexCumulative(a: CodexCumulative, b: CodexCumulative): boolean {
845+
return a.input === b.input
846+
&& a.cached === b.cached
847+
&& a.output === b.output
848+
&& a.reasoning === b.reasoning
849+
&& a.total === b.total
850+
}
851+
832852
// Per-turn delta from a cumulative total_token_usage minus the prior cumulative
833853
// baseline (ccusage subtract_codex_raw_usage). Returns undefined when the delta is
834854
// entirely zero (e.g. a repeated cumulative) so no empty usage event is emitted.
835855
function codexUsageDelta(
836856
totalUsage: Record<string, unknown>,
837-
previous: CodexCumulative,
857+
previous: CodexCumulative | undefined,
838858
info: Record<string, unknown>,
839859
) {
840860
const cur = readCodexCumulative(totalUsage)
841-
const input = Math.max(0, cur.input - previous.input)
842-
const cached = Math.max(0, cur.cached - previous.cached)
843-
const output = Math.max(0, cur.output - previous.output)
844-
const reasoning = Math.max(0, cur.reasoning - previous.reasoning)
845-
const total = Math.max(0, cur.total - previous.total)
861+
const input = Math.max(0, cur.input - (previous?.input ?? 0))
862+
const cached = Math.max(0, cur.cached - (previous?.cached ?? 0))
863+
const output = Math.max(0, cur.output - (previous?.output ?? 0))
864+
const reasoning = Math.max(0, cur.reasoning - (previous?.reasoning ?? 0))
865+
const total = Math.max(0, cur.total - (previous?.total ?? 0))
846866
if (input === 0 && cached === 0 && output === 0 && reasoning === 0 && total === 0) {
847867
return
848868
}

‎packages/cli/test/codex.test.ts‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,57 @@ test('parity: ccusage codex dedupes consecutive identical last_token_usage', asy
307307
assert.equal(usageEvents(events).length, 1)
308308
})
309309

310+
test('parity: ccusage codex skips repeated last_token_usage when the cumulative total is unchanged', async () => {
311+
// From ccusage adapter/codex/loader.rs
312+
// skips_repeated_last_usage_when_cumulative_total_is_unchanged (#1435).
313+
const info = {
314+
model: 'gpt-5.5',
315+
last_token_usage: { input_tokens: 100, cached_input_tokens: 20, output_tokens: 10, total_tokens: 110 },
316+
total_token_usage: { input_tokens: 100, cached_input_tokens: 20, output_tokens: 10, total_tokens: 110 },
317+
}
318+
const events = await parse([
319+
{ timestamp: '2026-07-10T08:00:00.000Z', type: 'session_meta', payload: { id: 'session-a', cwd: '/w', model_provider: 'gpt-5.5' } },
320+
{ timestamp: '2026-07-10T08:00:01.000Z', type: 'event_msg', payload: { type: 'token_count', info } },
321+
{ timestamp: '2026-07-10T08:00:02.000Z', type: 'event_msg', payload: { type: 'token_count', info } },
322+
])
323+
324+
assert.equal(usageEvents(events).length, 1)
325+
})
326+
327+
test('parity: ccusage codex skips a stale last_token_usage the cumulative does not back', async () => {
328+
// The cumulative check catches what the consecutive-duplicate dedup cannot:
329+
// a re-emitted snapshot whose last_token_usage differs from the record right
330+
// before it, while total_token_usage stands still. The differing value slips
331+
// past lastTokenUsageKey, so only the cumulative proves no tokens were spent.
332+
const events = await parse([
333+
{ timestamp: '2026-07-10T08:00:00.000Z', type: 'session_meta', payload: { id: 'session-a', cwd: '/w', model_provider: 'gpt-5.5' } },
334+
// Turn 1.
335+
{ timestamp: '2026-07-10T08:00:01.000Z', type: 'event_msg', payload: { type: 'token_count', info: { model: 'gpt-5.5', last_token_usage: { input_tokens: 100, cached_input_tokens: 20, output_tokens: 10, total_tokens: 110 }, total_token_usage: { input_tokens: 100, cached_input_tokens: 20, output_tokens: 10, total_tokens: 110 } } } },
336+
// Turn 2 — cumulative advances.
337+
{ timestamp: '2026-07-10T08:00:02.000Z', type: 'event_msg', payload: { type: 'token_count', info: { model: 'gpt-5.5', last_token_usage: { input_tokens: 40, cached_input_tokens: 5, output_tokens: 7, total_tokens: 47 }, total_token_usage: { input_tokens: 140, cached_input_tokens: 25, output_tokens: 17, total_tokens: 157 } } } },
338+
// Turn 1's snapshot re-emitted against turn 2's cumulative: no new tokens.
339+
{ timestamp: '2026-07-10T08:00:03.000Z', type: 'event_msg', payload: { type: 'token_count', info: { model: 'gpt-5.5', last_token_usage: { input_tokens: 100, cached_input_tokens: 20, output_tokens: 10, total_tokens: 110 }, total_token_usage: { input_tokens: 140, cached_input_tokens: 25, output_tokens: 17, total_tokens: 157 } } } },
340+
])
341+
342+
const usages = usageEvents(events)
343+
assert.equal(usages.length, 2)
344+
assert.equal(usages[0].metrics?.tokensTotal, 110)
345+
assert.equal(usages[1].metrics?.tokensTotal, 47)
346+
})
347+
348+
test('parity: ccusage codex counts the first snapshot even when the cumulative is all zeros', async () => {
349+
// ccusage tracks previous_totals as an Option, so the first event of a file is
350+
// never mistaken for a repeat of a zeroed baseline.
351+
const events = await parse([
352+
{ timestamp: '2026-07-10T08:00:00.000Z', type: 'session_meta', payload: { id: 'session-a', cwd: '/w', model_provider: 'gpt-5.5' } },
353+
{ timestamp: '2026-07-10T08:00:01.000Z', type: 'event_msg', payload: { type: 'token_count', info: { model: 'gpt-5.5', last_token_usage: { input_tokens: 100, cached_input_tokens: 20, output_tokens: 10, total_tokens: 110 }, total_token_usage: { input_tokens: 0, cached_input_tokens: 0, output_tokens: 0, total_tokens: 0 } } } },
354+
])
355+
356+
const usages = usageEvents(events)
357+
assert.equal(usages.length, 1)
358+
assert.equal(usages[0].metrics?.tokensTotal, 110)
359+
})
360+
310361
// ── headless `codex exec` parity (turn.completed / result / bare data.usage) ──
311362

312363
test('parity: ccusage codex loads_saved_codex_exec_json_usage', async () => {

0 commit comments

Comments
 (0)