fix(codex): stop double-billing reasoning output, price cache writes at the explicit rate only

Reasoning tokens are a subset of output_tokens for OpenAI models, not an
extra bucket: on a 1,396-rollout corpus all 134,316 token_count events
carrying a total satisfy input + output == total. CodeBurn added
reasoning_output_tokens on top when pricing a codex call, in the
cache-rehydration re-price, and in the models/audit display sums. That
overstated codex cost by $166.03 (3.5%) and displayed output tokens by
34.6% on that corpus. Both cost sites and the display sums now go through
one shared billableOutputTokens() so a cold parse and a warm read cannot
drift apart.

cache_write_input_tokens (codex PR #33454) was never read and
cacheCreationInputTokens was hardcoded to 0. It is now carved out of the
uncached-input bucket and clamped to it, but routed to the cache-write
bucket ONLY when the pricing source publishes an explicit cache-write rate.
buildCosts fabricates 1.25x input when a source omits one, which is correct
for Anthropic and would have invented a surcharge OpenAI never charged on
gpt-5.5 / 5.4 / 5.3-codex / gpt-5. ModelCosts now carries
cacheWriteCostIsExplicit so that distinction survives getModelCosts.

A cost change invalidates persisted output: codex-results.json v10 -> v11
(stores costUSD verbatim), the codex parse version moves (the token-bucket
change does not self-heal on read), and the daily cache goes 20 -> 23 (21 is
claimed by the #946 landing branch and 22 by PR #1056). The upgrade-path
corpus asserts codex tokens and calls exactly and reports the repricing.

Closes #1075
This commit is contained in:
iamtoruk 2026-08-21 11:50:23 -07:00
parent e12fb39e3f
commit fda7e8024d
15 changed files with 542 additions and 27 deletions

View file

@ -1,4 +1,4 @@
import { getModelCosts, sanitizeModelForDisplay, type ModelCosts } from './models.js'
import { billableOutputTokens, getModelCosts, sanitizeModelForDisplay, 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'
@ -124,7 +124,7 @@ export async function aggregateAudit(projects: ProjectSummary[]): Promise<AuditR
const meta = await resolveProvider(bucket.provider)
const displayed = {
inputTokens: bucket.raw.inputTokens,
outputTokens: bucket.raw.outputTokens + bucket.raw.reasoningTokens,
outputTokens: billableOutputTokens(bucket.provider, bucket.raw.outputTokens, bucket.raw.reasoningTokens),
cacheWriteTokens: bucket.raw.cacheCreationInputTokens,
cacheReadTokens: bucket.cacheReadDisplayed,
}

View file

@ -23,7 +23,11 @@ import type { ParsedProviderCall } from './providers/types.js'
// cannot overwrite the model selected by turn_context.
// v10: same depth-1 window for the rest of session_meta's raw string fields
// (cwd/name/originator/session_id/forked_from_id/model_provider).
const CODEX_CACHE_VERSION = 10
// v11: codex pricing fix (#1075) - reasoning is no longer added on top of
// output, and cache_write_input_tokens is carved out of the input bucket. This
// file stores each call's costUSD and token buckets verbatim, so entries
// written by v10 carry the old (overstated) cost and must be re-derived.
const CODEX_CACHE_VERSION = 11
const CACHE_FILE = 'codex-results.json'
export type CodexFileFingerprint = { dev: number; ino: number; mtimeMs: number; sizeBytes: number }

View file

@ -110,8 +110,17 @@ 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 = 20
const MIN_SUPPORTED_VERSION = 20
// v23: codex pricing fix (#1075) - 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 v20 carry codex costs overstated by ~3.5% and codex output
// tokens overstated by ~34.6%. Raising MIN_SUPPORTED_VERSION forces the
// one-time re-derivation.
// It takes 23, not 21: v21 is claimed by the #946 landing branch and v22 by
// PR #1056, so those numbers are spoken for and reusing one would let two
// incompatible schemas share a filename. (feat/core-extraction sits at 26 and
// reconciles at its final merge by keeping the max.)
export const DAILY_CACHE_VERSION = 23
const MIN_SUPPORTED_VERSION = 23
// 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

View file

@ -3,7 +3,7 @@ import stripAnsi from 'strip-ansi'
import { codexCredits } from './codex-credits.js'
import { formatCost, formatTokens } from './format.js'
import { sanitizeModelForDisplay } from './models.js'
import { billableOutputTokens, sanitizeModelForDisplay } from './models.js'
import { getProvider } from './providers/index.js'
import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js'
@ -120,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
@ -182,9 +182,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,

View file

@ -13,6 +13,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 = {
@ -71,6 +93,7 @@ function buildCosts(
cacheReadCostPerToken: cacheRead ?? input * 0.1,
webSearchCostPerRequest: WEB_SEARCH_COST,
fastMultiplier: fast ?? 1,
cacheWriteCostIsExplicit: cacheWrite !== null && cacheWrite !== undefined,
}
}

View file

@ -2,7 +2,7 @@ import { existsSync } from 'fs'
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, getModelAliasesConfigHash, getPriceOverridesConfigHash, getLocalModelSavingsConfigHash } from './models.js'
import { billableOutputTokens, calculateCost, calculateLocalModelSavings, getShortModelName, isProxiedPath, getProxyPathsConfigHash, getModelAliasesConfigHash, getPriceOverridesConfigHash, getLocalModelSavingsConfigHash } from './models.js'
import { resolveSubagentAttribution, sessionIdentity } from './sessions-report.js'
import { normalizeContentBlocks, flatSlice, flatString } from './content-utils.js'
import { discoverAllSessions, getProvider } from './providers/index.js'
@ -1768,7 +1768,7 @@ function buildSessionSummary(
modelBreakdown[modelKey].tokens.reasoningTokens += call.usage.reasoningTokens
if (call.activeDurationMs !== undefined) {
modelBreakdown[modelKey].activeDurationMs = (modelBreakdown[modelKey].activeDurationMs ?? 0) + call.activeDurationMs
modelBreakdown[modelKey].activeGeneratedTokens = (modelBreakdown[modelKey].activeGeneratedTokens ?? 0) + (call.activeGeneratedTokens ?? call.usage.outputTokens + call.usage.reasoningTokens)
modelBreakdown[modelKey].activeGeneratedTokens = (modelBreakdown[modelKey].activeGeneratedTokens ?? 0) + (call.activeGeneratedTokens ?? billableOutputTokens(call.provider, call.usage.outputTokens, call.usage.reasoningTokens))
modelBreakdown[modelKey].toolWaitMs = (modelBreakdown[modelKey].toolWaitMs ?? 0) + (call.toolWaitMs ?? 0)
}
@ -2629,9 +2629,11 @@ function providerCallsToCachedTurns(calls: ParsedProviderCall[]): CachedTurn[] {
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
// src/providers/codex.ts (and every other provider's parser): 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,

View file

@ -5,7 +5,7 @@ import { basename, join } from 'path'
import { homedir } from 'os'
import { readSessionLines } from '../fs-utils.js'
import { calculateCost } from '../models.js'
import { billableOutputTokens, calculateCost, getModelCosts } from '../models.js'
import { readCachedCodexResults, writeCachedCodexResults, getCachedCodexProject, fingerprintFile, type CodexFileFingerprint } from '../codex-cache.js'
import { normalizeContentBlocks } from '../content-utils.js'
import { estimateTokensFromChars } from '../token-estimate.js'
@ -126,6 +126,10 @@ type CodexEntry = {
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
@ -323,6 +327,7 @@ function getRawTokenUsage(head: string, field: 'last_token_usage' | 'total_token
return {
input_tokens: getRawJsonNumberField(body, 'input_tokens'),
cached_input_tokens: getRawJsonNumberField(body, 'cached_input_tokens'),
cache_write_input_tokens: getRawJsonNumberField(body, 'cache_write_input_tokens'),
output_tokens: getRawJsonNumberField(body, 'output_tokens'),
reasoning_output_tokens: getRawJsonNumberField(body, 'reasoning_output_tokens'),
total_tokens: getRawJsonNumberField(body, 'total_tokens'),
@ -593,6 +598,7 @@ type CodexResumeState = {
prevCumulativeTotal: number | null
prevInput: number
prevCached: number
prevCacheWrite: number
prevOutput: number
prevReasoning: number
pendingTools: string[]
@ -619,6 +625,7 @@ function isResumeState(value: unknown): value is CodexResumeState {
&& (v['prevCumulativeTotal'] === null || typeof v['prevCumulativeTotal'] === 'number')
&& typeof v['prevInput'] === 'number'
&& typeof v['prevCached'] === 'number'
&& typeof v['prevCacheWrite'] === 'number'
&& typeof v['prevOutput'] === 'number'
&& typeof v['prevReasoning'] === 'number'
&& Array.isArray(v['pendingTools'])
@ -678,6 +685,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>, capture?: {
let prevCumulativeTotal: number | null = resume?.state.prevCumulativeTotal ?? null
let prevInput = resume?.state.prevInput ?? 0
let prevCached = resume?.state.prevCached ?? 0
let prevCacheWrite = resume?.state.prevCacheWrite ?? 0
let prevOutput = resume?.state.prevOutput ?? 0
let prevReasoning = resume?.state.prevReasoning ?? 0
let pendingTools: string[] = resume ? [...resume.state.pendingTools] : []
@ -795,6 +803,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>, capture?: {
prevCumulativeTotal,
prevInput,
prevCached,
prevCacheWrite,
prevOutput,
prevReasoning,
pendingTools: [...pendingTools],
@ -1014,12 +1023,14 @@ function createParser(source: SessionSource, seenKeys: Set<string>, capture?: {
const last = info.last_token_usage
let inputTokens = 0
let cachedInputTokens = 0
let cacheWriteTokens = 0
let outputTokens = 0
let reasoningTokens = 0
if (last) {
inputTokens = last.input_tokens ?? 0
cachedInputTokens = last.cached_input_tokens ?? 0
cacheWriteTokens = last.cache_write_input_tokens ?? 0
outputTokens = last.output_tokens ?? 0
reasoningTokens = last.reasoning_output_tokens ?? 0
} else if (cumulativeTotal > 0) {
@ -1027,6 +1038,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>, capture?: {
if (!total) continue
inputTokens = (total.input_tokens ?? 0) - prevInput
cachedInputTokens = (total.cached_input_tokens ?? 0) - prevCached
cacheWriteTokens = (total.cache_write_input_tokens ?? 0) - prevCacheWrite
outputTokens = (total.output_tokens ?? 0) - prevOutput
reasoningTokens = (total.reasoning_output_tokens ?? 0) - prevReasoning
}
@ -1042,6 +1054,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>, capture?: {
if (total) {
prevInput = total.input_tokens ?? 0
prevCached = total.cached_input_tokens ?? 0
prevCacheWrite = total.cache_write_input_tokens ?? 0
prevOutput = total.output_tokens ?? 0
prevReasoning = total.reasoning_output_tokens ?? 0
}
@ -1053,7 +1066,22 @@ function createParser(source: SessionSource, seenKeys: Set<string>, capture?: {
// Normalize to Anthropic 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.
const cacheWriteInputTokens = Math.max(0, Math.min(cacheWriteTokens, uncachedInputTokens))
const model = resolveModel(entry.payload, sessionModel)
// Only move tokens 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 that OpenAI never billed, so the tokens stay where they
// already were -- in plain input, priced exactly as before.
const billedCacheWriteTokens = cacheWriteInputTokens > 0 && getModelCosts(model)?.cacheWriteCostIsExplicit
? cacheWriteInputTokens
: 0
const billedInputTokens = uncachedInputTokens - billedCacheWriteTokens
const timestamp = entry.timestamp ?? ''
// Forked sessions copy the parent's entire token_count history
// (re-timestamped), so replays must collide with the parent's events
@ -1074,11 +1102,15 @@ function createParser(source: SessionSource, seenKeys: Set<string>, capture?: {
if (seenKeys.has(dedupKey)) continue
seenKeys.add(dedupKey)
// Reasoning tokens are already inside output_tokens, so they are NOT
// added here. The cache-rehydration twin of this line lives in
// src/parser.ts (cachedCallToApiCall); both call billableOutputTokens
// so a fresh parse and a cache read can never price differently.
const costUSD = calculateCost(
model,
uncachedInputTokens,
outputTokens + reasoningTokens,
0,
billedInputTokens,
billableOutputTokens('codex', outputTokens, reasoningTokens),
billedCacheWriteTokens,
cachedInputTokens,
0,
)
@ -1086,9 +1118,9 @@ function createParser(source: SessionSource, seenKeys: Set<string>, capture?: {
pendingTaskCalls.push({
provider: 'codex',
model,
inputTokens: uncachedInputTokens,
inputTokens: billedInputTokens,
outputTokens,
cacheCreationInputTokens: 0,
cacheCreationInputTokens: billedCacheWriteTokens,
cacheReadInputTokens: cachedInputTokens,
cachedInputTokens,
reasoningTokens,

View file

@ -281,7 +281,12 @@ export const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
// nested base_instructions provenance.model cannot overwrite turn_context.
// session-meta-fields-v1: the same depth-1 window for cwd/name/originator/
// session_id/forked_from_id/model_provider, not just model.
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 (#1075): 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-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',
cursor: 'composer-anchored-crediting-v1-est-cost',
'cursor-agent': 'workspaceless-transcript-v1',
// source-provenance-v1 (#944): CLI sessions were misread as VS Code