Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/codex-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ import type { ParsedProviderCall } from './providers/types.js'
// v15: builtin alias prices `codex-auto-review` (#1047). Exact-hit cache
// entries still hold the pre-alias $0; bump so unchanged rollouts reprice.
// Must be max(main v14 #1092, this)+1 — #1092 spent v14 on MCP/skills.
export const CODEX_CACHE_VERSION = 15
// Missing cumulative usage no longer collapses distinct records.
export const CODEX_CACHE_VERSION = 16
export const CODEX_LEGACY_CACHE_FILE = 'codex-results.json'
export function codexCacheFileName(version = CODEX_CACHE_VERSION): string {
return `codex-results.v${version}.json`
Expand Down
24 changes: 12 additions & 12 deletions src/providers/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1142,13 +1142,12 @@ function createParser(source: SessionSource, seenKeys: Set<string>, capture?: {
}

const cumulativeTotal = info.total_token_usage?.total_tokens ?? 0
// Dedup guard. Two consecutive events with cumulativeTotal=0 but
// non-empty last_token_usage would have been double-counted with
// the previous `> 0` clause. The null sentinel ensures the FIRST
// event always passes (so a session that never reports cumulative
// doesn't lose its opening turn).
if (prevCumulativeTotal !== null && cumulativeTotal === prevCumulativeTotal) continue
prevCumulativeTotal = cumulativeTotal
// Missing/null/partial cumulative data is not a repeated zero total.
const reportsCumulative = typeof info.total_token_usage?.total_tokens === 'number'
&& Number.isFinite(info.total_token_usage.total_tokens)
&& info.total_token_usage.total_tokens >= 0
if (reportsCumulative && prevCumulativeTotal !== null && cumulativeTotal === prevCumulativeTotal) continue
prevCumulativeTotal = reportsCumulative ? cumulativeTotal : null

const last = info.last_token_usage
let inputTokens = 0
Expand Down Expand Up @@ -1227,12 +1226,13 @@ function createParser(source: SessionSource, seenKeys: Set<string>, capture?: {
// are computed against a running `prev` that the fork advances
// differently once the 5s cutoff skips some replays, so a delta-based
// key would spuriously diverge on a replay and double-count it.
const dedupKey = `codex:${forkedFromId || sessionId}:${cumulativeTotal}:${total?.input_tokens ?? 0}:${total?.cached_input_tokens ?? 0}:${total?.output_tokens ?? 0}:${total?.reasoning_output_tokens ?? 0}`
// Without cumulative identity, equal usage can be distinct requests.
// Use the physical record position: stable on cache resume/re-read,
// but deliberately do not guess cross-file replay identity.
const dedupKey = reportsCumulative
? `codex:${forkedFromId || sessionId}:${cumulativeTotal}:${total?.input_tokens ?? 0}:${total?.cached_input_tokens ?? 0}:${total?.output_tokens ?? 0}:${total?.reasoning_output_tokens ?? 0}`
: `codex:record:${JSON.stringify([source.path, tracker.lastCompleteLineOffset])}`

// A drop here can only be a byte-identical replay: the
// prevCumulativeTotal guard above already discards a repeated
// running total, so nothing reaching this point ever loses real
// tokens -- no active-time rescaling needed (#1088 investigation).
if (seenKeys.has(dedupKey)) continue
seenKeys.add(dedupKey)

Expand Down
2 changes: 1 addition & 1 deletion src/session-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,7 @@ export const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
// activity-price-v1: `codex-auto-review` now prices via the recommended
// review model. session-cache.json would otherwise keep the pre-alias $0.
// Compose all four — a take-ours merge would drop #1075, #1079, or #1092.
codex: 'mcp-attribution-v5-est-cost-active-timing-mcp-wait-rich-capture-v1-cross-provider-pr-v1-session-meta-model-v1-session-meta-fields-v1-codex-pricing-v1-codex-tps-v1-codex-mcp-skills-v1-activity-price-v1',
codex: 'mcp-attribution-v5-est-cost-active-timing-mcp-wait-rich-capture-v1-cross-provider-pr-v1-session-meta-model-v1-session-meta-fields-v1-codex-pricing-v1-codex-tps-v1-codex-mcp-skills-v1-activity-price-v1-missing-cumulative-v1',
cursor: 'composer-anchored-crediting-v1-est-cost',
'cursor-agent': 'workspaceless-transcript-v1',
// source-provenance-v1 (#944): CLI sessions were misread as VS Code
Expand Down
34 changes: 34 additions & 0 deletions tests/codex-missing-cumulative.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { it, expect, vi } from 'vitest'
import { mkdtemp, writeFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createCodexProvider } from '../src/providers/codex.js'

vi.mock('../src/codex-cache.js', async (original) => ({
...await original<typeof import('../src/codex-cache.js')>(),
readCachedCodexResults: async () => null,
readCodexResume: async () => null,
writeCachedCodexResults: async () => {},
}))

for (const total of [undefined, null, {}]) {
it(`preserves distinct equal-usage records with cumulative ${JSON.stringify(total)}`, async () => {
const dir = await mkdtemp(join(tmpdir(), 'codeburn-missing-total-'))
try {
const path = join(dir, 'rollout-synthetic.jsonl')
const lines = [
{ type: 'session_meta', timestamp: '2026-09-01T10:00:00Z', payload: { session_id: 's', model: 'gpt-5.3-codex' } },
...[1, 2, 3].map(n => ({ type: 'event_msg', timestamp: `2026-09-01T10:00:0${n}Z`, payload: {
type: 'token_count', info: { last_token_usage: { input_tokens: 100, output_tokens: 20 }, total_token_usage: total },
} })),
]
await writeFile(path, lines.map(line => JSON.stringify(line)).join('\n') + '\n')
const parser = createCodexProvider(dir).createSessionParser({ path, project: 'test', provider: 'codex' }, new Set())
const calls = []
for await (const call of parser.parse()) calls.push(call)
expect(calls).toHaveLength(3)
expect(calls.reduce((sum, call) => sum + call.outputTokens, 0)).toBe(60)
expect(new Set(calls.map(call => call.deduplicationKey)).size).toBe(3)
} finally { await rm(dir, { recursive: true, force: true }) }
})
}