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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +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 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. Days whose Codex transcripts have since aged out are held by the same never-lose guard #1040 relies on: a re-derivation that finds fewer calls than the settled baseline keeps the older, pre-fix (double-counted) total rather than truncating it, so those days do not pick up the repricing until their sources are re-derived with equal or greater evidence. 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
24 changes: 20 additions & 4 deletions scripts/upgrade-path/compare.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,23 @@
// 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.
// the full exact treatment for the call count and every token field; the
// cost tolerance is instead replaced with REPRICE_TOLERANCE — the
// upgraded cost must be strictly lower than the baseline and within 25%
// of it, since #1075 only ever removes a double-count and never raises
// cost — 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
// #1075 only ever LOWERS codex cost (double-counted reasoning removed, cache
// writes carved out of the input bucket) and by a bounded amount on any real
// corpus; a rise, or a drop past this bound, means something beyond the known
// repricing changed.
const REPRICE_TOLERANCE = 0.25 // 25% relative

import { readFileSync } from 'node:fs'
import { join } from 'node:path'
Expand Down Expand Up @@ -102,8 +110,14 @@ 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)
let repriced = false
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`)
if (u.cost > b.cost) diffs.push(`cost ${fmt(b.cost)} -> ${fmt(u.cost)} rose; #1075 should only lower codex cost`)
else if (costDrift > REPRICE_TOLERANCE) diffs.push(`cost ${fmt(b.cost)} -> ${fmt(u.cost)} (${(costDrift * 100).toFixed(3)}% > ${(REPRICE_TOLERANCE * 100).toFixed(0)}% expected bound for #1075)`)
else {
repriced = true
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)}%)`)
}
Expand All @@ -113,6 +127,8 @@ for (const name of providers) {
} else if (diffs.length) {
failures.push(`${name}: ${diffs.join(', ')}`)
verdict = 'DIFFERS'
} else if (repriced) {
verdict = `repriced (cost ${(costDrift * 100).toFixed(3)}% drift)`
} else {
verdict = costDrift === 0 ? 'identical' : `identical (cost ${(costDrift * 100).toFixed(3)}% drift)`
}
Expand Down
7 changes: 3 additions & 4 deletions src/codex-credits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,9 @@ export type CodexCreditTokens = {
inputTokens: number
/// Cache-read (cached input) tokens, billed at the cheaper cached rate.
cachedReadTokens: number
/// Billable output tokens: reasoning is already included (billableOutputTokens
/// in models.ts), so callers must not add it on top here.
outputTokens: number
/// Reasoning tokens are billed as output, matching CodeBurn's cost model.
reasoningTokens?: number
}

/// Credits consumed for one Codex usage record. Returns null when the model has
Expand All @@ -48,10 +48,9 @@ export function codexCredits(model: string, tokens: CodexCreditTokens): number |
if (!rate) return null
const safe = (n: number) => (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
)
}
9 changes: 8 additions & 1 deletion src/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,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

Expand Down Expand Up @@ -223,6 +228,7 @@ async function fetchAndCachePricing(): Promise<Map<string, ModelCosts>> {

await mkdir(getCodeburnCacheDir(), { recursive: true })
await writeFile(getCachePath(), JSON.stringify({
version: CACHE_SCHEMA_VERSION,
timestamp: Date.now(),
data: Object.fromEntries(pricing),
}))
Expand All @@ -233,7 +239,8 @@ async function fetchAndCachePricing(): Promise<Map<string, ModelCosts>> {
async function loadCachedPricing(): Promise<Map<string, ModelCosts> | null> {
try {
const raw = await readFile(getCachePath(), 'utf-8')
const cached = JSON.parse(raw) as { timestamp: number; data: Record<string, ModelCosts> }
const cached = JSON.parse(raw) as { version?: number; timestamp: number; data: Record<string, ModelCosts> }
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 {
Expand Down
2 changes: 1 addition & 1 deletion src/parser.ts
Original file line number Diff line number Diff line change
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 ?? billableOutputTokens(call.provider, call.usage.outputTokens, call.usage.reasoningTokens))
modelBreakdown[modelKey].activeGeneratedTokens = (modelBreakdown[modelKey].activeGeneratedTokens ?? 0) + (call.activeGeneratedTokens ?? call.usage.outputTokens + call.usage.reasoningTokens)
modelBreakdown[modelKey].toolWaitMs = (modelBreakdown[modelKey].toolWaitMs ?? 0) + (call.toolWaitMs ?? 0)
}

Expand Down
15 changes: 9 additions & 6 deletions src/providers/grok.ts
Original file line number Diff line number Diff line change
Expand Up @@ -405,12 +405,15 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
inputTokens: parsed.usage.input,
// Grok reports reasoning INSIDE outputTokens, but the repo contract is
// the opposite: ParsedProviderCall.reasoningTokens is exclusive of
// outputTokens, and every consumer sums the two (parser.ts's
// cachedCallToApiCall for cost, modelBreakdown for tokens, and the
// models/audit reports). tests/providers/kiro.test.ts states it
// outright. So split it here rather than special-casing grok in five
// downstream places: subtracting reasoning makes `output + reasoning`
// reconstruct exactly the number Grok reported.
// outputTokens. Downstream consumers reconstitute the billable total
// through billableOutputTokens() (models.ts): it adds reasoning back on
// top for grok and every other provider, except the
// REASONING_INCLUDED_IN_OUTPUT set (claude, codex) whose reasoning is
// already inside output_tokens and must not be added again.
// tests/providers/kiro.test.ts states the exclusive contract outright.
// So split it here rather than special-casing grok in every downstream
// site: subtracting reasoning makes `output + reasoning` reconstruct
// exactly the number Grok reported.
outputTokens: parsed.usage.output - reasoningTokens,
cacheCreationInputTokens: parsed.usage.cacheCreation,
cacheReadInputTokens: parsed.usage.cacheRead,
Expand Down
6 changes: 3 additions & 3 deletions src/usage-aggregator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -609,9 +609,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),
Expand Down
5 changes: 0 additions & 5 deletions tests/codex-credits.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
9 changes: 5 additions & 4 deletions tests/codex-pricing-1075.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,17 +112,18 @@ describe('#1075 A - reasoning is not billed on top of output', () => {
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.
const additive = makeApiCall('hermes', 'gpt-5.5', { outputTokens: 1000, reasoningTokens: 400 })
// change to every display sum. Gemini documents "thoughts" as genuinely
// separate from output (src/providers/gemini.ts), 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 === 'hermes')!.displayed.outputTokens).toBe(1400)
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 === 'hermes')!.outputTokens).toBe(1400)
expect(modelRows.find(r => r.provider === 'gemini')!.outputTokens).toBe(1400)
})
})

Expand Down
4 changes: 2 additions & 2 deletions tests/models-report.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,8 +244,8 @@ describe('aggregateModels', () => {
const project = makeProject([
makeTurn('feature', [
{
provider: 'hermes',
model: 'gpt-5',
provider: 'gemini',
model: 'gemini-2.5-pro',
usage: { ...emptyTokens(), inputTokens: 100, outputTokens: 50, reasoningTokens: 200 },
costUSD: 1.0,
tools: [],
Expand Down
Loading
Loading