diff --git a/CHANGELOG.md b/CHANGELOG.md index acd824b0..42959283 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ - **`codeburn sync` re-keys device, span, and trace identity.** Device, span, and trace ids are now domain-separated HMAC digests under the per-install privacy key rather than bare hashes. This is a one-time identity re-key: a sync backend sees a new device identity after upgrade, and spans pushed before the upgrade won't correlate with spans pushed after it. `sync push` also now hard-aborts — instead of degrading to an ephemeral, unstable key — when the config dir is unwritable or the on-disk privacy-key file is corrupt. ### Fixed +- **Codex spend no longer counts reasoning tokens twice, and cache writes are priced only where OpenAI actually charges for them.** OpenAI bills reasoning tokens as *part of* `output_tokens`, not on top of it, but CodeBurn added `reasoning_output_tokens` to output when pricing a Codex call and again in the models and audit displays. Every Codex number was therefore too high: on a real corpus **cost by 3.5%** and **displayed Output tokens by 34.6%** ($4,713.12 -> $4,547.09; 22.6M -> 16.8M output tokens). The raw `reasoningTokens` figure is unchanged and still reported on its own; only the double-count is gone. Both places that price a Codex call — the host pricing pass and the cache-rehydration re-price — now go through one shared `billableOutputTokens` helper, so a cold run and a warm run can never disagree. Separately, Codex's `cache_write_input_tokens` (new in codex PR #33454) was never read and cache-creation tokens were hardcoded to 0; the core decoder now reads and clamps them so they can never exceed the uncached input, and the host carves them out of that bucket **only on models whose pricing source publishes a real cache-write rate** — gpt-5.6 and its terra/sol/luna variants charge 1.25x input for a cache write, everything before it charges nothing extra — because CodeBurn fabricates a 1.25x rate when a source omits one, and charging that would have invented a surcharge on gpt-5.5, gpt-5.4, gpt-5.3-codex and gpt-5. On models without an explicit rate the tokens stay in the plain input bucket and the price is unchanged to the cent. The field is new enough that today's impact is $0 on that corpus. Codex sessions re-parse once (codex results cache v13, codex parse version `-codex-pricing-v1`) and the daily cache takes v28 so finalized days re-derive; days whose Codex transcripts have since aged out are held by the same never-lose guard #1040 relies on and keep their pre-fix totals. No other provider's numbers move. This is the `feat/core-extraction` port of #1078 and its #1084 hardening follow-up, so the final branch→main merge keeps the fix instead of reverting it. Thanks @chr-evensen. (#1083, ports #1075/#1078/#1084) - Claude Desktop and Cowork sessions are discovered for Windows Microsoft Store (MSIX) installs. (#611) - **Nine providers served silently stale numbers after you pointed their env override at a different profile or root.** Kiro, Grok, Kimi, Mux, Mistral Vibe, Zerostack, Codebuff, Goose and Crush each honor an env var that relocates where discovery looks, but the var was never declared in the provider env fingerprint, so the cache section survived the change and kept reporting sessions parsed from the old root — with no diagnostic anywhere. The fix declares those vars, the adjacent OS-set path variables that resolve a discovery root for Claude, IBM Bob, Open Design and Kilo Code on Windows and Linux, Cursor's parse-budget override, and the Vercel AI Gateway credential — which must invalidate the fingerprint because a read-only refresh serves the cached report and would otherwise keep reporting the previous account's usage after a swap. Your next run re-parses the fourteen file-backed providers whose declarations changed — the nine above plus Claude, Cursor, Open Design, IBM Bob and Kilo Code — once, and only once; the Vercel AI Gateway declaration is a read-only-path correction, not a migration (its report is re-fetched on every writable run anyway); Copilot is deliberately NOT included because a discovery-root/account switch needs provider-specific namespace semantics — blindly carrying its durable section would combine old-root and new-root histories; `codeburn doctor` names deliberate overrides including the XDG_* vars, never the Windows ambient APPDATA / LOCALAPPDATA, and redacts credential values. (#920) - Sessions that were silently invisible now appear: Pi and Oh My Pi transcripts with an OMP title slot, Cline sessions under Code - Insiders or VSCodium roots, and OpenCode/kilo-code usage that silently read as zero now reports. (#930) diff --git a/packages/cli/src/audit-report.ts b/packages/cli/src/audit-report.ts index a133dcf4..7e80faf1 100644 --- a/packages/cli/src/audit-report.ts +++ b/packages/cli/src/audit-report.ts @@ -1,4 +1,4 @@ -import { getModelCosts, type ModelCosts } from './models.js' +import { billableOutputTokens, getModelCosts, type ModelCosts } from './models.js' import { getProvider } from './providers/index.js' import { formatCost, formatTokens } from './format.js' import { renderTable, type TableColumn } from './text-table.js' @@ -122,7 +122,7 @@ export async function aggregateAudit(projects: ProjectSummary[]): Promise (Number.isFinite(n) && n > 0 ? n : 0) const PER_MILLION = 1_000_000 - const output = safe(tokens.outputTokens) + safe(tokens.reasoningTokens ?? 0) return ( (safe(tokens.inputTokens) / PER_MILLION) * rate.input + (safe(tokens.cachedReadTokens) / PER_MILLION) * rate.cachedInput + - (output / PER_MILLION) * rate.output + (safe(tokens.outputTokens) / PER_MILLION) * rate.output ) } diff --git a/packages/cli/src/daily-cache.ts b/packages/cli/src/daily-cache.ts index 0461589e..21141477 100644 --- a/packages/cli/src/daily-cache.ts +++ b/packages/cli/src/daily-cache.ts @@ -5,6 +5,16 @@ import { homedir } from 'os' import { join } from 'path' import type { DateRange, ProjectSummary } from './types.js' +// Bumped to 28: the #1075/#1078 codex pricing fix, ported to this branch +// (#1083). Reasoning tokens were billed on top of output (they are a subset of +// it) and cache_write_input_tokens was ignored, so days finalized at v27 carry +// codex costs overstated by ~3.5% and codex output tokens overstated by ~34.6%. +// `usage-aggregator` serves every day before today from this cache and +// retention is ten years, so nothing downstream would ever notice; raising +// MIN_SUPPORTED_VERSION forces the one-time re-derivation. (main's own ladder +// took 23 for the same fix; this branch reconciles at its final merge by +// keeping the max.) +// // Bumped to 27: the Cline CLI (npm `cline`, 3.x) is a NEW provider, so every // historical session under ~/.cline/data/sessions contributes usage that no // older rollup ever contained. Those files were never scanned before they were @@ -109,8 +119,8 @@ import type { DateRange, ProjectSummary } from './types.js' // that older binaries skipped. v8 added local-model savings to the daily // rollup; the `savingsConfigHash` field is invalidated separately when the // user changes their `localModelSavings` mapping. -export const DAILY_CACHE_VERSION = 27 -const MIN_SUPPORTED_VERSION = 27 +export const DAILY_CACHE_VERSION = 28 +const MIN_SUPPORTED_VERSION = 28 // Version-suffixed so different binaries each own a distinct file and never // clobber an incompatible schema. Bumping the version mints a fresh filename; // adoptOlderDailyCaches then unions days out of every previous file (including diff --git a/packages/cli/src/models-report.ts b/packages/cli/src/models-report.ts index aaeb2235..b7a8c1c0 100644 --- a/packages/cli/src/models-report.ts +++ b/packages/cli/src/models-report.ts @@ -3,6 +3,7 @@ import stripAnsi from 'strip-ansi' import { codexCredits } from './codex-credits.js' import { formatCost, formatTokens } from './format.js' +import { billableOutputTokens } from './models.js' import { getProvider } from './providers/index.js' import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js' @@ -119,7 +120,7 @@ export async function aggregateModels(projects: ProjectSummary[], opts: Aggregat buckets.set(key, bucket) } bucket.inputTokens += call.usage.inputTokens - bucket.outputTokens += call.usage.outputTokens + call.usage.reasoningTokens + bucket.outputTokens += billableOutputTokens(provider, call.usage.outputTokens, call.usage.reasoningTokens) bucket.cacheWriteTokens += call.usage.cacheCreationInputTokens // cacheReadInputTokens (Anthropic vocab) and cachedInputTokens (OpenAI vocab) // are two names for the same thing. Providers populate one or set both to the @@ -179,9 +180,10 @@ export async function aggregateModels(projects: ProjectSummary[], opts: Aggregat savingsUSD: bucket.savingsUSD, savingsBaselineModel: bucket.savingsBaselineModel, calls: bucket.calls, - // outputTokens already includes reasoning (folded in above), and for Codex - // inputTokens is non-cached with cacheReadTokens holding cached input, which - // is exactly what the credit rates expect. + // outputTokens is the billable output (for Codex that already contains + // reasoning, so nothing is added on top), and inputTokens is non-cached + // with cacheReadTokens holding cached input - exactly what the credit + // rates expect. credits: bucket.provider === 'codex' ? codexCredits(bucket.model, { inputTokens: bucket.inputTokens, diff --git a/packages/cli/src/models.ts b/packages/cli/src/models.ts index 84311e7f..bf43ac87 100644 --- a/packages/cli/src/models.ts +++ b/packages/cli/src/models.ts @@ -12,6 +12,28 @@ export type ModelCosts = { cacheReadCostPerToken: number webSearchCostPerRequest: number fastMultiplier: number + /// True only when the pricing source carried a real cache-write rate. When + /// absent/false, `cacheWriteCostPerToken` is the fabricated `1.25 x input` + /// default, which is right for Anthropic-style pricing but would invent a + /// surcharge on providers that charge nothing extra to write cache. Callers + /// that decide WHICH bucket to put tokens in (rather than what to multiply + /// them by) must consult this before routing tokens to the cache-write + /// bucket. Optional so an incomplete literal defaults to the safe answer. + cacheWriteCostIsExplicit?: boolean +} + +/// Providers whose reported `reasoningTokens` are a SUBSET of `outputTokens` +/// rather than a separate bucket to add on top. OpenAI bills reasoning as part +/// of output (every codex `token_count` event satisfies input + output == +/// total), and Anthropic folds thinking into output the same way, so summing +/// the two double-counts both the cost and the displayed output tokens. +const REASONING_INCLUDED_IN_OUTPUT = new Set(['claude', 'codex']) + +/// Output tokens to bill and display for one call. Single source of truth so +/// the pricing sites and the display sums can never disagree about whether a +/// provider's reasoning tokens are already inside its output count (#1075). +export function billableOutputTokens(provider: string, outputTokens: number, reasoningTokens: number): number { + return REASONING_INCLUDED_IN_OUTPUT.has(provider) ? outputTokens : outputTokens + reasoningTokens } type PriceOverrideRates = { @@ -36,6 +58,11 @@ type SnapshotEntry = [number, number, number | null, number | null, (number | nu const LITELLM_URL = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json' const CACHE_TTL_MS = 24 * 60 * 60 * 1000 +// Bump whenever a ModelCosts field changes pricing behavior (cacheWriteCostIsExplicit, +// added in #1075/#1078). A cache written under an older/missing version is treated as a +// miss instead of read verbatim, so a stale on-disk file can't reintroduce a killed bug +// for up to CACHE_TTL_MS after an upgrade. +const CACHE_SCHEMA_VERSION = 2 const WEB_SEARCH_COST = 0.01 const ONE_HOUR_CACHE_WRITE_MULTIPLIER_FROM_FIVE_MINUTE_RATE = 1.6 @@ -70,6 +97,7 @@ function buildCosts( cacheReadCostPerToken: cacheRead ?? input * 0.1, webSearchCostPerRequest: WEB_SEARCH_COST, fastMultiplier: fast ?? 1, + cacheWriteCostIsExplicit: cacheWrite !== null && cacheWrite !== undefined, } } @@ -200,6 +228,7 @@ async function fetchAndCachePricing(): Promise> { await mkdir(getCacheDir(), { recursive: true }) await writeFile(getCachePath(), JSON.stringify({ + version: CACHE_SCHEMA_VERSION, timestamp: Date.now(), data: Object.fromEntries(pricing), })) @@ -210,7 +239,8 @@ async function fetchAndCachePricing(): Promise> { async function loadCachedPricing(): Promise | null> { try { const raw = await readFile(getCachePath(), 'utf-8') - const cached = JSON.parse(raw) as { timestamp: number; data: Record } + const cached = JSON.parse(raw) as { version?: number; timestamp: number; data: Record } + if (cached.version !== CACHE_SCHEMA_VERSION) return null if (Date.now() - cached.timestamp > CACHE_TTL_MS) return null return new Map(Object.entries(cached.data)) } catch { diff --git a/packages/cli/src/parser.ts b/packages/cli/src/parser.ts index cca90399..f2b1e52a 100644 --- a/packages/cli/src/parser.ts +++ b/packages/cli/src/parser.ts @@ -1,7 +1,7 @@ import { lstat, readFile, readdir, stat } from 'fs/promises' import { basename, dirname, join, resolve, sep } from 'path' import { readSessionLines } from './fs-utils.js' -import { calculateCost, calculateLocalModelSavings, getShortModelName, isProxiedPath, getProxyPathsConfigHash } from './models.js' +import { billableOutputTokens, calculateCost, calculateLocalModelSavings, getShortModelName, isProxiedPath, getProxyPathsConfigHash } from './models.js' import { resolveSubagentAttribution, sessionIdentity } from './sessions-report.js' import { discoverAllSessions, getProvider } from './providers/index.js' import { priceProviderCall } from './pricing-pass.js' @@ -1272,9 +1272,10 @@ function mergeDurableCachedTurns( function cachedCallToApiCall(call: CachedCall): ParsedApiCall { const u = call.usage - const outputForCost = call.provider === 'claude' - ? u.outputTokens - : u.outputTokens + u.reasoningTokens + // Cache-rehydration twin of the fresh-parse pricing in pricing-pass.ts: both + // go through billableOutputTokens so a cached read and a cold parse can never + // disagree about whether reasoning is already inside output (#1075). + const outputForCost = billableOutputTokens(call.provider, u.outputTokens, u.reasoningTokens) const costUSD = calculateCost( call.model, u.inputTokens, outputForCost, u.cacheCreationInputTokens, u.cacheReadInputTokens, diff --git a/packages/cli/src/pricing-pass.ts b/packages/cli/src/pricing-pass.ts index 46bce774..59ea5b8a 100644 --- a/packages/cli/src/pricing-pass.ts +++ b/packages/cli/src/pricing-pass.ts @@ -1,4 +1,4 @@ -import { calculateCost } from './models.js' +import { billableOutputTokens, calculateCost } from './models.js' import type { ParsedProviderCall } from './providers/types.js' // Host-side pricing pass (Phase 0 of the @codeburn/core extraction). @@ -20,10 +20,14 @@ import type { ParsedProviderCall } from './providers/types.js' export function priceProviderCall(call: ParsedProviderCall): ParsedProviderCall { if (call.costBasis !== 'estimated') return call - // Mirror cachedCallToApiCall's non-claude branch: reasoning tokens are billed - // at the output rate. Provider calls never carry 1-hour cache tokens (the - // cache write path hardcodes them to 0), so the default 0 is correct here. - const outputForCost = call.outputTokens + call.reasoningTokens + // Mirror cachedCallToApiCall: reasoning tokens are billed at the output rate, + // except for the providers that already report reasoning INSIDE output + // (billableOutputTokens in models.ts), where adding it double-counts (#1075). + // Both sites go through that helper so a fresh parse and a cache-rehydrated + // read can never price the same call differently. Provider calls never carry + // 1-hour cache tokens (the cache write path hardcodes them to 0), so the + // default 0 is correct here. + const outputForCost = billableOutputTokens(call.provider, call.outputTokens, call.reasoningTokens) // Seam extension: price `pricingModel` when the decoder supplied one (its // display `model` differs from the model the price table is keyed by, e.g. // antigravity's suffix-stripped / aliased id). Falls back to `model` for diff --git a/packages/cli/src/providers/codex.ts b/packages/cli/src/providers/codex.ts index 4300b085..5d1eca67 100644 --- a/packages/cli/src/providers/codex.ts +++ b/packages/cli/src/providers/codex.ts @@ -8,6 +8,7 @@ import { decodeCodex, codexToolNameMap, countUnifiedDiffLoc } from '@codeburn/co import type { CodexDecodedCall, CodexDecodeState, CodexEntry } from '@codeburn/core/providers/codex' import { readSessionLines } from '../fs-utils.js' +import { getModelCosts } from '../models.js' import { priceProviderCall } from '../pricing-pass.js' import { readCodexCacheEntry, @@ -185,12 +186,22 @@ async function discoverSessionsInDir(codexDir: string): Promise // marks the call so the pricing pass fills `costUSD` from the token buckets, // byte-identical to the two in-decoder pricing calls this retires (issue #809). function toPricedProviderCall(rich: CodexDecodedCall): ParsedProviderCall { + // Only move the decoder's cache-write candidate into the cache-write bucket + // when the pricing source publishes a real cache-write rate for this model + // (gpt-5.6+ charges 1.25x input; everything before it charges nothing extra). + // Otherwise buildCosts' fabricated 1.25x default would invent a surcharge + // OpenAI never billed, so the tokens stay where they already were - in plain + // input, priced exactly as before (#1075). The rate table is host-side, which + // is why this split lives here and not in the decoder. + const cacheWriteTokens = rich.cacheWriteCandidateTokens > 0 && getModelCosts(rich.model)?.cacheWriteCostIsExplicit + ? rich.cacheWriteCandidateTokens + : 0 const call: ParsedProviderCall = { provider: 'codex', model: rich.model, - inputTokens: rich.inputTokens, + inputTokens: rich.inputTokens - cacheWriteTokens, outputTokens: rich.outputTokens, - cacheCreationInputTokens: rich.cacheCreationInputTokens, + cacheCreationInputTokens: rich.cacheCreationInputTokens + cacheWriteTokens, cacheReadInputTokens: rich.cacheReadInputTokens, cachedInputTokens: rich.cachedInputTokens, reasoningTokens: rich.reasoningTokens, diff --git a/packages/cli/src/session-cache.ts b/packages/cli/src/session-cache.ts index bfab06ab..f64facf2 100644 --- a/packages/cli/src/session-cache.ts +++ b/packages/cli/src/session-cache.ts @@ -308,7 +308,12 @@ export const PROVIDER_PARSE_VERSIONS: Record = { // task_started boundary (see codex-cache.ts v12). Cached turns hold neither, // so bump in lockstep with that cache or session-cache.json keeps serving // timing-less turns without ever invoking the parser. - codex: 'mcp-attribution-v2-est-cost-rich-capture-v1-cross-provider-pr-v1-active-timing-v1', + // codex-pricing-v1 (#1075, ported in #1083): reasoning tokens are no longer + // added on top of output, and cache_write_input_tokens moves out of the plain + // input bucket on models with an explicit cache-write rate. The bucket move + // does NOT self-heal on read (cached entries store the buckets, not the raw + // event), so cached sessions must re-parse. + codex: 'mcp-attribution-v2-est-cost-rich-capture-v1-cross-provider-pr-v1-active-timing-v1-codex-pricing-v1', cursor: 'composer-anchored-crediting-v1-est-cost', 'cursor-agent': 'workspaceless-transcript-v1', copilot: 'cli-shutdown-cost-v1-skills-dedup-key-hmac-v2', diff --git a/packages/cli/src/usage-aggregator.ts b/packages/cli/src/usage-aggregator.ts index 13ec3f5f..3a869ddb 100644 --- a/packages/cli/src/usage-aggregator.ts +++ b/packages/cli/src/usage-aggregator.ts @@ -451,9 +451,9 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts: } claudeConfigs = claudeConfigs ?? await claudeConfigSelector(scanProjects, null) - // Codex credits for the period. Reuses the models aggregation (folds reasoning - // into output, keeps non-cached input + cached-read separate) so the figure - // matches the official credit rates. + // Codex credits for the period. Reuses the models aggregation (billable output + // already includes reasoning for codex, keeps non-cached input + cached-read + // separate) so the figure matches the official credit rates. const modelRows = await aggregateModels(scanProjects) currentData.codexCredits = modelRows.reduce( (sum, r) => sum + (r.provider === 'codex' && r.credits != null ? r.credits : 0), diff --git a/packages/cli/tests/audit-report.test.ts b/packages/cli/tests/audit-report.test.ts index 1723712c..28479799 100644 --- a/packages/cli/tests/audit-report.test.ts +++ b/packages/cli/tests/audit-report.test.ts @@ -86,8 +86,11 @@ describe('aggregateAudit', () => { expect(r.raw.reasoningTokens).toBe(10) expect(r.raw.cacheReadInputTokens).toBe(200) expect(r.raw.cachedInputTokens).toBe(300) - // reasoning folds into output for pricing - expect(r.displayed.outputTokens).toBe(110) + // Reasoning does NOT fold into output for claude or codex: both bill it + // as part of output_tokens already, so adding it would double-count + // (#1075/#1083). Providers that report reasoning as a separate bucket + // still get the additive treatment - see codex-pricing-1083.test.ts. + expect(r.displayed.outputTokens).toBe(100) // cache read is the SUM of per-call max(anthropic, openai), not max of sums expect(r.displayed.cacheReadTokens).toBe(500) // attributed cost is preserved exactly diff --git a/packages/cli/tests/codex-credits.test.ts b/packages/cli/tests/codex-credits.test.ts index cfa6c482..46ecd70f 100644 --- a/packages/cli/tests/codex-credits.test.ts +++ b/packages/cli/tests/codex-credits.test.ts @@ -32,11 +32,6 @@ describe('codexCredits', () => { expect(codexCredits('gpt-5.5', { inputTokens: 0, cachedReadTokens: 1_000_000, outputTokens: 0 })).toBe(12.5) }) - it('folds reasoning tokens into the output rate', () => { - // 500k output + 500k reasoning = 1M output-billed => 750 credits. - expect(codexCredits('gpt-5.5', { inputTokens: 0, cachedReadTokens: 0, outputTokens: 500_000, reasoningTokens: 500_000 })).toBe(750) - }) - it('sums a mixed record (gpt-5.4)', () => { // 2M input (125) + 1M cached (6.25) + 0.5M output (187.5) = 318.75 const credits = codexCredits('gpt-5.4', { inputTokens: 2_000_000, cachedReadTokens: 1_000_000, outputTokens: 500_000 }) diff --git a/packages/cli/tests/codex-pricing-1083-rehydrate.test.ts b/packages/cli/tests/codex-pricing-1083-rehydrate.test.ts new file mode 100644 index 00000000..5f7265e5 --- /dev/null +++ b/packages/cli/tests/codex-pricing-1083-rehydrate.test.ts @@ -0,0 +1,69 @@ +// #1083, cost site 2 of 2. Codex is NOT on parser.ts's reported-cost +// pass-through allowlist, so the session cache stores its calls with +// `costUSD: undefined` and every warm run re-prices them from the stored token +// buckets in cachedCallToApiCall. That line and the one in src/pricing-pass.ts +// are twins: if only one drops the reasoning double-count, a user's number +// changes between a cold and a warm run. This drives the full parseAllSessions +// pipeline twice against the same file to prove they agree. +// +// Own file because the codex provider captures CODEX_HOME when its module is +// first evaluated, so the env must be set before any import of it. + +import { afterAll, beforeEach, expect, it, vi } from 'vitest' +import { mkdir, rm, writeFile } from 'fs/promises' +import { join } from 'path' + +const testRoot = vi.hoisted(() => { + const root = `${process.env['TMPDIR'] || '/tmp'}/codex-1083-rehydrate-${process.pid}-${Date.now()}` + process.env['HOME'] = `${root}/home` + process.env['USERPROFILE'] = `${root}/home` + process.env['CODEX_HOME'] = `${root}/codex` + return root +}) + +const CODEX_HOME = join(testRoot, 'codex') +const CACHE_DIR = join(testRoot, 'cache') + +// gpt-5.5: input 5e-6, output 30e-6, cacheRead 5e-7 (src/data/litellm-snapshot.json). +// 800 uncached input + 200 cached + 1000 output, of which 400 are reasoning. +const EXPECTED = 800 * 5e-6 + 200 * 5e-7 + 1000 * 30e-6 + +beforeEach(() => { + process.env['HOME'] = join(testRoot, 'home') + process.env['USERPROFILE'] = join(testRoot, 'home') + process.env['CODEX_HOME'] = CODEX_HOME + process.env['CODEBURN_CACHE_DIR'] = CACHE_DIR +}) + +afterAll(async () => { + await rm(testRoot, { recursive: true, force: true }) +}) + +it('prices a codex call the same on a cold parse and a cache-rehydrated read', async () => { + const sessionDir = join(CODEX_HOME, 'sessions', '2026', '08', '16') + await mkdir(sessionDir, { recursive: true }) + await mkdir(CACHE_DIR, { recursive: true }) + const usage = { input_tokens: 1000, cached_input_tokens: 200, output_tokens: 1000, reasoning_output_tokens: 400, total_tokens: 2000 } + await writeFile(join(sessionDir, 'rollout-1083.jsonl'), [ + JSON.stringify({ type: 'session_meta', timestamp: '2026-08-16T10:00:00Z', payload: { session_id: 's1083', model: 'gpt-5.5', cwd: '/Users/test/proj', originator: 'codex_cli_rs' } }), + JSON.stringify({ type: 'response_item', timestamp: '2026-08-16T10:00:10Z', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'hello' }] } }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-08-16T10:01:00Z', payload: { type: 'token_count', info: { model: 'gpt-5.5', last_token_usage: usage, total_token_usage: usage } } }), + ].join('\n') + '\n') + + const { clearSessionCache, parseAllSessions } = await import('../src/parser.js') + + clearSessionCache() + const cold = await parseAllSessions(undefined, 'codex') + const coldCost = cold.reduce((sum, p) => sum + p.totalCostUSD, 0) + + // Drop the in-memory cache only: the on-disk session cache now serves the + // unchanged file, so this run's cost comes out of cachedCallToApiCall. + clearSessionCache() + const warm = await parseAllSessions(undefined, 'codex') + const warmCost = warm.reduce((sum, p) => sum + p.totalCostUSD, 0) + + // Revert only src/pricing-pass.ts and the cold leg breaks; revert only + // src/parser.ts's outputForCost and the warm leg breaks. + expect(coldCost).toBeCloseTo(EXPECTED, 12) + expect(warmCost).toBeCloseTo(EXPECTED, 12) +}) diff --git a/packages/cli/tests/codex-pricing-1083.test.ts b/packages/cli/tests/codex-pricing-1083.test.ts new file mode 100644 index 00000000..1c9bb086 --- /dev/null +++ b/packages/cli/tests/codex-pricing-1083.test.ts @@ -0,0 +1,349 @@ +// Regression suite for #1083 — the branch port of #1075/#1078. +// +// Two independent codex pricing bugs, each with the site that would silently +// drift from its twin if only one half were reverted: +// +// A. reasoning_output_tokens is a SUBSET of output_tokens (OpenAI bills +// reasoning as part of output; every token_count event in a 134k-event +// corpus satisfies input + output == total), but codeburn added the two. +// On this branch the fresh-parse price lives in src/pricing-pass.ts and +// the cache-rehydration re-price in src/parser.ts; both now go through +// billableOutputTokens(). The rehydration half lives in +// codex-pricing-1083-rehydrate.test.ts, which needs CODEX_HOME set before +// the provider module is evaluated. +// +// B. cache_write_input_tokens was never read. The core decoder now reads and +// clamps it, and the host adapter carves it out of the uncached-input +// bucket — but ONLY on models whose pricing source carries an explicit +// cache-write rate: buildCosts() fabricates 1.25x input when the source +// omits one, which is right for Anthropic but would invent a surcharge +// OpenAI never charged on every pre-5.6 model. + +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'fs/promises' +import { statSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { aggregateAudit } from '../src/audit-report.js' +import { aggregateModels } from '../src/models-report.js' +import { currentTzKey, ensureCacheHydrated, toDateString, type DailyEntry } from '../src/daily-cache.js' +import { createCodexProvider } from '../src/providers/codex.js' +import type { ParsedProviderCall } from '../src/providers/types.js' +import type { + ClassifiedTurn, + ParsedApiCall, + ProjectSummary, + SessionSummary, + TaskCategory, + TokenUsage, +} from '../src/types.js' + +// Snapshot ground truth (src/data/litellm-snapshot.json), USD per token: +// gpt-5.6-terra input 2.5e-6 output 15e-6 cacheWrite 3.125e-6 (EXPLICIT) cacheRead 2.5e-7 +// gpt-5.5 input 5e-6 output 30e-6 cacheWrite null (fabricated) cacheRead 5e-7 +const TERRA = { input: 2.5e-6, output: 15e-6, cacheWrite: 3.125e-6, cacheRead: 2.5e-7 } +const GPT55 = { input: 5e-6, output: 30e-6, cacheRead: 5e-7 } + +let tmpDir: string +beforeEach(async () => { tmpDir = await mkdtemp(join(tmpdir(), 'codex-1083-')) }) +afterEach(async () => { await rm(tmpDir, { recursive: true, force: true }) }) + +type Usage = { + input_tokens: number + cached_input_tokens?: number + cache_write_input_tokens?: number + output_tokens: number + reasoning_output_tokens?: number +} + +async function parseOneEvent(model: string, usage: Usage): Promise { + const total = usage.input_tokens + usage.output_tokens + const sessionDir = join(tmpDir, 'sessions', '2026', '08', '16') + await mkdir(sessionDir, { recursive: true }) + const filePath = join(sessionDir, `rollout-${model}-${Math.random().toString(36).slice(2)}.jsonl`) + await writeFile(filePath, [ + JSON.stringify({ + type: 'session_meta', + timestamp: '2026-08-16T10:00:00Z', + payload: { cwd: '/Users/t/p', originator: 'codex-cli', session_id: 's1083', model }, + }), + JSON.stringify({ + type: 'event_msg', + timestamp: '2026-08-16T10:01:00Z', + payload: { + type: 'token_count', + info: { model, last_token_usage: { ...usage, total_tokens: total }, total_token_usage: { ...usage, total_tokens: total } }, + }, + }), + ].join('\n') + '\n') + + const provider = createCodexProvider(tmpDir) + const parser = provider.createSessionParser({ path: filePath, project: 'test', provider: 'codex' }, new Set()) + const calls: ParsedProviderCall[] = [] + for await (const call of parser.parse()) calls.push(call) + expect(calls).toHaveLength(1) + return calls[0]! +} + +// ── Fix A: reasoning is already inside output ───────────────────────────── + +describe('#1083 A - reasoning is not billed on top of output', () => { + it('prices a fresh codex parse from output_tokens alone', async () => { + const call = await parseOneEvent('gpt-5.5', { + input_tokens: 1000, + cached_input_tokens: 200, + output_tokens: 1000, + reasoning_output_tokens: 400, + }) + + // 800 uncached input + 200 cached + 1000 output. The 400 reasoning tokens + // are INSIDE the 1000, so they must not be priced again. + const expected = 800 * GPT55.input + 200 * GPT55.cacheRead + 1000 * GPT55.output + expect(call.costUSD).toBeCloseTo(expected, 12) + // Guard the direction: the pre-fix arithmetic charged 1400 output tokens. + const preFix = 800 * GPT55.input + 200 * GPT55.cacheRead + 1400 * GPT55.output + expect(call.costUSD).toBeLessThan(preFix) + // The raw fields are still reported untouched; only the pricing changed. + expect(call.outputTokens).toBe(1000) + expect(call.reasoningTokens).toBe(400) + }) + + it('does not double-count reasoning in the displayed output tokens', async () => { + const codex = makeApiCall('codex', 'gpt-5.5', { outputTokens: 1000, reasoningTokens: 400 }) + // A provider that really does report reasoning as a separate bucket keeps + // the additive behaviour, so this is a codex carve-out and not a blanket + // change to every display sum. Gemini documents "thoughts" as genuinely + // separate from output, unlike codex/claude. + const additive = makeApiCall('gemini', 'gemini-2.5-pro', { outputTokens: 1000, reasoningTokens: 400 }) + const projects = [makeProject([codex, additive])] + + const auditRows = await aggregateAudit(projects) + expect(auditRows.find(r => r.provider === 'codex')!.displayed.outputTokens).toBe(1000) + expect(auditRows.find(r => r.provider === 'gemini')!.displayed.outputTokens).toBe(1400) + + const modelRows = await aggregateModels(projects) + expect(modelRows.find(r => r.provider === 'codex')!.outputTokens).toBe(1000) + expect(modelRows.find(r => r.provider === 'gemini')!.outputTokens).toBe(1400) + }) +}) + +// ── Fix B: cache_write_input_tokens, guarded ────────────────────────────── + +describe('#1083 B - cache_write_input_tokens', () => { + it('prices cache writes at the explicit rate on gpt-5.6-terra', async () => { + const call = await parseOneEvent('gpt-5.6-terra', { + input_tokens: 1000, + cached_input_tokens: 200, + cache_write_input_tokens: 300, + output_tokens: 100, + }) + + expect(call.inputTokens).toBe(500) + expect(call.cacheCreationInputTokens).toBe(300) + expect(call.cacheReadInputTokens).toBe(200) + const expected = + 500 * TERRA.input + + 300 * TERRA.cacheWrite + + 200 * TERRA.cacheRead + + 100 * TERRA.output + expect(call.costUSD).toBeCloseTo(expected, 12) + }) + + it('THE GUARD: leaves cache writes in the input bucket when the model has no explicit rate', async () => { + // gpt-5.5 carries `null` for its cache-write slot, so buildCosts fabricates + // 1.25x input for it. OpenAI charges nothing extra to write cache before + // gpt-5.6, so routing these tokens through that fabricated rate would + // invent a surcharge. Cost must be byte-identical to the pre-fix number. + // Delete the guard and this test fails. + const withWrite = await parseOneEvent('gpt-5.5', { + input_tokens: 1000, + cached_input_tokens: 200, + cache_write_input_tokens: 300, + output_tokens: 100, + }) + const withoutWrite = await parseOneEvent('gpt-5.5', { + input_tokens: 1000, + cached_input_tokens: 200, + output_tokens: 100, + }) + + expect(withWrite.inputTokens).toBe(800) + expect(withWrite.cacheCreationInputTokens).toBe(0) + const expected = 800 * GPT55.input + 200 * GPT55.cacheRead + 100 * GPT55.output + expect(withWrite.costUSD).toBeCloseTo(expected, 12) + expect(withWrite.costUSD).toBeCloseTo(withoutWrite.costUSD, 12) + // The fabricated rate is 1.25 x 5e-6; make sure not a cent of it landed. + expect(withWrite.costUSD).toBeLessThan(expected + 300 * GPT55.input * 1.25) + }) + + it('clamps a cache-write count larger than the uncached input', async () => { + const call = await parseOneEvent('gpt-5.6-terra', { + input_tokens: 1000, + cached_input_tokens: 200, + cache_write_input_tokens: 5000, + output_tokens: 100, + }) + + expect(call.inputTokens).toBe(0) + expect(call.cacheCreationInputTokens).toBe(800) + expect(call.costUSD).toBeCloseTo(800 * TERRA.cacheWrite + 200 * TERRA.cacheRead + 100 * TERRA.output, 12) + }) +}) + +// ── Cache invalidation: a cost change must not be served from stale bytes ── + +describe('#1083 cache invalidation', () => { + it('discards a v12 codex results cache (it stores costUSD verbatim)', async () => { + const cacheDir = join(tmpDir, 'cache') + await mkdir(cacheDir, { recursive: true }) + const sessionFile = join(tmpDir, 'rollout-stale.jsonl') + await writeFile(sessionFile, '{}\n') + + const s = statSync(sessionFile) + const stale: ParsedProviderCall = { + provider: 'codex', + model: 'gpt-5.5', + inputTokens: 800, + outputTokens: 1000, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 200, + cachedInputTokens: 200, + reasoningTokens: 400, + webSearchRequests: 0, + costUSD: 0.0465, // the pre-fix, reasoning-double-counted number + tools: [], + bashCommands: [], + timestamp: '2026-08-16T10:01:00Z', + speed: 'standard', + deduplicationKey: 'codex:stale', + } + await writeFile(join(cacheDir, 'codex-results.json'), JSON.stringify({ + version: 12, + files: { + [sessionFile]: { + mtimeMs: s.mtimeMs, sizeBytes: s.size, project: 'p', + byteOffset: 0, state: {}, callCount: 1, calls: [stale], + }, + }, + })) + + process.env['CODEBURN_CACHE_DIR'] = cacheDir + // Fresh module instance so the module-level mem cache re-reads from disk. + vi.resetModules() + const { readCodexCacheEntry } = await import('../src/codex-cache.js') + // Revert CODEX_CACHE_VERSION to 12 and this returns the stale $0.0465 call. + expect(await readCodexCacheEntry(sessionFile)).toBeNull() + }) + + it('re-derives days finalized at daily-cache v27', async () => { + const cacheRoot = join(tmpDir, 'daily') + await mkdir(cacheRoot, { recursive: true }) + process.env['CODEBURN_CACHE_DIR'] = cacheRoot + const date = toDateString(new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)) + const yesterday = toDateString(new Date(Date.now() - 24 * 60 * 60 * 1000)) + const oldPath = join(cacheRoot, 'daily-cache.v27.json') + const oldCache = { + version: 27, + savingsConfigHash: 'cfg', + tzKey: currentTzKey(), + lastComputedDate: yesterday, + days: [codexDay(date, 99)], + complete: true, + watermarkTrusted: true, + } + await writeFile(oldPath, JSON.stringify(oldCache)) + + let parseCount = 0 + const hydrated = await ensureCacheHydrated( + async () => { parseCount++; return [] }, + () => [codexDay(date, 2)], + 'cfg', + () => true, + ) + + // Drop MIN_SUPPORTED_VERSION back to 27 and the v27 day is trusted as-is, + // so parseCount stays 0 and the day keeps its overstated $99. + expect(parseCount).toBe(1) + expect(hydrated.days.find(d => d.date === date)?.cost).toBe(2) + expect(JSON.parse(await readFile(oldPath, 'utf8'))).toEqual(oldCache) + }) +}) + +// ── fixtures ────────────────────────────────────────────────────────────── + +function makeApiCall(provider: string, model: string, usage: Partial): ParsedApiCall { + return { + provider, + model, + usage: { + inputTokens: 0, + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + ...usage, + }, + costUSD: 0, + tools: [], + mcpTools: [], + skills: [], + hasAgentSpawn: false, + hasPlanMode: false, + speed: 'standard', + timestamp: '2026-08-16T00:00:00.000Z', + bashCommands: [], + deduplicationKey: `${provider}-${model}`, + } +} + +function makeProject(calls: ParsedApiCall[]): ProjectSummary { + const turn: ClassifiedTurn = { + userMessage: 't', + assistantCalls: calls, + timestamp: '2026-08-16T00:00:00.000Z', + sessionId: 's1', + category: 'feature' as TaskCategory, + retries: 0, + hasEdits: false, + } + const session: SessionSummary = { + sessionId: 's1', + project: 'p', + firstTimestamp: '2026-08-16T00:00:00.000Z', + lastTimestamp: '2026-08-16T00:00:00.000Z', + totalCostUSD: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheReadTokens: 0, + totalCacheWriteTokens: 0, + apiCalls: 0, + turns: [turn], + modelBreakdown: {}, + toolBreakdown: {}, + mcpBreakdown: {}, + bashBreakdown: {}, + categoryBreakdown: {} as SessionSummary['categoryBreakdown'], + skillBreakdown: {}, + } + return { project: 'p', projectPath: 'p', sessions: [session], totalCostUSD: 0, totalApiCalls: 0 } +} + +function codexDay(date: string, cost: number): DailyEntry { + const tokens = { inputTokens: 100, outputTokens: 20, cacheReadTokens: 30, cacheWriteTokens: 0 } + return { + date, + cost, + savingsUSD: 0, + calls: 1, + sessions: 1, + ...tokens, + editTurns: 0, + oneShotTurns: 0, + models: { 'GPT-5.5': { calls: 1, cost, savingsUSD: 0, ...tokens } }, + categories: {}, + providers: { codex: { calls: 1, cost, savingsUSD: 0, sessions: 1, ...tokens } }, + } +} diff --git a/packages/cli/tests/models-report.test.ts b/packages/cli/tests/models-report.test.ts index 33317fd8..8db04187 100644 --- a/packages/cli/tests/models-report.test.ts +++ b/packages/cli/tests/models-report.test.ts @@ -234,12 +234,15 @@ describe('aggregateModels', () => { expect(above.find(r => r.provider === 'cursor')).toBeUndefined() }) + // Providers that report reasoning as a bucket SEPARATE from output still get + // it added in. Codex and claude do not - they bill reasoning inside + // output_tokens - and that carve-out is covered in codex-pricing-1083.test.ts. it('counts reasoning tokens as output tokens', async () => { const project = makeProject([ makeTurn('feature', [ { - provider: 'codex', - model: 'gpt-5', + provider: 'gemini', + model: 'gemini-2.5-pro', usage: { ...emptyTokens(), inputTokens: 100, outputTokens: 50, reasoningTokens: 200 }, costUSD: 1.0, tools: [], diff --git a/packages/cli/tests/models.test.ts b/packages/cli/tests/models.test.ts index e0d3cfd0..1034b14e 100644 --- a/packages/cli/tests/models.test.ts +++ b/packages/cli/tests/models.test.ts @@ -611,6 +611,7 @@ describe('DeepSeek v4 models resolve to pricing', () => { process.env['CODEBURN_CACHE_DIR'] = cacheRoot await mkdir(cacheRoot, { recursive: true }) await writeFile(join(cacheRoot, 'litellm-pricing.json'), JSON.stringify({ + version: 2, // must match models.ts's CACHE_SCHEMA_VERSION or the cache is treated as a miss timestamp: Date.now(), data: { 'gpt-4o-mini': { @@ -636,6 +637,43 @@ describe('DeepSeek v4 models resolve to pricing', () => { }) }) +describe('pricing cache schema version (#1078 follow-up, ported in #1083)', () => { + it('discards a cache written by a pre-fix binary instead of reading its missing cacheWriteCostIsExplicit as false', async () => { + const cacheRoot = await mkdtemp(join(tmpdir(), 'codeburn-pricing-cache-')) + try { + process.env['CODEBURN_CACHE_DIR'] = cacheRoot + await mkdir(cacheRoot, { recursive: true }) + // Shape of a cache file written before the fix added `version` and + // `cacheWriteCostIsExplicit`: no version field, and entries missing the + // key despite carrying a real (non-default) cache-write rate. + await writeFile(join(cacheRoot, 'litellm-pricing.json'), JSON.stringify({ + timestamp: Date.now(), + data: { + 'gpt-5.6': { + inputCostPerToken: 5e-6, + outputCostPerToken: 3e-5, + cacheWriteCostPerToken: 6.25e-6, + cacheReadCostPerToken: 5e-7, + webSearchCostPerRequest: 0.01, + fastMultiplier: 1, + }, + }, + }), 'utf-8') + + await loadPricing() + + // Pre-fix, loadCachedPricing had no version check: it would read this + // cache verbatim, and gpt-5.6's missing key would resolve to undefined + // (falsy) here instead of the true its snapshot entry actually carries. + expect(getModelCosts('gpt-5.6')!.cacheWriteCostIsExplicit).toBe(true) + } finally { + delete process.env['CODEBURN_CACHE_DIR'] + await rm(cacheRoot, { recursive: true, force: true }) + await loadPricing() + } + }) +}) + describe('provider pricing suffix variants', () => { const cases: Array<[string, string]> = [ ['GLM-4.7-TEE', 'glm-4.7'], @@ -736,6 +774,7 @@ describe('findUnpricedModels', () => { try { process.env['CODEBURN_CACHE_DIR'] = cacheRoot await writeFile(join(cacheRoot, 'litellm-pricing.json'), JSON.stringify({ + version: 2, // must match models.ts's CACHE_SCHEMA_VERSION or the cache is treated as a miss timestamp: Date.now(), data: { 'zz-zero-stub-model': { diff --git a/packages/core/src/providers/codex/decode.ts b/packages/core/src/providers/codex/decode.ts index 62f4e04a..8d426cc2 100644 --- a/packages/core/src/providers/codex/decode.ts +++ b/packages/core/src/providers/codex/decode.ts @@ -380,6 +380,7 @@ export function freshCodexState(): CodexDecodeState { prevCumulativeTotal: null, prevInput: 0, prevCached: 0, + prevCacheWrite: 0, prevOutput: 0, prevReasoning: 0, pendingTools: [], @@ -728,6 +729,7 @@ export function decodeCodex({ records, state: prevState, seenKeys: liveSeen, ses cacheCreationInputTokens: 0, cacheReadInputTokens: 0, cachedInputTokens: 0, + cacheWriteCandidateTokens: 0, reasoningTokens: 0, webSearchRequests: 0, costIsEstimated: true, @@ -757,6 +759,7 @@ export function decodeCodex({ records, state: prevState, seenKeys: liveSeen, ses const last = info.last_token_usage let inputTokens = 0 let cachedInputTokens = 0 + let cacheWriteTokens = 0 let outputTokens = 0 let reasoningTokens = 0 @@ -769,6 +772,7 @@ export function decodeCodex({ records, state: prevState, seenKeys: liveSeen, ses // siblings are never thrown away and NaN can never form. inputTokens = firstFiniteNumber(last.input_tokens) ?? 0 cachedInputTokens = firstFiniteNumber(last.cached_input_tokens) ?? 0 + cacheWriteTokens = firstFiniteNumber(last.cache_write_input_tokens) ?? 0 outputTokens = firstFiniteNumber(last.output_tokens) ?? 0 reasoningTokens = firstFiniteNumber(last.reasoning_output_tokens) ?? 0 } else if (cumulativeTotal > 0) { @@ -776,6 +780,7 @@ export function decodeCodex({ records, state: prevState, seenKeys: liveSeen, ses if (!total) continue inputTokens = (firstFiniteNumber(total.input_tokens) ?? 0) - s.prevInput cachedInputTokens = (firstFiniteNumber(total.cached_input_tokens) ?? 0) - s.prevCached + cacheWriteTokens = (firstFiniteNumber(total.cache_write_input_tokens) ?? 0) - s.prevCacheWrite outputTokens = (firstFiniteNumber(total.output_tokens) ?? 0) - s.prevOutput reasoningTokens = (firstFiniteNumber(total.reasoning_output_tokens) ?? 0) - s.prevReasoning } @@ -786,6 +791,7 @@ export function decodeCodex({ records, state: prevState, seenKeys: liveSeen, ses if (total) { s.prevInput = firstFiniteNumber(total.input_tokens) ?? 0 s.prevCached = firstFiniteNumber(total.cached_input_tokens) ?? 0 + s.prevCacheWrite = firstFiniteNumber(total.cache_write_input_tokens) ?? 0 s.prevOutput = firstFiniteNumber(total.output_tokens) ?? 0 s.prevReasoning = firstFiniteNumber(total.reasoning_output_tokens) ?? 0 } @@ -797,6 +803,12 @@ export function decodeCodex({ records, state: prevState, seenKeys: liveSeen, ses // semantics: inputTokens = non-cached only. const uncachedInputTokens = Math.max(0, inputTokens - cachedInputTokens) + // Cache writes are carved out of the uncached input, never added to it: + // clamp so a malformed or lagging count can never drive the plain input + // bucket negative. Which bucket they end up billed in is the host's call + // (see toPricedProviderCall in the CLI's providers/codex.ts). + const cacheWriteCandidateTokens = Math.max(0, Math.min(cacheWriteTokens, uncachedInputTokens)) + const model = resolveModel(entry.payload, s.sessionModel) // Fork replays copy the parent's token_count history verbatim, so key on // the parent namespace plus the cumulative breakdown: a true replay collides @@ -814,6 +826,7 @@ export function decodeCodex({ records, state: prevState, seenKeys: liveSeen, ses cacheCreationInputTokens: 0, cacheReadInputTokens: cachedInputTokens, cachedInputTokens, + cacheWriteCandidateTokens, reasoningTokens, webSearchRequests: 0, tools: s.pendingTools, diff --git a/packages/core/src/providers/codex/types.ts b/packages/core/src/providers/codex/types.ts index b242e93a..e24c1336 100644 --- a/packages/core/src/providers/codex/types.ts +++ b/packages/core/src/providers/codex/types.ts @@ -10,6 +10,10 @@ export type CodexTokenUsage = { input_tokens?: number cached_input_tokens?: number + /// Portion of `input_tokens` that was WRITTEN to the prompt cache this call + /// (codex PR #33454). Like `cached_input_tokens`, it is carved out of + /// `input_tokens`, not added on top. + cache_write_input_tokens?: number output_tokens?: number reasoning_output_tokens?: number total_tokens?: number @@ -64,6 +68,12 @@ export type CodexDecodedCall = { cacheCreationInputTokens: number cacheReadInputTokens: number cachedInputTokens: number + /// Cache-write tokens observed on this call, already clamped into + /// `inputTokens` (never larger than it) but NOT yet carved out of it: whether + /// they move into `cacheCreationInputTokens` depends on whether the pricing + /// source publishes a real cache-write rate for the model, which only the + /// host knows. The host's codex adapter does that split (#1075). + cacheWriteCandidateTokens: number reasoningTokens: number webSearchRequests: number tools: string[] @@ -132,6 +142,7 @@ export type CodexDecodeState = { prevCumulativeTotal: number | null prevInput: number prevCached: number + prevCacheWrite: number prevOutput: number prevReasoning: number pendingTools: string[]