From 497f655623e0478b7b4a8337dc086e56ffafc07b Mon Sep 17 00:00:00 2001 From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com> Date: Thu, 20 Aug 2026 08:52:30 +0530 Subject: [PATCH 1/3] fix(codex): read session_meta string fields at payload depth 1 #1040 fixed nested provenance.model. The compact Buffer path still took the first cwd/session_id/originator/name anywhere in the payload. Same depth-1 window for every session_meta string field. Bump parse fingerprint and CODEX_CACHE_VERSION so present sources re-parse. --- CHANGELOG.md | 1 + src/codex-cache.ts | 4 +++- src/providers/codex.ts | 28 ++++++++++++++---------- src/session-cache.ts | 4 +++- tests/providers/codex.test.ts | 41 +++++++++++++++++++++++++++++++++++ 5 files changed, 64 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48595e28..ee7f1562 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ ### 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) +- **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. - **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) diff --git a/src/codex-cache.ts b/src/codex-cache.ts index cee8f704..376ad4d3 100644 --- a/src/codex-cache.ts +++ b/src/codex-cache.ts @@ -21,7 +21,9 @@ import type { ParsedProviderCall } from './providers/types.js' // entry without them simply re-parses in full once and gains them. // v9: parse large session_meta records structurally so nested provenance.model // cannot overwrite the model selected by turn_context. -const CODEX_CACHE_VERSION = 9 +// v10: same depth-1 window for the rest of session_meta's raw string fields +// (cwd/name/originator/session_id/forked_from_id/model_provider). +const CODEX_CACHE_VERSION = 10 const CACHE_FILE = 'codex-results.json' export type CodexFileFingerprint = { dev: number; ino: number; mtimeMs: number; sizeBytes: number } diff --git a/src/providers/codex.ts b/src/providers/codex.ts index 74a83aee..439793b7 100644 --- a/src/providers/codex.ts +++ b/src/providers/codex.ts @@ -431,12 +431,16 @@ function parseCodexLine(line: string | Buffer): CodexEntry | null { ? getRawDurationMs(getRawPayloadFieldWindow(line, 'duration') ?? '') : undefined const timingDuration = payloadDuration ?? getRawDurationMs(pHead) ?? getRawDurationMs(timingTail) - // 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') + // session_meta can embed same-name keys under base_instructions / + // dynamic_tools (including provenance.model). A depth-agnostic scan of the + // compact head steals the first nested hit and can overwrite turn_context. + // Restrict every session_meta string field to payload depth 1. Other event + // types keep the cheap first-match scan. + const payloadString = (field: string): string | undefined => + type === 'session_meta' + ? getRawJsonStringField(getRawPayloadFieldWindow(line, field) ?? '', field) + : getRawJsonStringField(pHead, field) + const compactModel = payloadString('model') const compactModelName = getRawJsonStringField(pHead, 'model_name') const compactLastUsage = getRawTokenUsage(pHead, 'last_token_usage') const compactTotalUsage = getRawTokenUsage(pHead, 'total_token_usage') @@ -451,13 +455,13 @@ function parseCodexLine(line: string | Buffer): CodexEntry | null { payload: { type: payloadType, role, - cwd: getRawJsonStringField(pHead, 'cwd'), - model_provider: getRawJsonStringField(pHead, 'model_provider'), - originator: getRawJsonStringField(pHead, 'originator'), - session_id: getRawJsonStringField(pHead, 'session_id'), - forked_from_id: getRawJsonStringField(pHead, 'forked_from_id'), + cwd: payloadString('cwd'), + model_provider: payloadString('model_provider'), + originator: payloadString('originator'), + session_id: payloadString('session_id'), + forked_from_id: payloadString('forked_from_id'), model: compactModel, - name: getRawJsonStringField(pHead, 'name'), + name: payloadString('name'), invocation, call_id: getRawJsonStringField(pHead, 'call_id'), turn_id: getRawJsonStringField(pHead, 'turn_id'), diff --git a/src/session-cache.ts b/src/session-cache.ts index 042ba14a..f165652e 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -279,7 +279,9 @@ export const PROVIDER_PARSE_VERSIONS: Record = { // lockstep so the pre-session-cache layer re-parses too.) // 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', + // session-meta-fields-v1: the same depth-1 window for cwd/name/originator/ + // session_id/forked_from_id/model_provider, not just model. + codex: 'mcp-attribution-v5-est-cost-active-timing-mcp-wait-rich-capture-v1-cross-provider-pr-v1-session-meta-model-v1-session-meta-fields-v1', cursor: 'composer-anchored-crediting-v1-est-cost', 'cursor-agent': 'workspaceless-transcript-v1', // source-provenance-v1 (#944): CLI sessions were misread as VS Code diff --git a/tests/providers/codex.test.ts b/tests/providers/codex.test.ts index 2b12f648..66613174 100644 --- a/tests/providers/codex.test.ts +++ b/tests/providers/codex.test.ts @@ -605,6 +605,47 @@ describe('codex provider - JSONL parsing', () => { expect(calls.map(call => call.model)).toEqual(['gpt-5.6-luna', 'gpt-5.6-luna']) }) + it('reads session_meta cwd/session_id/originator at payload depth 1, not the first nested same-name key', async () => { + const largeSessionMeta = JSON.stringify({ + type: 'session_meta', + timestamp: '2026-04-14T10:00:00Z', + payload: { + dynamic_tools: [{ + name: 'shadow-tool', + cwd: '/shadow/cwd', + originator: 'shadow-originator', + session_id: 'shadow-session', + forked_from_id: 'shadow-fork', + model_provider: 'shadow-provider', + }], + base_instructions: { text: 'x'.repeat(40_000) }, + cwd: '/Users/test/real-project', + originator: 'codex-cli', + session_id: 'sess-real', + model: 'gpt-5.6-luna', + model_provider: 'openai', + name: 'real-session-name', + }, + }) + const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-nested-keys.jsonl', [ + largeSessionMeta, + functionCall('exec_command'), + tokenCount({ timestamp: '2026-04-14T10:01:00Z', last: { input: 100, output: 50 }, total: { total: 150 } }), + ]) + + 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).toHaveLength(1) + expect(calls[0]!.sessionId).toBe('sess-real') + expect(calls[0]!.workingDirectory).toBe('/Users/test/real-project') + expect(calls[0]!.projectPath).toBe('/Users/test/real-project') + expect(calls[0]!.model).toBe('gpt-5.6-luna') + expect(calls[0]!.tools).toEqual(['Bash']) + }) + 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' }), From 4866332b91a446898b5cecc00bb75be7720f3a50 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Fri, 21 Aug 2026 02:21:31 -0700 Subject: [PATCH 2/3] test: price off the bundled snapshot so upstream reprices can't turn tests red loadPricing() fetched the live LiteLLM table during tests and live data wins over the bundled snapshot, so DeepSeek v4 assertions went red when upstream dropped the off-peak discount. CODEBURN_PRICING_SNAPSHOT_ONLY skips the fetch; env-isolation sets it for the whole suite. --- src/models.ts | 25 +++++++++++++++++-------- tests/setup/env-isolation.ts | 4 ++++ 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/models.ts b/src/models.ts index 50aa8098..7daa5f1b 100644 --- a/src/models.ts +++ b/src/models.ts @@ -225,21 +225,30 @@ function mergeSnapshotFallbacks(pricing: Map): Map): void { + pricingCache = pricing + sortedPricingKeys = null + lowercasePricingIndex = null + knownNamespaces = null +} + export async function loadPricing(): Promise { const cached = await loadCachedPricing() if (cached) { - pricingCache = mergeSnapshotFallbacks(cached) - sortedPricingKeys = null - lowercasePricingIndex = null - knownNamespaces = null + setPricingCache(mergeSnapshotFallbacks(cached)) + return + } + + // Test-only escape hatch, set for the whole suite in + // tests/setup/env-isolation.ts: skip the live LiteLLM fetch and price purely + // off the bundled snapshot, so an upstream reprice can't turn tests red. + if (process.env['CODEBURN_PRICING_SNAPSHOT_ONLY']) { + setPricingCache(mergeSnapshotFallbacks(new Map())) return } try { - pricingCache = mergeSnapshotFallbacks(await fetchAndCachePricing()) - sortedPricingKeys = null - lowercasePricingIndex = null - knownNamespaces = null + setPricingCache(mergeSnapshotFallbacks(await fetchAndCachePricing())) } catch { // snapshot already loaded at init; nothing more to do } diff --git a/tests/setup/env-isolation.ts b/tests/setup/env-isolation.ts index ea1c32a4..42f2aeba 100644 --- a/tests/setup/env-isolation.ts +++ b/tests/setup/env-isolation.ts @@ -106,6 +106,10 @@ function applyIsolation(): void { if (original === undefined) delete process.env[key] else process.env[key] = original } + // Price off the bundled LiteLLM snapshot only. Without this, loadPricing() + // fetches the live upstream table, so a mid-week reprice by a provider turns + // pricing assertions red with no local change. + process.env['CODEBURN_PRICING_SNAPSHOT_ONLY'] = '1' // Pin the timezone so date grouping is deterministic regardless of the dev's // shell TZ. Clearing it is not enough (Node falls back to the OS zone); a // non-UTC TZ would otherwise shift day buckets versus a clean CI runner. A From 577ff0b9eeded4b19b632e5038b7e20a2d80aa88 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Fri, 21 Aug 2026 02:23:41 -0700 Subject: [PATCH 3/3] test: skip the live Frankfurter fetch so FX moves can't shift assertions Mirrors the pricing guard: CODEBURN_FX_NO_FETCH short-circuits getExchangeRate after the cache read and before the fetch, returning the same USD-equivalent rate an unreachable network already yields. Tests that seed their own exchange-rate.json (serve-stdio's EUR case) keep working unchanged. --- src/currency.ts | 5 +++++ tests/setup/env-isolation.ts | 3 +++ 2 files changed, 8 insertions(+) diff --git a/src/currency.ts b/src/currency.ts index 1e9fae1d..f084546a 100644 --- a/src/currency.ts +++ b/src/currency.ts @@ -114,6 +114,11 @@ async function getExchangeRate(code: string): Promise { const cached = await loadCachedRate(code) if (cached) return cached + // Test-only escape hatch, set for the whole suite in + // tests/setup/env-isolation.ts: skip the live Frankfurter fetch so a real FX + // move can't shift assertions. Same fallback an unreachable network gets. + if (process.env['CODEBURN_FX_NO_FETCH']) return 1 + let rate: number try { rate = await fetchRate(code) diff --git a/tests/setup/env-isolation.ts b/tests/setup/env-isolation.ts index 42f2aeba..6a3326ec 100644 --- a/tests/setup/env-isolation.ts +++ b/tests/setup/env-isolation.ts @@ -110,6 +110,9 @@ function applyIsolation(): void { // fetches the live upstream table, so a mid-week reprice by a provider turns // pricing assertions red with no local change. process.env['CODEBURN_PRICING_SNAPSHOT_ONLY'] = '1' + // Same for FX: skip the live Frankfurter fetch and fall back to the + // USD-equivalent rate unless a test seeds its own exchange-rate.json cache. + process.env['CODEBURN_FX_NO_FETCH'] = '1' // Pin the timezone so date grouping is deterministic regardless of the dev's // shell TZ. Clearing it is not enough (Node falls back to the OS zone); a // non-UTC TZ would otherwise shift day buckets versus a clean CI runner. A