Merge pull request #1056 from avs-io/fix/1047-codex-activity-ids

fix(models): price Codex activity ids via the official underlying model
This commit is contained in:
Resham Joshi 2026-08-22 04:14:32 -07:00 committed by GitHub
commit 13c1785df7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 156 additions and 11 deletions

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

View file

@ -40,7 +40,10 @@ import type { ParsedProviderCall } from './providers/types.js'
// in `skills`. This file stores each call's `tools`/`toolSequence`/`skills`
// verbatim (they are passed through on read, never re-derived), so v13 entries
// keep the old, MCP- and skill-less attribution until they re-parse.
export const CODEX_CACHE_VERSION = 14
// v15: 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(main v14 #1092, this)+1 — #1092 spent v14 on MCP/skills.
export const CODEX_CACHE_VERSION = 15
export const CODEX_LEGACY_CACHE_FILE = 'codex-results.json'
export function codexCacheFileName(version = CODEX_CACHE_VERSION): string {
return `codex-results.v${version}.json`

View file

@ -20,10 +20,19 @@ const CREDITS_PER_MILLION: Record<string, CodexCreditRate> = {
'gpt-5.4-mini': { input: 18.75, cachedInput: 1.875, output: 113 },
}
// Activity surfaces keep their product id on the call (display stays
// "Codex Auto Review"). Credits must follow the same underlying model
// BUILTIN_ALIASES uses for USD. Keep this table in lockstep with
// `codex-auto-review` in src/models.ts.
const ACTIVITY_CREDIT_MODELS: Record<string, string> = {
'codex-auto-review': 'gpt-5.5',
}
/// Resolve the credit rate for a Codex model name, tolerating suffix variants
/// (e.g. "gpt-5.5-codex"). Returns null when the model has no known credit rate.
export function codexCreditRate(model: string): CodexCreditRate | null {
const m = model.toLowerCase()
const mapped = ACTIVITY_CREDIT_MODELS[model] ?? ACTIVITY_CREDIT_MODELS[model.toLowerCase()]
const m = (mapped ?? model).toLowerCase()
if (m.includes('5.4') && m.includes('mini')) return CREDITS_PER_MILLION['gpt-5.4-mini']!
if (m.includes('5.4')) return CREDITS_PER_MILLION['gpt-5.4']!
if (m.includes('5.5')) return CREDITS_PER_MILLION['gpt-5.5']!

View file

@ -6,6 +6,15 @@ import { join } from 'path'
import { getCodeburnCacheDir } from './cache-dir.js'
import type { DateRange, ProjectSummary } from './types.js'
// Bumped to 25: `codex-auto-review` now prices as the recommended GPT-5.5
// row (#1047). Days already finalized under v24 keep that id at $0
// forever unless MIN_SUPPORTED_VERSION moves: the daily cache has no
// per-provider invalidation. The Codex parse version and CODEX_CACHE_VERSION
// move with this so the lower caches reprice first; this pass then re-derives
// ALL days from the warm session cache (seconds, not a full re-parse).
// adoptOlderDailyCaches keeps the superseded file as the baseline. v21 is
// #946, v22 was this PR's earlier claim, v23 is #1075, v24 is #1090.
//
// 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
@ -128,11 +137,10 @@ import type { DateRange, ProjectSummary } from './types.js'
// window where neither existed). The daily cache has no per-provider
// invalidation, so there is no way to tell those days apart from here -
// raising MIN_SUPPORTED_VERSION forces the one-time re-derivation for
// everyone, which is a lossless no-op for days already correct. #1056 also
// claims 24 on its own branch (unmerged as of this writing); whichever lands
// second takes the next number instead of reusing this one.
export const DAILY_CACHE_VERSION = 24
const MIN_SUPPORTED_VERSION = 24
// everyone, which is a lossless no-op for days already correct.
// v25: #1047 activity-id pricing. v24 on main already shipped #1090.
export const DAILY_CACHE_VERSION = 25
const MIN_SUPPORTED_VERSION = 25
// 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

@ -312,6 +312,16 @@ const BUILTIN_ALIASES: Record<string, string> = {
'openclaw-auto': 'claude-sonnet-4-5',
'warp-auto-efficient': 'gpt-5.3-codex',
'warp-auto-powerful': 'claude-opus-4-6',
// Codex activity ids are product surfaces, not subscription SKUs and not
// LiteLLM rows. OpenAI's tracker (openai/codex#32224) says auto review
// consumes normal model usage. Public evidence: review_model defaults to
// the session model; GPT-5.5 is the currently recommended review model.
// Price as that existing bundled row. Do not invent a rate. Do not treat
// the id as honestly $0 — it draws from the same credit pool. Display
// stays on autoModelNames (same class as cursor-auto / copilot-openai-auto).
// Only alias ids observed in Codex source / real rollouts. Do not infer
// `codex-code-review` from the activity name "code review".
'codex-auto-review': 'gpt-5.5',
'grok-build': 'grok-build-0.1',
'GPT-5.3 Codex (low reasoning)': 'gpt-5.3-codex',
'GPT-5.3 Codex (medium reasoning)': 'gpt-5.3-codex',
@ -1017,6 +1027,7 @@ const autoModelNames: Record<string, string> = {
'openclaw-auto': 'OpenClaw (auto)',
'qwen-auto': 'Qwen (auto)',
'kimi-auto': 'Kimi (auto)',
'codex-auto-review': 'Codex Auto Review',
}
const SHORT_NAMES: Record<string, string> = {

View file

@ -280,7 +280,7 @@ export const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
// session-meta-model-v1: parse large session_meta records structurally so a
// nested base_instructions provenance.model cannot overwrite turn_context.
// session-meta-fields-v1: the same depth-1 window for cwd/name/originator/
// session_id/forked_from_id/model_provider, not just model.
// session_id/forked_from_id/model_provider, not just model. (#1055)
// codex-pricing-v1 (#1075): reasoning tokens are no longer added on top of
// output, and cache_write_input_tokens moves out of the plain input bucket on
// models with an explicit cache-write rate. The bucket move does NOT self-heal
@ -294,7 +294,10 @@ export const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
// through the `exec` custom tool or the item model's `CommandExecution` item
// were counted as Bash only. Cached sessions store tools/toolSequence/skills
// verbatim, so they must re-parse to gain the attribution.
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-codex-pricing-v1-codex-tps-v1-codex-mcp-skills-v1',
// activity-price-v1: `codex-auto-review` now prices via the recommended
// review model. session-cache.json would otherwise keep the pre-alias $0.
// Compose all four — a take-ours merge would drop #1075, #1079, or #1092.
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-codex-pricing-v1-codex-tps-v1-codex-mcp-skills-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

View file

@ -17,6 +17,12 @@ describe('codexCreditRate', () => {
expect(codexCreditRate('gpt-4o')).toBeNull()
expect(codexCreditRate('claude-opus-4-8')).toBeNull()
})
it('resolves the auto-review activity id to the same rate as GPT-5.5', () => {
expect(codexCreditRate('codex-auto-review')).toEqual(codexCreditRate('gpt-5.5'))
expect(codexCreditRate('codex-auto-review')).not.toBeNull()
expect(codexCreditRate('CODEX-AUTO-REVIEW')).toEqual(codexCreditRate('gpt-5.5'))
})
})
describe('codexCredits', () => {
@ -45,4 +51,8 @@ describe('codexCredits', () => {
it('returns null for an unknown model', () => {
expect(codexCredits('gpt-4o', { inputTokens: 1_000_000, cachedReadTokens: 0, outputTokens: 0 })).toBeNull()
})
it('charges auto-review at the GPT-5.5 credit rate, not null', () => {
expect(codexCredits('codex-auto-review', { inputTokens: 1_000_000, cachedReadTokens: 0, outputTokens: 0 })).toBe(125)
})
})

View file

@ -653,6 +653,8 @@ describe('Cursor model variants resolve to pricing', () => {
['claude-4.6-haiku', 'claude-haiku-4-5'],
// Cursor auto proxy
['cursor-auto', 'claude-sonnet-4-5'],
// Codex activity surface (official rate card, observed raw id)
['codex-auto-review', 'gpt-5.5'],
// OpenAI variants Cursor emits
['gpt-5', 'gpt-5'],
['gpt-5-fast', 'gpt-5'],
@ -688,6 +690,29 @@ describe('Cursor model variants resolve to pricing', () => {
})
})
describe('Codex activity ids (#1047)', () => {
it('keeps the activity label instead of collapsing to the underlying model name', () => {
expect(getShortModelName('codex-auto-review')).toBe('Codex Auto Review')
})
it('prices as the exact bundled GPT-5.5 object, not an invented rate', () => {
expect(getModelCosts('codex-auto-review')).toBe(getModelCosts('gpt-5.5'))
const auto = calculateCost('codex-auto-review', 1_000_000, 1_000_000, 0, 0, 0)
const gpt55 = calculateCost('gpt-5.5', 1_000_000, 1_000_000, 0, 0, 0)
expect(auto).toBeGreaterThan(0)
expect(auto).toBe(gpt55)
})
it('does not invent a family or an unobserved sibling id', () => {
expect(getModelCosts('codex-code-review')).toBeNull()
expect(getModelCosts('codex-cloud-task')).toBeNull()
expect(getModelCosts('codex-automation')).toBeNull()
expect(getModelCosts('code-review')).toBeNull()
expect(getModelCosts('auto-review')).toBeNull()
expect(calculateCost('codex-code-review', 1_000_000, 1_000_000, 0, 0, 0)).toBe(0)
})
})
describe('Cursor house model pricing', () => {
const cases: Array<[string, { input: number; output: number; cacheWrite: number; cacheRead: number }]> = [
['composer-2.5', { input: 0.5, output: 2.5, cacheWrite: 0.5, cacheRead: 0.2 }],

View file

@ -1,9 +1,11 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises'
import { mkdtemp, mkdir, writeFile, rm, stat } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import { createCodexProvider } from '../../src/providers/codex.js'
import { clearCodexMemCaches, CODEX_CACHE_VERSION, codexCacheFileName } from '../../src/codex-cache.js'
import { calculateCost } from '../../src/models.js'
import type { ParsedProviderCall } from '../../src/providers/types.js'
let tmpDir: string
@ -1317,3 +1319,77 @@ describe('codex provider - forked session dedupe', () => {
expect(tokens).toBe(300)
})
})
describe('codex auto-review pricing (#1047)', () => {
it('parses auto-review as itself and prices it as GPT-5.5', async () => {
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-auto-review.jsonl', [
sessionMeta({ session_id: 'sess-auto', model: 'codex-auto-review' }),
userMessage('review the PR'),
tokenCount({
timestamp: '2026-04-14T10:01:00Z',
last: { input: 1_000_000, output: 1_000_000 },
total: { total: 2_000_000 },
}),
])
const provider = createCodexProvider(tmpDir)
const calls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser({ path: filePath, project: 'test', provider: 'codex' }, new Set()).parse()) {
calls.push(call)
}
expect(calls).toHaveLength(1)
expect(calls[0]!.model).toBe('codex-auto-review')
expect(calls[0]!.costUSD).toBe(calculateCost('gpt-5.5', 1_000_000, 1_000_000, 0, 0, 0))
})
it('discards a warm v11 versioned $0 exact hit so unchanged rollouts reprice', async () => {
const cacheDir = join(tmpDir, 'cache')
await mkdir(cacheDir, { recursive: true })
const prev = process.env['CODEBURN_CACHE_DIR']
process.env['CODEBURN_CACHE_DIR'] = cacheDir
try {
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-stale-auto.jsonl', [
sessionMeta({ session_id: 'sess-stale-auto', model: 'codex-auto-review' }),
userMessage('review the PR'),
tokenCount({
timestamp: '2026-04-14T10:01:00Z',
last: { input: 1_000_000, output: 1_000_000 },
total: { total: 2_000_000 },
}),
])
const st = await stat(filePath)
// Main's #1075 already owns v11. A colliding v11 $0 file must not be
// treated as current after this PR takes v12.
expect(CODEX_CACHE_VERSION).toBeGreaterThan(11)
await writeFile(join(cacheDir, codexCacheFileName(11)), JSON.stringify({
version: 11,
files: {
[filePath]: {
mtimeMs: st.mtimeMs,
sizeBytes: st.size,
project: 'test',
calls: [{
model: 'codex-auto-review',
costUSD: 0,
inputTokens: 1_000_000,
outputTokens: 1_000_000,
deduplicationKey: 'stale',
}],
},
},
}))
clearCodexMemCaches()
const provider = createCodexProvider(tmpDir)
const calls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser({ path: filePath, project: 'test', provider: 'codex' }, new Set()).parse()) {
calls.push(call)
}
expect(calls).toHaveLength(1)
expect(calls[0]!.costUSD).toBeGreaterThan(0)
expect(calls[0]!.costUSD).toBe(calculateCost('gpt-5.5', 1_000_000, 1_000_000, 0, 0, 0))
} finally {
clearCodexMemCaches()
if (prev === undefined) delete process.env['CODEBURN_CACHE_DIR']
else process.env['CODEBURN_CACHE_DIR'] = prev
}
})
})