mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-23 23:45:12 +00:00
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.
157 lines
7.9 KiB
JavaScript
157 lines
7.9 KiB
JavaScript
// Per-provider payload parity between the published CLI and this build.
|
|
//
|
|
// node scripts/upgrade-path/compare.mjs <baselineDir> <upgradedDir>
|
|
//
|
|
// Each dir holds the payloads run.mjs captured: `export.json` (per-call records,
|
|
// the token/call source) and `menubar.json` (the unrounded per-provider cost).
|
|
// Prints one row per provider and exits non-zero on a diff that is not expected.
|
|
//
|
|
// Expectations, and why:
|
|
// claude, codex, gemini, kiro, cursor parse identically either side of the
|
|
// upgrade. Calls and every token field must match EXACTLY; cost is allowed
|
|
// COST_TOLERANCE of drift because the two binaries carry different bundled
|
|
// LiteLLM price snapshots and only agree when the shared pricing cache in
|
|
// CODEBURN_CACHE_DIR is warm (which it is, unless the runner is offline).
|
|
// grok changed by design in #1015: usage now comes from the CLI's own
|
|
// turn_completed records instead of a context-curve estimate. The change
|
|
// is REPORTED, never asserted — not even directionally. On real corpora
|
|
// the changelog documents totals rising, but that is a property of real
|
|
// Grok sessions, and the direction here would only reflect how the
|
|
// generator happened to size its synthetic context curve against its
|
|
// synthetic usage records. Both sides must still count the same SESSIONS,
|
|
// 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; 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'
|
|
|
|
const [baseDir, upDir] = process.argv.slice(2)
|
|
if (!baseDir || !upDir) {
|
|
console.error('usage: compare.mjs <baselineDir> <upgradedDir>')
|
|
process.exit(2)
|
|
}
|
|
|
|
const TOKEN_FIELDS = ['inputTokens', 'outputTokens', 'reasoningTokens', 'cacheWriteTokens', 'cacheReadTokens']
|
|
|
|
function load(dir) {
|
|
const exported = JSON.parse(readFileSync(join(dir, 'export.json'), 'utf8'))
|
|
const menubar = JSON.parse(readFileSync(join(dir, 'menubar.json'), 'utf8'))
|
|
const byProvider = {}
|
|
for (const r of exported.records ?? []) {
|
|
const p = r.provider || 'unknown'
|
|
const acc = (byProvider[p] ??= { calls: 0, cost: 0, ...Object.fromEntries(TOKEN_FIELDS.map(f => [f, 0])) })
|
|
acc.calls++
|
|
acc.cost += r.cost ?? 0
|
|
for (const f of TOKEN_FIELDS) acc[f] += r[f] ?? 0
|
|
}
|
|
// Prefer the unrounded cost, keyed by the provider's internal id.
|
|
// `providerDetails` is the only place that pairing exists — the sibling
|
|
// `providers` map is keyed by lowercased display name. The per-record sum
|
|
// above stands in when a binary predates providerDetails; it is rounded per
|
|
// record, so it is the coarser of the two.
|
|
for (const d of menubar.current?.providerDetails ?? []) {
|
|
if (byProvider[d.id]) byProvider[d.id].cost = d.cost
|
|
}
|
|
return byProvider
|
|
}
|
|
|
|
const base = load(baseDir)
|
|
const up = load(upDir)
|
|
const providers = [...new Set([...Object.keys(base), ...Object.keys(up)])].sort()
|
|
|
|
const failures = []
|
|
const notes = []
|
|
const rows = []
|
|
|
|
const relDiff = (a, b) => (a === 0 && b === 0 ? 0 : Math.abs(b - a) / Math.max(Math.abs(a), Math.abs(b)))
|
|
const fmt = n => (Number.isInteger(n) ? String(n) : n.toFixed(6))
|
|
|
|
for (const name of providers) {
|
|
const b = base[name]
|
|
const u = up[name]
|
|
let verdict
|
|
|
|
if (NEW_IN_THIS_RELEASE.includes(name)) {
|
|
if (b) failures.push(`${name}: expected to be absent from the 0.9.20 baseline, but it reported ${b.calls} calls`)
|
|
else if (!u || u.calls === 0) failures.push(`${name}: new in this release but the upgraded run reported nothing`)
|
|
verdict = 'new (expected)'
|
|
} else if (!b || !u) {
|
|
failures.push(`${name}: present in ${b ? 'baseline' : 'upgraded'} only`)
|
|
verdict = 'MISSING'
|
|
} else if (CHANGED_BY_DESIGN.includes(name)) {
|
|
const bt = TOKEN_FIELDS.reduce((s, f) => s + b[f], 0)
|
|
const ut = TOKEN_FIELDS.reduce((s, f) => s + u[f], 0)
|
|
if (b.calls !== u.calls) failures.push(`${name}: usage accounting changed in #1015, but the session/call COUNT should not have: ${b.calls} != ${u.calls}`)
|
|
verdict = 'changed by design'
|
|
notes.push(`${name}: tokens ${bt} -> ${ut}, cost ${fmt(b.cost)} -> ${fmt(u.cost)} (#1015, expected; magnitude here is a property of the fixture, not evidence)`)
|
|
} else {
|
|
const diffs = []
|
|
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)) {
|
|
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)}%)`)
|
|
}
|
|
if (!EXACT.includes(name)) {
|
|
notes.push(`${name}: no expectation declared in compare.mjs; ${diffs.length ? diffs.join(', ') : 'identical'}`)
|
|
verdict = diffs.length ? 'differs (unclassified)' : 'identical'
|
|
} 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)`
|
|
}
|
|
}
|
|
|
|
rows.push({
|
|
provider: name,
|
|
calls: `${b?.calls ?? '-'} -> ${u?.calls ?? '-'}`,
|
|
tokens: `${b ? TOKEN_FIELDS.reduce((s, f) => s + b[f], 0) : '-'} -> ${u ? TOKEN_FIELDS.reduce((s, f) => s + u[f], 0) : '-'}`,
|
|
cost: `${b ? fmt(b.cost) : '-'} -> ${u ? fmt(u.cost) : '-'}`,
|
|
verdict,
|
|
})
|
|
}
|
|
|
|
const cols = ['provider', 'calls', 'tokens', 'cost', 'verdict']
|
|
const width = Object.fromEntries(cols.map(c => [c, Math.max(c.length, ...rows.map(r => r[c].length))]))
|
|
const line = r => cols.map(c => String(r[c]).padEnd(width[c])).join(' ')
|
|
console.log('')
|
|
console.log(line(Object.fromEntries(cols.map(c => [c, c.toUpperCase()]))))
|
|
console.log(cols.map(c => '-'.repeat(width[c])).join(' '))
|
|
for (const r of rows) console.log(line(r))
|
|
console.log('')
|
|
for (const n of notes) console.log(`note: ${n}`)
|
|
for (const f of failures) console.log(`FAIL: ${f}`)
|
|
console.log(failures.length ? `\nparity: ${failures.length} unexpected difference(s)` : '\nparity: ok')
|
|
process.exit(failures.length ? 1 : 0)
|