mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-26 00:44:41 +00:00
merge: union #1055 session_meta invalidation into auto-review pricing
#1055 already took CODEX_CACHE_VERSION 10 and session-meta-fields-v1. Keep both parse-version tokens and bump Codex results to 11 so a take-ours merge cannot drop either invalidation.
This commit is contained in:
commit
4d15b05d84
8 changed files with 99 additions and 26 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -21,9 +21,12 @@ 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.
|
||||
// v10: builtin alias prices `codex-auto-review` as gpt-5.4 (#1047). Exact-hit
|
||||
// cache entries still hold the pre-alias $0; bump so unchanged rollouts reprice.
|
||||
const CODEX_CACHE_VERSION = 10
|
||||
// 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). (#1055)
|
||||
// v11: builtin alias prices `codex-auto-review` (#1047). Exact-hit cache
|
||||
// entries still hold the pre-alias $0; bump so unchanged rollouts reprice.
|
||||
// Must be max(#1055, this)+1 — both claimed v10 independently.
|
||||
const CODEX_CACHE_VERSION = 11
|
||||
const CACHE_FILE = 'codex-results.json'
|
||||
|
||||
export type CodexFileFingerprint = { dev: number; ino: number; mtimeMs: number; sizeBytes: number }
|
||||
|
|
|
|||
|
|
@ -114,6 +114,11 @@ async function getExchangeRate(code: string): Promise<number> {
|
|||
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)
|
||||
|
|
|
|||
|
|
@ -225,21 +225,30 @@ function mergeSnapshotFallbacks(pricing: Map<string, ModelCosts>): Map<string, M
|
|||
return applyBuiltinPriceOverrides(pricing)
|
||||
}
|
||||
|
||||
function setPricingCache(pricing: Map<string, ModelCosts>): void {
|
||||
pricingCache = pricing
|
||||
sortedPricingKeys = null
|
||||
lowercasePricingIndex = null
|
||||
knownNamespaces = null
|
||||
}
|
||||
|
||||
export async function loadPricing(): Promise<void> {
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -279,9 +279,12 @@ export const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
|
|||
// 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.
|
||||
// activity-price-v1: `codex-auto-review` now prices as gpt-5.4 (#1047).
|
||||
// session-cache.json would otherwise keep the pre-alias $0 section.
|
||||
codex: 'mcp-attribution-v5-est-cost-active-timing-mcp-wait-rich-capture-v1-cross-provider-pr-v1-session-meta-model-v1-activity-price-v1',
|
||||
// session-meta-fields-v1: the same depth-1 window for cwd/name/originator/
|
||||
// session_id/forked_from_id/model_provider, not just model. (#1055)
|
||||
// activity-price-v1: `codex-auto-review` now prices via the recommended
|
||||
// review model. session-cache.json would otherwise keep the pre-alias $0.
|
||||
// Union both tokens — a take-ours merge would drop #1055's invalidation.
|
||||
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-activity-price-v1',
|
||||
cursor: 'composer-anchored-crediting-v1-est-cost',
|
||||
'cursor-agent': 'workspaceless-transcript-v1',
|
||||
// source-provenance-v1 (#944): CLI sessions were misread as VS Code
|
||||
|
|
|
|||
|
|
@ -607,6 +607,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' }),
|
||||
|
|
|
|||
|
|
@ -106,6 +106,13 @@ 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'
|
||||
// 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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue