codeburn/tests/codex-pricing-1075.test.ts
iamtoruk f92949c081 docs(pricing): bound the codex repricing drift in verify:upgrade, fix stale comments
The COST_CHANGED_BY_DESIGN carve-out in compare.mjs left codex cost entirely
unasserted after #1075/#1078. It now requires the upgraded cost to be strictly
lower than baseline and within 25% of it, and the row verdict says "repriced"
instead of the misleading "identical (cost N% drift)".

grok.ts's comment on the reasoning/output split still claimed provider-side
splitting was the repo's only mechanism; it now also names
billableOutputTokens/REASONING_INCLUDED_IN_OUTPUT (models.ts), which is the
other half since #1078. usage-aggregator.ts's "folds reasoning into output"
comment was true pre-#1078 but is backwards for codex now (reasoning is
already inside output, not added to it).

Test exemplars for the "reasoning is additive" case used hermes, whose
upstream is OpenAI-shaped and may not stay a safe example; swapped to gemini,
which documents "thoughts" as genuinely separate output.

CHANGELOG's #1075 entry gets one line noting days whose codex transcripts
have aged out keep their pre-fix totals via the daily-cache never-lose guard,
matching the disclosure already given for #1040.
2026-08-21 12:49:05 -07:00

353 lines
14 KiB
TypeScript

// Regression suite for #1075 (reported by chr-evensen).
//
// 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.
// Priced in TWO places -- the fresh parse in src/providers/codex.ts and
// the cache-rehydration re-price in src/parser.ts -- plus three display
// sums. Both cost sites now go through billableOutputTokens(). The
// cache-rehydration half lives in codex-pricing-1075-rehydrate.test.ts,
// which needs CODEX_HOME set before the provider module is evaluated.
//
// B. cache_write_input_tokens was never read. It is now carved 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 { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { aggregateAudit } from '../src/audit-report.js'
import { aggregateModels } from '../src/models-report.js'
import { clearCodexMemCaches, readCachedCodexResults } from '../src/codex-cache.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 2e-6 output 12e-6 cacheWrite 2.5e-6 (EXPLICIT) cacheRead 2e-7
// gpt-5.5 input 5e-6 output 30e-6 cacheWrite null (fabricated) cacheRead 5e-7
const TERRA = { input: 2e-6, output: 12e-6, cacheWrite: 2.5e-6, cacheRead: 2e-7 }
const GPT55 = { input: 5e-6, output: 30e-6, cacheRead: 5e-7 }
let tmpDir: string
beforeEach(async () => { tmpDir = await mkdtemp(join(tmpdir(), 'codex-1075-')) })
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<ParsedProviderCall> {
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: 's1075', 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('#1075 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 (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 === '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('#1075 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(expected).toBeCloseTo(0.00299, 12)
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 cache_creation_input_token_cost, 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('#1075 cache invalidation', () => {
it('discards a v10 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 { statSync } = await import('fs')
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.0445, // 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: 10,
files: { [sessionFile]: { dev: s.dev, ino: s.ino, mtimeMs: s.mtimeMs, sizeBytes: s.size, project: 'p', calls: [stale] } },
}))
const prevCacheDir = process.env['CODEBURN_CACHE_DIR']
process.env['CODEBURN_CACHE_DIR'] = cacheDir
try {
clearCodexMemCaches()
// Revert CODEX_CACHE_VERSION to 10 and this returns the stale $0.0445 call.
expect(await readCachedCodexResults(sessionFile)).toBeNull()
} finally {
if (prevCacheDir === undefined) delete process.env['CODEBURN_CACHE_DIR']; else process.env['CODEBURN_CACHE_DIR'] = prevCacheDir
}
})
it('re-derives days finalized at daily-cache v20', async () => {
const cacheRoot = join(tmpDir, 'daily')
await mkdir(cacheRoot, { recursive: true })
const prevCacheDir = process.env['CODEBURN_CACHE_DIR']
process.env['CODEBURN_CACHE_DIR'] = cacheRoot
try {
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.v20.json')
const oldCache = {
version: 20,
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 20 and the v20 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)
} finally {
if (prevCacheDir === undefined) delete process.env['CODEBURN_CACHE_DIR']; else process.env['CODEBURN_CACHE_DIR'] = prevCacheDir
}
})
})
// ── fixtures ──────────────────────────────────────────────────────────────
function makeApiCall(provider: string, model: string, usage: Partial<TokenUsage>): 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 } },
}
}