@@ -48,7 +48,9 @@ async function parseCodexSessionFile(
4848 let sessionId = sessionIdFromFilePath ( filePath , 'codex' )
4949 let cwd : string | undefined
5050 let project : string | undefined
51- let model : string | undefined
51+ // `session_meta` has no model — only `model_provider` — so seed from the
52+ // first `turn_context` up front. See firstCodexTurnContextModel.
53+ let model : string | undefined = firstCodexTurnContextModel ( lines )
5254 // Headless `codex exec` files have no session_meta; emit a synthetic
5355 // session.started on the first usage line so rollups still get a boundary.
5456 let sessionStartEmitted = false
@@ -149,7 +151,8 @@ async function parseCodexSessionFile(
149151 sessionId = stringField ( payload , 'id' ) || sessionId
150152 cwd = stringField ( payload , 'cwd' ) || cwd
151153 project = cwd ? path . basename ( cwd ) : project
152- model = stringField ( payload , 'model_provider' ) || model
154+ // Deliberately NOT reading `model_provider` here — see
155+ // firstCodexTurnContextModel; `model` was seeded from it above.
153156 events . push ( withBackfillRefs ( {
154157 schemaVersion : AGENT_TIME_SCHEMA_VERSION ,
155158 ts,
@@ -171,7 +174,7 @@ async function parseCodexSessionFile(
171174 currentTurnId = stringField ( payload , 'turn_id' ) || currentTurnId
172175 cwd = stringField ( payload , 'cwd' ) || cwd
173176 project = cwd ? path . basename ( cwd ) : project
174- model = stringField ( payload , 'model' ) || model
177+ model = normalizeCodexModel ( stringField ( payload , 'model' ) , ts ) || model
175178 // Codex hasn't shipped service_tier inside turn_context yet, but the field
176179 // is the natural per-turn location and the upstream protocol allows it.
177180 // Honor it when present so future Codex builds get accurate fast/priority
@@ -189,12 +192,9 @@ async function parseCodexSessionFile(
189192 if ( topType === 'turn.completed' || topType === 'result' || topType === undefined ) {
190193 const usage = headlessCodexUsage ( raw )
191194 if ( usage ) {
192- const parsedModel = headlessCodexModel ( raw )
193- if ( parsedModel ) {
194- model = parsedModel
195- }
196- const eventModel = parsedModel || model || 'gpt-5'
197195 const headlessTs = headlessCodexTimestamp ( raw ) || ts
196+ model = normalizeCodexModel ( headlessCodexModel ( raw ) , headlessTs ) || model
197+ const eventModel = model || 'gpt-5'
198198 if ( ! sessionStartEmitted ) {
199199 pushEvent ( baseCodexEvent ( {
200200 ts : headlessTs ,
@@ -566,6 +566,78 @@ async function parseCodexSessionFile(
566566
567567// ── Codex-specific helpers ──
568568
569+ // Codex stamps `codex-auto-review` as the model on its automatic code-review
570+ // turns. It is a label, not a model — the tokens are billed against whichever
571+ // review model Codex shipped on that date. Ported from ccusage's
572+ // codex_log_model_fallback + codex-auto-review-fallbacks.json
573+ // (rust/crates/ccusage/src/adapter/codex/parser.rs). Newest first; keep in
574+ // sync when ccusage refreshes its snapshot. Note the table stops at gpt-5.5,
575+ // so any review turn after 2026-04-23 resolves to it — upstream has not
576+ // published a gpt-5.6-era row. That is not cost-neutral (gpt-5.6-sol carries
577+ // an explicit cache-creation rate gpt-5.5 does not), just the best available
578+ // evidence about which model actually ran.
579+ const CODEX_AUTO_REVIEW_MODEL = 'codex-auto-review'
580+ const CODEX_AUTO_REVIEW_FALLBACKS : ReadonlyArray < { releasedOn : string , model : string } > = [
581+ { releasedOn : '2026-04-23' , model : 'gpt-5.5' } ,
582+ { releasedOn : '2026-03-05' , model : 'gpt-5.4' } ,
583+ { releasedOn : '2026-02-05' , model : 'gpt-5.3-codex' } ,
584+ { releasedOn : '2025-12-11' , model : 'gpt-5.2-codex' } ,
585+ { releasedOn : '2025-11-13' , model : 'gpt-5.1-codex' } ,
586+ { releasedOn : '2025-09-15' , model : 'gpt-5-codex' } ,
587+ { releasedOn : '2025-08-07' , model : 'gpt-5' } ,
588+ ]
589+
590+ // Turn a raw Codex model string into a name the backend pricing catalogue can
591+ // resolve:
592+ // - `gpt-5.5(xhigh)` / `gpt-5.4 (high)` — some third-party Codex proxies
593+ // append the reasoning effort. It is not part of any catalogue id and
594+ // pricing never varies by effort, so drop it.
595+ // - `codex-auto-review` — resolve to the review model shipping at `ts`.
596+ function normalizeCodexModel ( model : string | undefined , ts : string | undefined ) : string | undefined {
597+ if ( ! model ) {
598+ return model
599+ }
600+ const cleaned = model . replace ( / \s * \( [ ^ ) ] * \) \s * $ / , '' ) . trim ( ) || model
601+ if ( cleaned !== CODEX_AUTO_REVIEW_MODEL ) {
602+ return cleaned
603+ }
604+ const date = ts ?. slice ( 0 , 10 )
605+ const fallback = date && / ^ \d { 4 } - \d { 2 } - \d { 2 } $ / . test ( date )
606+ ? CODEX_AUTO_REVIEW_FALLBACKS . find ( entry => date >= entry . releasedOn )
607+ : undefined
608+ // Pre-dating the whole table (or an unparseable ts) means the oldest
609+ // release is the best guess — same default ccusage uses.
610+ return fallback ?. model ?? 'gpt-5'
611+ }
612+
613+ // `session_meta` carries `model_provider` (an API provider id — `openai`, or
614+ // whatever name a proxy gives itself), never a model; the model lives in
615+ // `turn_context.model`. Scan ahead for the first one so events emitted before
616+ // the first turn_context (session.started, plus any usage line that precedes
617+ // it) still carry the real model. Reading `model_provider` into `model` is
618+ // how `openai` / `crs` / `custom` used to reach the model leaderboard.
619+ function firstCodexTurnContextModel ( lines : string [ ] ) : string | undefined {
620+ for ( const line of lines ) {
621+ // Cheap reject first — re-parsing every line of a large rollout is not
622+ // worth it when turn_context normally sits within the first few lines.
623+ if ( ! line . includes ( '"turn_context"' ) ) {
624+ continue
625+ }
626+ const raw = parseJsonLine ( line )
627+ if ( ! raw || stringField ( raw , 'type' ) !== 'turn_context' ) {
628+ continue
629+ }
630+ const model = normalizeCodexModel (
631+ stringField ( objectField ( raw , 'payload' ) , 'model' ) ,
632+ timestampFrom ( raw . timestamp ) ,
633+ )
634+ if ( model ) {
635+ return model
636+ }
637+ }
638+ return undefined
639+ }
640+
569641function rewriteCodexModelForTier ( model : string | undefined , serviceTier : string | undefined ) : string | undefined {
570642 if ( ! model ) {
571643 return model
0 commit comments