diff --git a/src/core/cost-calculator.ts b/src/core/cost-calculator.ts index 6200d31d..76058df3 100644 --- a/src/core/cost-calculator.ts +++ b/src/core/cost-calculator.ts @@ -66,6 +66,34 @@ function pickNum(obj: any, keys: readonly string[]): number { return 0; } +interface PartitionedInputTokens { + rawInputTokens: number; + inputTokens: number; + cacheReadTokens: number; + cacheCreateTokens: number; +} + +/** Partition a provider's cache-inclusive input total into mutually exclusive + * accounting buckets. Cache read consumes the raw total first, then cache + * creation consumes only the remainder; malformed counters cannot make the + * three buckets exceed the provider's raw input total. */ +function partitionInclusiveInputTokens( + rawInput: number, + reportedCacheRead: number, + reportedCacheCreate: number, +): PartitionedInputTokens { + const rawInputTokens = Math.max(0, rawInput); + const cacheReadTokens = Math.min(rawInputTokens, Math.max(0, reportedCacheRead)); + const afterCacheRead = rawInputTokens - cacheReadTokens; + const cacheCreateTokens = Math.min(afterCacheRead, Math.max(0, reportedCacheCreate)); + return { + rawInputTokens, + inputTokens: afterCacheRead - cacheCreateTokens, + cacheReadTokens, + cacheCreateTokens, + }; +} + function extractNativeUsage(entry: any): { usage: any; model?: string } | null { const candidates = [ { usage: entry?.message?.usage, model: entry?.message?.model }, @@ -91,16 +119,23 @@ function extractCodexTokenCountUsage(entry: any): SessionTokenUsage | null { if (entry?.type !== 'event_msg' || entry?.payload?.type !== 'token_count') return null; const u = entry.payload?.info?.total_token_usage; if (!u || typeof u !== 'object') return null; - const inputTokens = pickNum(u, ['input_tokens', 'inputTokens']); + // Codex-compatible token_count snapshots define input_tokens as the whole + // prompt-side total: cached tokens are a subset, not an additional bucket. + // Keep the raw total in `in` for the dashboard, while the accounting fields + // are mutually exclusive so ledger consumers can safely sum them. const outputTokens = pickNum(u, ['output_tokens', 'outputTokens']); - const cacheReadTokens = pickNum(u, ['cached_input_tokens', 'cachedInputTokens']); + const { rawInputTokens, inputTokens, cacheReadTokens, cacheCreateTokens } = partitionInclusiveInputTokens( + pickNum(u, ['input_tokens', 'inputTokens']), + pickNum(u, ['cached_input_tokens', 'cachedInputTokens', 'cache_read_input_tokens', 'cacheReadInputTokens']), + pickNum(u, ['cache_creation_input_tokens', 'cacheCreationInputTokens', 'cache_write_input_tokens', 'cacheWriteInputTokens']), + ); return { - in: inputTokens, + in: rawInputTokens, out: outputTokens, inputTokens, outputTokens, cacheReadTokens, - cacheCreateTokens: 0, + cacheCreateTokens, model: '', turns: 0, }; @@ -467,6 +502,7 @@ export function readSessionTokenUsageFile(path: string, kind: CachedUsageKind, o } function readTokenUsageFromAidenCheckpoint(path: string): SessionTokenUsage | null { + let rawInputTokens = 0; let inputTokens = 0; let outputTokens = 0; let cacheReadTokens = 0; @@ -484,16 +520,20 @@ function readTokenUsageFromAidenCheckpoint(path: string): SessionTokenUsage | nu if (msg?.type === 'human' || msg?.type === 'tool') continue; const u = msg?.usage_metadata ?? msg?.usage; if (!u || typeof u !== 'object') continue; - const input = pickNum(u, ['input_tokens', 'inputTokens', 'prompt_tokens', 'promptTokens']); + const rawInput = pickNum(u, ['input_tokens', 'inputTokens', 'prompt_tokens', 'promptTokens']); const output = pickNum(u, ['output_tokens', 'outputTokens', 'completion_tokens', 'completionTokens']); - inputTokens += input; - outputTokens += output; - cacheReadTokens += + const reportedCacheRead = pickNum(u?.input_token_details, ['cache_read', 'cached_tokens', 'cacheRead']) + pickNum(u?.input_tokens_details, ['cache_read', 'cached_tokens', 'cacheRead']); - cacheCreateTokens += + const reportedCacheCreate = pickNum(u?.input_token_details, ['cache_creation', 'cache_write', 'cacheCreate']) + pickNum(u?.input_tokens_details, ['cache_creation', 'cache_write', 'cacheCreate']); + const partitioned = partitionInclusiveInputTokens(rawInput, reportedCacheRead, reportedCacheCreate); + rawInputTokens += partitioned.rawInputTokens; + inputTokens += partitioned.inputTokens; + outputTokens += output; + cacheReadTokens += partitioned.cacheReadTokens; + cacheCreateTokens += partitioned.cacheCreateTokens; if (!model && typeof msg?.response_metadata?.model_name === 'string') model = msg.response_metadata.model_name; turns++; } @@ -504,7 +544,7 @@ function readTokenUsageFromAidenCheckpoint(path: string): SessionTokenUsage | nu if (turns === 0) return null; return { - in: inputTokens, + in: rawInputTokens, out: outputTokens, inputTokens, outputTokens, diff --git a/src/services/usage-ledger.ts b/src/services/usage-ledger.ts index 37332b97..69b5544e 100644 --- a/src/services/usage-ledger.ts +++ b/src/services/usage-ledger.ts @@ -23,8 +23,12 @@ import { logger } from '../utils/logger.js'; import { getSessionTokenUsage, type SessionTokenUsage } from '../core/cost-calculator.js'; import type { DaemonSession } from '../core/types.js'; +export type InputTokenSemantics = 'includes_cache' | 'uncached'; + export interface UsageLedgerRecord { - v: 1; + v: 2; + /** `inputTokens` / `totalInputTokens` exclude both cache buckets. */ + inputTokenSemantics: 'uncached'; /** 'ownership' marks a zero-delta marker written at session spawn so * consumers can exclude the session from native parsers BEFORE its first * positive delta lands; absent for normal usage records. */ @@ -78,6 +82,9 @@ interface SessionBaseline { outputTokens: number; cacheReadTokens: number; cacheCreateTokens: number; + /** Missing on v1 state/ledger baselines. For Codex/TraeX/Aiden those legacy + * baselines used includes_cache prompt input and must be migrated once. */ + inputTokenSemantics?: InputTokenSemantics; recordedAt: string; /** Bumped on every baseline reset (shrink, explicit anchor) so identical * totals transitions in different epochs get distinct recordIds. */ @@ -85,7 +92,7 @@ interface SessionBaseline { } interface LedgerState { - v: 1; + v: 2; sessions: { [sessionId: string]: SessionBaseline }; } @@ -110,6 +117,43 @@ function finiteNum(v: unknown): number { return typeof v === 'number' && Number.isFinite(v) ? v : 0; } +function inputTokenSemantics(v: unknown): InputTokenSemantics | undefined { + return v === 'includes_cache' || v === 'uncached' ? v : undefined; +} + +/** Convert any persisted baseline into the producer's current mutually + * exclusive accounting contract before comparing candidates or diffing. + * v1 Codex/TraeX/Aiden baselines had no marker and stored raw includes_cache input; + * other v1 producers already stored uncached input and remain unchanged. */ +function normalizeBaseline( + baseline: SessionBaseline | undefined | null, + cliId: string | undefined, +): SessionBaseline | undefined { + if (!baseline) return undefined; + const semantics = inputTokenSemantics(baseline.inputTokenSemantics); + const semanticsPresent = Object.prototype.hasOwnProperty.call(baseline, 'inputTokenSemantics'); + const includedCache = semantics === 'includes_cache' + || (!semanticsPresent && (cliId === 'codex' || cliId === 'traex' || cliId === 'aiden')); + if (!includedCache) { + return { ...baseline, inputTokenSemantics: 'uncached' }; + } + + // Mirror the native parser's bounded partitioning. Persisted legacy cache + // counters can be inconsistent; each bucket is capped by the remaining raw + // prompt total so the migrated buckets still sum exactly to that total. + const rawInputTokens = Math.max(0, baseline.inputTokens); + const cacheReadTokens = Math.min(rawInputTokens, Math.max(0, baseline.cacheReadTokens)); + const afterCacheRead = rawInputTokens - cacheReadTokens; + const cacheCreateTokens = Math.min(afterCacheRead, Math.max(0, baseline.cacheCreateTokens)); + return { + ...baseline, + inputTokens: afterCacheRead - cacheCreateTokens, + cacheReadTokens, + cacheCreateTokens, + inputTokenSemantics: 'uncached', + }; +} + /** Reconstruct the newest baseline for a session from the ledger files * themselves (newest file first; last matching line in a file is newest). * This is the crash-recovery source of truth: a record that reached the @@ -142,11 +186,13 @@ function baselineFromLedger(dir: string, sessionId: string): SessionBaseline | n } catch { /* skip malformed lines */ } } if (latest) { + const semanticsPresent = Object.prototype.hasOwnProperty.call(latest, 'inputTokenSemantics'); return { inputTokens: finiteNum(latest.totalInputTokens), outputTokens: finiteNum(latest.totalOutputTokens), cacheReadTokens: finiteNum(latest.totalCacheReadTokens), cacheCreateTokens: finiteNum(latest.totalCacheCreateTokens), + ...(semanticsPresent ? { inputTokenSemantics: latest.inputTokenSemantics } : {}), recordedAt: typeof latest.ts === 'string' ? latest.ts : new Date(0).toISOString(), epoch: finiteNum(latest.epoch), }; @@ -173,15 +219,16 @@ function resolveBaseline( dir: string, larkAppId: string | undefined, sessionId: string, + cliId: string | undefined, stateBaseline: SessionBaseline | undefined, ): SessionBaseline | undefined { const key = baselineMemoryKey(larkAppId, sessionId); let remembered = sessionBaselineMemory.get(key); if (remembered === undefined) { - remembered = baselineFromLedger(dir, sessionId); + remembered = normalizeBaseline(baselineFromLedger(dir, sessionId), cliId) ?? null; sessionBaselineMemory.set(key, remembered); } - return newerBaseline(stateBaseline, remembered); + return newerBaseline(normalizeBaseline(stateBaseline, cliId), normalizeBaseline(remembered, cliId)); } /** Baselines for sessions idle longer than this are pruned from state.json. */ @@ -203,10 +250,10 @@ function loadState(dir: string, larkAppId?: string): LedgerState { try { const parsed = JSON.parse(readFileSync(statePath(dir, larkAppId), 'utf8')); if (parsed && typeof parsed === 'object' && parsed.sessions && typeof parsed.sessions === 'object') { - return { v: 1, sessions: parsed.sessions }; + return { v: 2, sessions: parsed.sessions }; } } catch { /* first run or corrupt state — start fresh */ } - return { v: 1, sessions: {} }; + return { v: 2, sessions: {} }; } function saveState(dir: string, larkAppId: string | undefined, state: LedgerState, now: Date): void { @@ -267,7 +314,7 @@ export function recordSessionUsage(args: RecordSessionUsageArgs): UsageLedgerRec mkdirSync(dir, { recursive: true }); const state = loadState(dir, args.larkAppId); - const prev = resolveBaseline(dir, args.larkAppId, args.sessionId, state.sessions[args.sessionId]); + const prev = resolveBaseline(dir, args.larkAppId, args.sessionId, args.cliId, state.sessions[args.sessionId]); const cur = args.usage; const prevEpoch = prev?.epoch ?? 0; @@ -281,6 +328,7 @@ export function recordSessionUsage(args: RecordSessionUsageArgs): UsageLedgerRec outputTokens: cur.outputTokens, cacheReadTokens: cur.cacheReadTokens, cacheCreateTokens: cur.cacheCreateTokens, + inputTokenSemantics: 'uncached', recordedAt: now.toISOString(), epoch: prevEpoch, }; @@ -300,7 +348,8 @@ export function recordSessionUsage(args: RecordSessionUsageArgs): UsageLedgerRec } const record: UsageLedgerRecord = { - v: 1, + v: 2, + inputTokenSemantics: 'uncached', recordId: deterministicRecordId(args.sessionId, prevEpoch, prev, cur), ts: now.toISOString(), epoch: prevEpoch, @@ -353,12 +402,13 @@ export function anchorSessionUsage(args: RecordSessionUsageArgs): void { mkdirSync(dir, { recursive: true }); const state = loadState(dir, args.larkAppId); - const prev = resolveBaseline(dir, args.larkAppId, args.sessionId, state.sessions[args.sessionId]); + const prev = resolveBaseline(dir, args.larkAppId, args.sessionId, args.cliId, state.sessions[args.sessionId]); const baseline: SessionBaseline = { inputTokens: args.usage.inputTokens, outputTokens: args.usage.outputTokens, cacheReadTokens: args.usage.cacheReadTokens, cacheCreateTokens: args.usage.cacheCreateTokens, + inputTokenSemantics: 'uncached', recordedAt: now.toISOString(), // Anchors start a new epoch: transitions after a re-anchor must never // collide with recordIds from before it. @@ -464,7 +514,8 @@ export function recordSessionOwnership(args: RecordSessionOwnershipArgs): UsageL mkdirSync(dir, { recursive: true }); const record: UsageLedgerRecord = { - v: 1, + v: 2, + inputTokenSemantics: 'uncached', kind: 'ownership', recordId, ts: now.toISOString(), @@ -530,7 +581,7 @@ export function reconcileUsageForDaemonSession(ds: DaemonSession, opts?: DaemonS if (!args.usage) return null; const dir = opts?.ledgerDir ?? defaultLedgerDir(); const state = loadState(dir, args.larkAppId); - if (resolveBaseline(dir, args.larkAppId, args.sessionId, state.sessions[args.sessionId])) { + if (resolveBaseline(dir, args.larkAppId, args.sessionId, args.cliId, state.sessions[args.sessionId])) { return recordSessionUsage({ ...args, usage: args.usage, ...opts }); } anchorSessionUsage({ ...args, usage: args.usage, ...opts }); diff --git a/test/cost-calculator-cache.test.ts b/test/cost-calculator-cache.test.ts index a46a529c..d720e07f 100644 --- a/test/cost-calculator-cache.test.ts +++ b/test/cost-calculator-cache.test.ts @@ -41,10 +41,13 @@ function claudeLine(id: string | null, input: number, output: number): string { }); } -function codexCountLine(input: number, output: number): string { +function codexCountLine(input: number, output: number, cacheRead = 0): string { return JSON.stringify({ type: 'event_msg', - payload: { type: 'token_count', info: { total_token_usage: { input_tokens: input, output_tokens: output } } }, + payload: { + type: 'token_count', + info: { total_token_usage: { input_tokens: input, output_tokens: output, cached_input_tokens: cacheRead } }, + }, }); } @@ -177,13 +180,23 @@ describe('readSessionTokenUsageFile caching', () => { it('keeps codex cumulative semantics across incremental reads', () => { const p = join(dir, 'rollout.jsonl'); - writeFileSync(p, `${codexCountLine(100, 20)}\n`); - expect(readSessionTokenUsageFile(p, 'codex')).toMatchObject({ in: 100, out: 20 }); + writeFileSync(p, `${codexCountLine(100, 20, 40)}\n`); + expect(readSessionTokenUsageFile(p, 'codex')).toMatchObject({ + in: 100, + inputTokens: 60, + cacheReadTokens: 40, + out: 20, + }); now += 20_000; - appendFileSync(p, `${codexCountLine(150, 30)}\n`); + appendFileSync(p, `${codexCountLine(150, 30, 60)}\n`); // Latest cumulative snapshot wins — not 100+150. - expect(readSessionTokenUsageFile(p, 'codex')).toMatchObject({ in: 150, out: 30 }); + expect(readSessionTokenUsageFile(p, 'codex')).toMatchObject({ + in: 150, + inputTokens: 90, + cacheReadTokens: 60, + out: 30, + }); }); it('skips oversized transcripts instead of scanning them from byte zero', () => { diff --git a/test/cost-calculator.test.ts b/test/cost-calculator.test.ts index 8f8ec726..df914a29 100644 --- a/test/cost-calculator.test.ts +++ b/test/cost-calculator.test.ts @@ -489,7 +489,7 @@ describe('getSessionTokenUsage', () => { })).toEqual({ in: 150, out: 30, - inputTokens: 150, + inputTokens: 90, outputTokens: 30, cacheReadTokens: 60, cacheCreateTokens: 0, @@ -517,7 +517,7 @@ describe('getSessionTokenUsage', () => { info: { total_token_usage: { input_tokens: 52634, - cached_input_tokens: 0, + cached_input_tokens: 12000, output_tokens: 307, }, }, @@ -532,9 +532,9 @@ describe('getSessionTokenUsage', () => { })).toEqual({ in: 52634, out: 307, - inputTokens: 52634, + inputTokens: 40634, outputTokens: 307, - cacheReadTokens: 0, + cacheReadTokens: 12000, cacheCreateTokens: 0, turns: 0, model: 'openrouter-1', @@ -542,6 +542,34 @@ describe('getSessionTokenUsage', () => { expect(findTraexRolloutBySessionId).toHaveBeenCalledWith('traex-sid'); }); + it('clamps Codex cache buckets to raw input before deriving uncached input', () => { + vi.mocked(findCodexSessionIdByBotmuxSessionId).mockReturnValue('codex-sid'); + vi.mocked(findCodexRolloutBySessionId).mockReturnValue('/home/testuser/.codex/sessions/rollout-codex-sid.jsonl'); + setupJsonl(JSON.stringify({ + type: 'event_msg', + payload: { + type: 'token_count', + info: { + total_token_usage: { + input_tokens: 50, + cached_input_tokens: 45, + cache_creation_input_tokens: 20, + output_tokens: 7, + }, + }, + }, + })); + + const usage = getSessionTokenUsage({ cliId: 'codex', sessionId: 'botmux-sid' }); + expect(usage).toMatchObject({ + in: 50, + inputTokens: 0, + cacheReadTokens: 45, + cacheCreateTokens: 5, + }); + expect(usage!.inputTokens + usage!.cacheReadTokens + usage!.cacheCreateTokens).toBe(usage!.in); + }); + it('reports CoCo nested response_meta usage without counting agent_end duplicates', () => { setupJsonl([ JSON.stringify({ @@ -594,7 +622,7 @@ describe('getSessionTokenUsage', () => { }); }); - it('reports Aiden checkpoint usage_metadata without double-counting cache read', () => { + it('partitions Aiden raw input into bounded uncached/cache buckets while preserving dashboard in', () => { vi.mocked(findAidenLatestCheckpointBySessionId).mockReturnValue('/home/testuser/.aiden/checkpoints/ws/aiden-sid/latest-checkpoint.json'); setupJsonl(JSON.stringify({ checkpoint: { @@ -611,7 +639,7 @@ describe('getSessionTokenUsage', () => { input_tokens: 100, output_tokens: 20, total_tokens: 120, - input_token_details: { cache_read: 40 }, + input_token_details: { cache_read: 40, cache_creation: 70 }, }, }, { @@ -620,7 +648,7 @@ describe('getSessionTokenUsage', () => { input_tokens: 150, output_tokens: 30, total_tokens: 180, - input_token_details: { cache_read: 60 }, + input_token_details: { cache_read: 60, cache_creation: 20 }, }, }, ], @@ -634,10 +662,10 @@ describe('getSessionTokenUsage', () => { })).toEqual({ in: 250, out: 50, - inputTokens: 250, + inputTokens: 70, outputTokens: 50, cacheReadTokens: 100, - cacheCreateTokens: 0, + cacheCreateTokens: 80, turns: 2, model: 'aiden-model', }); @@ -709,7 +737,7 @@ describe('getSessionTokenUsage', () => { expect(getSessionTokenUsage({ cliId: 'codex', sessionId: 'botmux-sid' })).toEqual({ in: 150, out: 30, - inputTokens: 150, + inputTokens: 90, outputTokens: 30, cacheReadTokens: 60, cacheCreateTokens: 0, diff --git a/test/usage-ledger.test.ts b/test/usage-ledger.test.ts index 6fe9738d..00ebfc78 100644 --- a/test/usage-ledger.test.ts +++ b/test/usage-ledger.test.ts @@ -87,7 +87,8 @@ describe('recordSessionUsage', () => { }); expect(rec).toMatchObject({ - v: 1, + v: 2, + inputTokenSemantics: 'uncached', larkAppId: 'cli_app', sessionId: 'sess-1', cliId: 'claude-code', @@ -261,6 +262,287 @@ describe('recordSessionUsage', () => { const rec = recordSessionUsage({ ...baseArgs({ larkAppId: 'cli_a' }), ledgerDir: dir, usage: cumulative(150, 12) }); expect(rec).toMatchObject({ inputTokens: 50, outputTokens: 2 }); }); + + it('migrates a legacy Codex includes_cache state baseline without treating the first v2 snapshot as shrink', () => { + writeFileSync(join(dir, 'state-cli_app.json'), JSON.stringify({ + v: 1, + sessions: { + 'sess-1': { + inputTokens: 150, + outputTokens: 30, + cacheReadTokens: 60, + cacheCreateTokens: 0, + recordedAt: '2026-06-10T11:00:00.000Z', + epoch: 0, + }, + }, + })); + + expect(recordSessionUsage({ + ...baseArgs({ cliId: 'codex' }), + ledgerDir: dir, + usage: cumulative(90, 30, 60), + })).toBeNull(); + + const rec = recordSessionUsage({ + ...baseArgs({ cliId: 'codex', now: new Date('2026-06-10T12:05:00Z') }), + ledgerDir: dir, + usage: cumulative(120, 40, 80), + }); + expect(rec).toMatchObject({ + v: 2, + inputTokenSemantics: 'uncached', + epoch: 0, + inputTokens: 30, + outputTokens: 10, + cacheReadTokens: 20, + }); + }); + + it('migrates a legacy TraeX includes_cache ledger baseline before crash recovery diffing', () => { + writeFileSync(join(dir, 'usage-2026-06-10.jsonl'), JSON.stringify({ + v: 1, + recordId: 'legacy-traex-record', + ts: '2026-06-10T11:00:00.000Z', + epoch: 0, + sessionId: 'sess-1', + cliId: 'traex', + totalInputTokens: 150, + totalOutputTokens: 30, + totalCacheReadTokens: 60, + totalCacheCreateTokens: 0, + }) + '\n'); + + const rec = recordSessionUsage({ + ...baseArgs({ cliId: 'traex' }), + ledgerDir: dir, + usage: cumulative(120, 40, 80), + }); + expect(rec).toMatchObject({ + epoch: 0, + inputTokens: 30, + outputTokens: 10, + cacheReadTokens: 20, + }); + }); + + it('migrates a legacy Aiden includes_cache state baseline without treating the first v2 snapshot as shrink', () => { + writeFileSync(join(dir, 'state-cli_app.json'), JSON.stringify({ + v: 1, + sessions: { + 'sess-1': { + inputTokens: 150, + outputTokens: 30, + cacheReadTokens: 60, + cacheCreateTokens: 0, + recordedAt: '2026-06-10T11:00:00.000Z', + epoch: 0, + }, + }, + })); + + expect(recordSessionUsage({ + ...baseArgs({ cliId: 'aiden' }), + ledgerDir: dir, + usage: cumulative(90, 30, 60), + })).toBeNull(); + + const rec = recordSessionUsage({ + ...baseArgs({ cliId: 'aiden', now: new Date('2026-06-10T12:05:00Z') }), + ledgerDir: dir, + usage: cumulative(120, 40, 80), + }); + expect(rec).toMatchObject({ + v: 2, + inputTokenSemantics: 'uncached', + epoch: 0, + inputTokens: 30, + outputTokens: 10, + cacheReadTokens: 20, + }); + }); + + it('migrates a legacy Aiden includes_cache ledger baseline before crash recovery diffing', () => { + writeFileSync(join(dir, 'usage-2026-06-10.jsonl'), JSON.stringify({ + v: 1, + recordId: 'legacy-aiden-record', + ts: '2026-06-10T11:00:00.000Z', + epoch: 0, + sessionId: 'sess-1', + cliId: 'aiden', + totalInputTokens: 150, + totalOutputTokens: 30, + totalCacheReadTokens: 60, + totalCacheCreateTokens: 0, + }) + '\n'); + + const rec = recordSessionUsage({ + ...baseArgs({ cliId: 'aiden' }), + ledgerDir: dir, + usage: cumulative(120, 40, 80), + }); + expect(rec).toMatchObject({ + epoch: 0, + inputTokens: 30, + outputTokens: 10, + cacheReadTokens: 20, + }); + }); + + it('does not subtract cache twice from explicitly uncached Codex or Aiden baselines', () => { + for (const cliId of ['codex', 'aiden']) { + const sessionId = `sess-${cliId}`; + writeFileSync(join(dir, 'state-cli_app.json'), JSON.stringify({ + v: 2, + sessions: { + [sessionId]: { + inputTokens: 90, + outputTokens: 30, + cacheReadTokens: 60, + cacheCreateTokens: 0, + inputTokenSemantics: 'uncached', + recordedAt: '2026-06-10T11:00:00.000Z', + epoch: 0, + }, + }, + })); + + const rec = recordSessionUsage({ + ...baseArgs({ cliId, sessionId }), + ledgerDir: dir, + usage: cumulative(120, 40, 80), + }); + expect(rec).toMatchObject({ inputTokens: 30, outputTokens: 10, cacheReadTokens: 20 }); + } + }); + + it.each(['state', 'ledger'])('does not apply legacy cache subtraction to an unknown explicit semantics in %s', (source) => { + const baseline = { + inputTokens: 90, + outputTokens: 30, + cacheReadTokens: 60, + cacheCreateTokens: 0, + inputTokenSemantics: 'future_contract', + recordedAt: '2026-06-10T11:00:00.000Z', + epoch: 0, + }; + if (source === 'state') { + writeFileSync(join(dir, 'state-cli_app.json'), JSON.stringify({ + v: 2, + sessions: { 'sess-1': baseline }, + })); + } else { + writeFileSync(join(dir, 'usage-2026-06-10.jsonl'), JSON.stringify({ + v: 2, + recordId: 'unknown-semantics-record', + ts: baseline.recordedAt, + sessionId: 'sess-1', + cliId: 'codex', + totalInputTokens: baseline.inputTokens, + totalOutputTokens: baseline.outputTokens, + totalCacheReadTokens: baseline.cacheReadTokens, + totalCacheCreateTokens: baseline.cacheCreateTokens, + inputTokenSemantics: baseline.inputTokenSemantics, + epoch: baseline.epoch, + }) + '\n'); + } + + const rec = recordSessionUsage({ + ...baseArgs({ cliId: 'codex' }), + ledgerDir: dir, + usage: cumulative(120, 40, 80), + }); + expect(rec).toMatchObject({ + inputTokens: 30, + outputTokens: 10, + cacheReadTokens: 20, + }); + }); + + it('normalizes an explicit includes_cache baseline even without a Codex CLI hint', () => { + writeFileSync(join(dir, 'state-cli_app.json'), JSON.stringify({ + v: 2, + sessions: { + 'sess-1': { + inputTokens: 50, + outputTokens: 10, + cacheReadTokens: 45, + cacheCreateTokens: 20, + inputTokenSemantics: 'includes_cache', + recordedAt: '2026-06-10T11:00:00.000Z', + epoch: 0, + }, + }, + })); + + const rec = recordSessionUsage({ + ...baseArgs({ cliId: 'claude-code' }), + ledgerDir: dir, + usage: cumulative(10, 20, 50, 5), + }); + expect(rec).toMatchObject({ + inputTokens: 10, + outputTokens: 10, + cacheReadTokens: 5, + cacheCreateTokens: 0, + }); + }); + + it('keeps legacy Claude baselines unchanged because their input was already uncached', () => { + writeFileSync(join(dir, 'state-cli_app.json'), JSON.stringify({ + v: 1, + sessions: { + 'sess-1': { + inputTokens: 100, + outputTokens: 10, + cacheReadTokens: 20, + cacheCreateTokens: 5, + recordedAt: '2026-06-10T11:00:00.000Z', + epoch: 0, + }, + }, + })); + + const rec = recordSessionUsage({ + ...baseArgs({ cliId: 'claude-code' }), + ledgerDir: dir, + usage: cumulative(130, 20, 30, 7), + }); + expect(rec).toMatchObject({ inputTokens: 30, outputTokens: 10, cacheReadTokens: 10, cacheCreateTokens: 2 }); + }); + + it('recovers from a v2 uncached ledger record without replaying the same transition', () => { + const legacyState = JSON.stringify({ + v: 1, + sessions: { + 'sess-1': { + inputTokens: 150, + outputTokens: 30, + cacheReadTokens: 60, + cacheCreateTokens: 0, + recordedAt: '2026-06-10T11:00:00.000Z', + epoch: 0, + }, + }, + }); + const stateFile = join(dir, 'state-cli_app.json'); + writeFileSync(stateFile, legacyState); + + const usage = cumulative(120, 40, 80); + const first = recordSessionUsage({ ...baseArgs({ cliId: 'codex' }), ledgerDir: dir, usage }); + expect(first).not.toBeNull(); + + // Simulate append success followed by a lost state advance and process crash. + writeFileSync(stateFile, legacyState); + __resetUsageLedgerMemoryForTest(); + expect(recordSessionUsage({ + ...baseArgs({ cliId: 'codex', now: new Date('2026-06-10T12:05:00Z') }), + ledgerDir: dir, + usage, + })).toBeNull(); + expect(ledgerLines(dir)).toHaveLength(1); + expect(ledgerLines(dir)[0].recordId).toBe(first!.recordId); + }); }); describe('anchorSessionUsage', () => { @@ -402,7 +684,8 @@ describe('ownership records', () => { }); expect(rec).toMatchObject({ - v: 1, + v: 2, + inputTokenSemantics: 'uncached', kind: 'ownership', sessionId: 'sess-1', cliSessionId: 'cli-sess-1',