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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
- **The resident `codeburn serve` child.** The first real panel request is also the cache warm-up, so startup never runs an artificial warm-up query beside a duplicate one-shot child; each served command carries its own read-only option allowlist, and anything outside it falls back to a normal spawn; the child exits when its stdin closes, so it can never outlive the app. Requests whose response exceeds the 16 MiB frame limit still replace the child, but that deliberate kill no longer spends the resident's unexpected-death budget. (#972)

### 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 — on a 1,396-rollout corpus all 134,316 events carrying a total satisfy `input + output == total` — but CodeBurn added `reasoning_output_tokens` to output when pricing a Codex call and again in the models, audit and per-model displays. Every Codex number was therefore too high: on that corpus **cost by $166.03 (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 parser 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; they are now carved out of the uncached-input bucket and clamped so they can never exceed it. That carve-out happens **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 and the daily cache re-derives once off the warm session cache (a global re-derivation of every day and every provider, since it has no per-provider invalidation); no other provider's numbers move. Long-context pricing tiers from the same report are tracked separately in #1076 and the missing `gpt-5.6-codex` snapshot rows in #1077. Thanks @chr-evensen. (#1075)
- **Codex calls attributed from session metadata no longer carry a stale model.** The Buffer fast path scanned `session_meta` for the first `"model"` string anywhere in the payload, so a nested `base_instructions.provenance.model` was read as if it were `payload.model` — and since the model is last-writer-wins state, that wrong value was credited to every call before the rollout's first `turn_context` and to every call after any mid-file `session_meta` (29 of 1380 rollouts on one real corpus carry a late `session_meta`, and 57 record usage before any `turn_context`). Direct payload fields are now read depth-aware, which is what the non-fast `JSON.parse` path always did. Codex sessions re-parse once (~9s on a 4 GB rollout corpus) and the daily cache re-derives once off the warm session cache, a global re-derivation of every day and every provider since it has no per-provider invalidation; it moves per-model attribution, and clears any rollup an earlier parse change had left stale. Days whose transcripts have partly aged out are held by the never-lose guard: on a real 110-day cache no day lost value and none disappeared — 100 days came back identical and 9 grok days rose by $19.80 in total. Thanks @timdp. (#1040)
- **Codex `session_meta` cwd / session id / originator follow the same depth-1 window as `model`.** #1040 fixed nested `provenance.model`; the compact Buffer path still took the first `cwd`, `session_id`, `originator`, `name`, `forked_from_id` or `model_provider` anywhere in the payload, so a `dynamic_tools[].name` (or any same-named nested key) could steal the top-level field. Those strings now use the existing payload-depth-1 scan. Function-call `name` on other event types is unchanged. Codex sessions re-parse once. (#1045)
- **Plan rows for sticker-price presets read as a budget instead of live provider quota.** There is no Grok quota endpoint, so a SuperGrok row was parsed API-equivalent spend divided by the plan's sticker price on a monthly reset — but the TUI labelled that math "plan" and "reset", which next to a client showing xAI's real weekly window read as CodeBurn being wrong. The bars and the arithmetic are unchanged; the words are not. Both the dashboard and the desktop app now say the number is an API-equivalent monthly budget and not a live provider window, in the same wording on both surfaces, and for every preset rather than as a SuperGrok special case. The window is anniversary-based (`plan.resetDay`, settable with `codeburn plan set --reset-day`), so it is called a budget reset rather than a calendar one. The row was also shortened to fit 80 columns: at that width the percentage and the projected month were being truncated away, including on custom plans, whose label carries the provider.
Expand Down
13 changes: 12 additions & 1 deletion scripts/upgrade-path/compare.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,15 @@
// which is the part the corpus can honestly establish.
// dsh did not exist in the published CLI. Reported; required to be absent
// in the baseline and present after the upgrade.
// codex PRICING changed by design in #1075: reasoning tokens are billed
// inside output rather than on top of it, and cache writes are carved out
// of the input bucket. Nothing about what was PARSED moved, so codex keeps
// the full exact treatment for the call count and every token field; only
// the cost tolerance is lifted, and the delta is reported instead. Drop it
// from this list once a published CLI carries the fix.
const EXACT = ['claude', 'codex', 'gemini', 'kiro', 'cursor']
const CHANGED_BY_DESIGN = ['grok']
const COST_CHANGED_BY_DESIGN = ['codex']
const NEW_IN_THIS_RELEASE = ['dsh']

const COST_TOLERANCE = 0.005 // 0.5% relative
Expand Down Expand Up @@ -95,7 +102,11 @@ for (const name of providers) {
if (b.calls !== u.calls) diffs.push(`calls ${b.calls} != ${u.calls}`)
for (const f of TOKEN_FIELDS) if (b[f] !== u[f]) diffs.push(`${f} ${b[f]} != ${u[f]}`)
const costDrift = relDiff(b.cost, u.cost)
if (costDrift > COST_TOLERANCE) diffs.push(`cost ${fmt(b.cost)} != ${fmt(u.cost)} (${(costDrift * 100).toFixed(3)}% > ${(COST_TOLERANCE * 100).toFixed(1)}%)`)
if (COST_CHANGED_BY_DESIGN.includes(name)) {
notes.push(`${name}: cost ${fmt(b.cost)} -> ${fmt(u.cost)} (${(costDrift * 100).toFixed(3)}%) — repricing expected (#1075); tokens and calls still asserted exactly`)
} else if (costDrift > COST_TOLERANCE) {
diffs.push(`cost ${fmt(b.cost)} != ${fmt(u.cost)} (${(costDrift * 100).toFixed(3)}% > ${(COST_TOLERANCE * 100).toFixed(1)}%)`)
}
if (!EXACT.includes(name)) {
notes.push(`${name}: no expectation declared in compare.mjs; ${diffs.length ? diffs.join(', ') : 'identical'}`)
verdict = diffs.length ? 'differs (unclassified)' : 'identical'
Expand Down
2 changes: 1 addition & 1 deletion scripts/upgrade-path/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const WORK = process.env['UPGRADE_PATH_WORK'] || join(tmpdir(), 'codeburn upgrad
const OLD_SESSION_CACHE = 'session-cache.v7.json'
const OLD_DAILY_CACHE = 'daily-cache.v17.json'
const NEW_SESSION_CACHE_DIR = 'session-cache.v9'
const NEW_DAILY_CACHE = 'daily-cache.v20.json'
const NEW_DAILY_CACHE = 'daily-cache.v23.json'

const HOME = join(WORK, 'user home')
const PAYLOADS = join(WORK, 'payloads')
Expand Down
4 changes: 2 additions & 2 deletions src/audit-report.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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,
}
Expand Down
6 changes: 5 additions & 1 deletion src/codex-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
13 changes: 11 additions & 2 deletions src/daily-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 6 additions & 5 deletions src/models-report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
23 changes: 23 additions & 0 deletions src/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -71,6 +93,7 @@ function buildCosts(
cacheReadCostPerToken: cacheRead ?? input * 0.1,
webSearchCostPerRequest: WEB_SEARCH_COST,
fastMultiplier: fast ?? 1,
cacheWriteCostIsExplicit: cacheWrite !== null && cacheWrite !== undefined,
}
}

Expand Down
12 changes: 7 additions & 5 deletions src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading