Merge pull request #1040 from timdp/codex-model-attribution-fix
Some checks failed
CI / semgrep (push) Has been cancelled
Tests / test (22) (push) Has been cancelled
macOS Menubar CI / test (push) Has been cancelled
Tests / test (22.13.0) (push) Has been cancelled

Fix Codex model attribution after session metadata
This commit is contained in:
Resham Joshi 2026-08-19 13:49:52 -07:00 committed by GitHub
commit a48d78f9d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 78 additions and 16 deletions

View file

@ -35,6 +35,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 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)
- **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.
- **MiMo sessions price from the LiteLLM Xiaomi rows, and MiMo v2 Flash no longer crashes the display path.** Hermes / Xiaomi token-plan sessions store the bare id (`mimo-v2.5-pro`, `mimo-v2.5`) while LiteLLM namespaces its row (`xiaomi/…`), so those models reported $0. They now alias to the existing snapshot rows — no invented rate, and `kimi-k3` still has none — which means a session Hermes left costless is priced from the shared tables and carries the estimated marker, exactly as `mimo-v2-flash` already did. The same change fixes a **pre-existing** crash that this alias did not introduce: the shipped `mimo-v2-flash -> xiaomi/mimo-v2-flash` alias already cycled through display-name resolution — strip the namespace, alias it back, take the leaf, repeat — so `getShortModelName` blew the stack on any real MiMo v2 Flash session and took every surface that names a model down with it, the `models` table included. Display-name resolution is now cycle-safe, and the `mimo-v2-flash` and `mimo-v2.5` rows are named rather than shown as raw slugs.
- **A date-ranged run no longer republishes the month shards it never read.** A scoped load leaves an out-of-range month on disk, so the files it holds have no visible cache entry and the reconcile re-parses them — re-deriving the entry the shard already stores. That re-parse marked the unloaded month dirty, and the save merged and republished it under a fresh nonce name on every single run, byte-identical content and all, so a repeated `codeburn status --format json` churned old months (on a real corpus: claude/2026-03, cursor/2026-02 and warp/2026-03 renamed every run) and left the retired shards for the sweeper. A merge into an unloaded month that neither adds, changes nor removes an entry now keeps the published shard, so unchanged months keep their names and their bytes. (#1032)

View file

@ -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.v19.json'
const NEW_DAILY_CACHE = 'daily-cache.v20.json'
const HOME = join(WORK, 'user home')
const PAYLOADS = join(WORK, 'payloads')

View file

@ -19,7 +19,9 @@ import type { ParsedProviderCall } from './providers/types.js'
// resumeState): they are additive and absence-safe in both directions, so a
// bump would only throw away a warm multi-hundred-MB cache to gain nothing. An
// entry without them simply re-parses in full once and gains them.
const CODEX_CACHE_VERSION = 8
// v9: parse large session_meta records structurally so nested provenance.model
// cannot overwrite the model selected by turn_context.
const CODEX_CACHE_VERSION = 9
const CACHE_FILE = 'codex-results.json'
export type CodexFileFingerprint = { dev: number; ino: number; mtimeMs: number; sizeBytes: number }

View file

@ -6,6 +6,24 @@ import { join } from 'path'
import { getCodeburnCacheDir } from './cache-dir.js'
import type { DateRange, ProjectSummary } from './types.js'
// Bumped to 20: the Codex fast-path read a nested
// `base_instructions.provenance.model` out of `session_meta` as if it were
// `payload.model` (#1040), so every call a rollout attributed from session
// metadata - the ones before its first `turn_context`, and every one after a
// mid-file `session_meta` - was credited to the wrong model. The codex parse
// version and CODEX_CACHE_VERSION move with it, but the daily cache has no
// per-provider invalidation, so days already finalized keep the wrong model
// rows forever unless MIN_SUPPORTED_VERSION moves too. This pass re-derives
// ALL days for EVERY provider off the warm session cache, so it costs seconds
// rather than a full re-parse, and it is lossless: adoptOlderDailyCaches keeps
// the superseded v19 file as the baseline, and the partial-survival guard from
// #1033 holds any day whose sources have only partly aged out. On a real
// 110-day cache the 19 -> 20 pass moved ZERO days down and lost none: 100 days
// came back byte-identical and 9 grok days rose by $19.80 in total, clearing
// rollups the grok parse change had left stale, while every codex model row
// stayed identical (that corpus predates the `provenance` field, so the fix it
// carries has nothing to correct there).
//
// Bumped to 19: Grok authoritative usage now keeps one session-level rollup
// from top-level totals, clamps reasoning to reported output, and labels mixed
// authoritative/heuristic coverage. Every day finalized under the previous
@ -92,8 +110,8 @@ 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 = 19
const MIN_SUPPORTED_VERSION = 19
export const DAILY_CACHE_VERSION = 20
const MIN_SUPPORTED_VERSION = 20
// 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

@ -431,7 +431,12 @@ function parseCodexLine(line: string | Buffer): CodexEntry | null {
? getRawDurationMs(getRawPayloadFieldWindow(line, 'duration') ?? '')
: undefined
const timingDuration = payloadDuration ?? getRawDurationMs(pHead) ?? getRawDurationMs(timingTail)
const compactModel = getRawJsonStringField(pHead, 'model')
// session_meta can contain base_instructions.provenance.model. Only inspect
// direct payload fields there, or a nested provenance model would overwrite
// the model selected by the latest turn_context.
const compactModel = type === 'session_meta'
? getRawJsonStringField(getRawPayloadFieldWindow(line, 'model') ?? '', 'model')
: getRawJsonStringField(pHead, 'model')
const compactModelName = getRawJsonStringField(pHead, 'model_name')
const compactLastUsage = getRawTokenUsage(pHead, 'last_token_usage')
const compactTotalUsage = getRawTokenUsage(pHead, 'total_token_usage')
@ -451,7 +456,7 @@ function parseCodexLine(line: string | Buffer): CodexEntry | null {
originator: getRawJsonStringField(pHead, 'originator'),
session_id: getRawJsonStringField(pHead, 'session_id'),
forked_from_id: getRawJsonStringField(pHead, 'forked_from_id'),
model: getRawJsonStringField(pHead, 'model'),
model: compactModel,
name: getRawJsonStringField(pHead, 'name'),
invocation,
call_id: getRawJsonStringField(pHead, 'call_id'),

View file

@ -277,7 +277,9 @@ export const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
// rich-session-capture-v1: per-call LOC deltas + editFailed from
// patch_apply_end. (The codex-results.json CODEX_CACHE_VERSION is bumped in
// lockstep so the pre-session-cache layer re-parses too.)
codex: 'mcp-attribution-v5-est-cost-active-timing-mcp-wait-rich-capture-v1-cross-provider-pr-v1',
// session-meta-model-v1: parse large session_meta records structurally so a
// nested base_instructions provenance.model cannot overwrite turn_context.
codex: 'mcp-attribution-v5-est-cost-active-timing-mcp-wait-rich-capture-v1-cross-provider-pr-v1-session-meta-model-v1',
cursor: 'composer-anchored-crediting-v1-est-cost',
'cursor-agent': 'workspaceless-transcript-v1',
// source-provenance-v1 (#944): CLI sessions were misread as VS Code

View file

@ -10,11 +10,11 @@ import {
type DailyEntry,
} from '../src/daily-cache.js'
// The last SHIPPED daily-cache version before the Grok accounting change, so
// this models the real 17 -> 19 upgrade path users hit. (v18 existed only as an
// unreleased draft.) Anything below MIN_SUPPORTED_VERSION is untrusted, which
// is what makes the re-derivation global rather than Grok-scoped.
const PRE_FIX_DAILY_VERSION = 17
// The last SHIPPED daily-cache version before the Codex session_meta model fix,
// so this models the real 19 -> 20 upgrade path users hit. Anything below
// MIN_SUPPORTED_VERSION is untrusted, which is what makes the re-derivation
// global rather than scoped to the provider the bump exists for.
const PRE_FIX_DAILY_VERSION = 19
const cacheRoot = join(tmpdir(), `codeburn-daily-rederive-${process.pid}-${Date.now()}`)
function day(date: string, cost: number): DailyEntry {
@ -67,10 +67,9 @@ afterEach(async () => {
await rm(cacheRoot, { recursive: true, force: true })
})
// Raising MIN_SUPPORTED_VERSION re-derives EVERY day from EVERY provider, not
// only Grok - the daily cache has no per-provider invalidation. A Grok day is
// used here because Grok is the provider whose totals the bump exists to
// correct; the mechanism under test is version-wide.
// Raising MIN_SUPPORTED_VERSION re-derives EVERY day from EVERY provider - the
// daily cache has no per-provider invalidation. Which provider the seeded day
// belongs to is incidental; the mechanism under test is version-wide.
describe('daily-cache re-derivation on a DAILY_CACHE_VERSION bump', () => {
it('re-derives a day from a below-minimum v17 cache while preserving the old file', async () => {
const date = toDateString(new Date(Date.now() - 7 * 24 * 60 * 60 * 1000))

View file

@ -570,6 +570,41 @@ describe('codex provider - session discovery', () => {
})
describe('codex provider - JSONL parsing', () => {
it('does not treat a nested session_meta model as the active turn model', async () => {
const largeSessionMeta = JSON.stringify({
type: 'session_meta',
timestamp: '2026-04-14T10:00:00Z',
payload: {
cwd: '/Users/test/model-switch',
originator: 'codex-cli',
session_id: 'sess-model-switch',
base_instructions: {
provenance: { type: 'model', model: 'gpt-5.6-sol' },
text: 'x'.repeat(40_000),
},
},
})
const turnContext = JSON.stringify({
type: 'turn_context',
timestamp: '2026-04-14T10:00:01Z',
payload: { model: 'gpt-5.6-luna' },
})
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-model-switch.jsonl', [
largeSessionMeta,
turnContext,
tokenCount({ timestamp: '2026-04-14T10:00:02Z', last: { input: 100, output: 50 }, total: { total: 150 } }),
largeSessionMeta,
tokenCount({ timestamp: '2026-04-14T10:00:03Z', last: { input: 200, output: 100 }, total: { total: 450 } }),
])
const provider = createCodexProvider(tmpDir)
const source = { path: filePath, project: 'test', provider: 'codex' }
const calls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call)
expect(calls.map(call => call.model)).toEqual(['gpt-5.6-luna', 'gpt-5.6-luna'])
})
it('extracts token usage from last_token_usage', async () => {
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-parse.jsonl', [
sessionMeta({ session_id: 'sess-parse', model: 'gpt-5.3-codex' }),