fix(codex): price Flex tier turns and the gpt-reserve / gpt-5.3-spark ids (#1642)

* fix(codex): price Flex tier turns and the gpt-reserve / gpt-5.3-spark ids

Flex turns (service_tier "flex") billed at standard rates. They now bill at
the model's published `<rate>_flex` rates, carried as an optional seventh
snapshot slot (only the flex fields were added to the bundled snapshot). A
model without flex rates still bills Flex at standard.

gpt-reserve (Luna Reserve) prices as GPT-5.6 Luna and gpt-5.3-spark as
GPT-5.3 Codex Spark; both showed $0 with an unknown-model warning.

* fix(codex): mark gpt-reserve cost as estimated
This commit is contained in:
Resham Joshi 2026-10-05 21:18:59 +02:00 • committed by GitHub
parent 6d9d233d4b
commit 3afcbd9480
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 241 additions and 27 deletions

View file

@ -55,12 +55,12 @@ const MANUAL_ENTRIES = {
// gpt-5.3-codex == gpt-5.3 (all four rates identical, verified against the
// live model_prices_and_context_window.json). Mirroring that pattern onto
// gpt-5.6 rather than inventing a number: both ids carry the exact gpt-5.6
// row, verbatim INCLUDING the >272k tier block, so the tests/models.test.ts
// codex-equals-base assertion can hold. These are full-row mirrors, not
// row, verbatim INCLUDING the >272k tier block and the Flex slot, so the
// tests/models.test.ts codex-equals-base assertion can hold. These are full-row mirrors, not
// hand-picked rates: drop both entries entirely once LiteLLM ships the
// codex SKUs, rather than editing them in place.
'gpt-5.6-codex': [4e-6, 2e-5, 5e-6, 4e-7, 2, { threshold: 272000, input: 8e-6, output: 3e-5, cacheWrite: 1e-5, cacheRead: 8e-7 }],
'gpt-5.6-codex-max': [4e-6, 2e-5, 5e-6, 4e-7, 2, { threshold: 272000, input: 8e-6, output: 3e-5, cacheWrite: 1e-5, cacheRead: 8e-7 }],
'gpt-5.6-codex': [4e-6, 2e-5, 5e-6, 4e-7, 2, { threshold: 272000, input: 8e-6, output: 3e-5, cacheWrite: 1e-5, cacheRead: 8e-7 }, [2e-6, 1e-5, 2.5e-6, 2e-7, null, { threshold: 272000, input: 4e-6, output: 1.5e-5, cacheWrite: 5e-6, cacheRead: 4e-7 }]],
'gpt-5.6-codex-max': [4e-6, 2e-5, 5e-6, 4e-7, 2, { threshold: 272000, input: 8e-6, output: 3e-5, cacheWrite: 1e-5, cacheRead: 8e-7 }, [2e-6, 1e-5, 2.5e-6, 2e-7, null, { threshold: 272000, input: 4e-6, output: 1.5e-5, cacheWrite: 5e-6, cacheRead: 4e-7 }]],
// LiteLLM dropped `claude-opus-4` upstream (a refresh moves dropped ids to
// the fallback tier), but the Cursor-style alias `claude-4-opus` resolves
// against PRIMARY rows - without this pin the bare id falls to the
@ -141,6 +141,32 @@ function tierOf(entry) {
return { threshold, input: rates.input, output: rates.output, cacheWrite: rates.cacheWrite ?? null, cacheRead: rates.cacheRead ?? null }
}
// OpenAI's Flex processing tier ships as explicit `<rate>_flex` keys
// (`_above_<n>k_tokens_flex` for the long-context tier), carried as slot 6: a
// full tuple of Flex rates, appended only where the row publishes input and
// output flex rates so every other row stays byte-identical. Rates, not a
// ratio (gpt-5.4's flex cache read is $0.13/M, not half of $0.25/M); a bucket
// without a flex rate keeps its standard rate. Mirrored in src/models.ts flexOf.
const FLEX_KEY_SUFFIX = '_flex'
function flexOf(entry, cacheWrite, cacheRead, tier) {
const rate = (key) => {
const v = entry[key + FLEX_KEY_SUFFIX]
return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : null
}
const input = rate('input_cost_per_token')
const output = rate('output_cost_per_token')
if (input === null || output === null) return null
const above = (key) => `${key}_above_${tier.threshold / 1000}k_tokens`
return [input, output, rate('cache_creation_input_token_cost') ?? cacheWrite, rate('cache_read_input_token_cost') ?? cacheRead, null, tier ? {
threshold: tier.threshold,
input: rate(above('input_cost_per_token')) ?? tier.input,
output: rate(above('output_cost_per_token')) ?? tier.output,
cacheWrite: rate(above('cache_creation_input_token_cost')) ?? tier.cacheWrite,
cacheRead: rate(above('cache_read_input_token_cost')) ?? tier.cacheRead,
} : null]
}
function toVal(entry) {
const inp = entry.input_cost_per_token
const out = entry.output_cost_per_token
@ -151,7 +177,9 @@ function toVal(entry) {
if (tier && priorityFast !== null && !Object.keys(entry).some((k) => k.endsWith(`_above_${tier.threshold / 1000}k_tokens${PRIORITY_KEY_SUFFIX}`))) {
tier.fast = 1
}
return [inp, out, entry.cache_creation_input_token_cost ?? null, entry.cache_read_input_token_cost ?? null, explicitFast ?? priorityFast, tier]
const val = [inp, out, entry.cache_creation_input_token_cost ?? null, entry.cache_read_input_token_cost ?? null, explicitFast ?? priorityFast, tier]
const flex = flexOf(entry, val[2], val[3], tier)
return flex ? [...val, flex] : val
}
// Pass 1: direct entries (no prefix) get priority

View file

@ -63,7 +63,10 @@ import { isWslUncPath } from './wsl.js'
// so v18 entries carry the standard-rate cost and must be re-derived.
// v20: `codex-auto-review` prices by date (gpt-5.4 before 30 Jul 2026, GPT-5.6
// Luna from then on) instead of gpt-5.5. Stored costUSD is the old price.
export const CODEX_CACHE_VERSION = 20
// v21: service_tier "flex" bills at the published Flex rates (it billed at
// standard), and `gpt-reserve` / `gpt-5.3-spark` price as GPT-5.6 Luna /
// GPT-5.3 Codex Spark instead of $0. Stored costUSD and speed are the old ones.
export const CODEX_CACHE_VERSION = 21
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

@ -269,7 +269,11 @@ import type { DateRange, ProjectSummary } from './types.js'
// Luna from then on (OpenAI's auto-review move), not gpt-5.5, so settled days
// over-price it. Only cost falls; call counts are unchanged, so no
// PENDING_REDERIVE_PROVIDER_VERSIONS entry is needed.
export const DAILY_CACHE_VERSION = 54
// v55: Codex service_tier "flex" bills at the published Flex rates instead of
// standard, and `gpt-reserve` / `gpt-5.3-spark` price as GPT-5.6 Luna / GPT-5.3
// Codex Spark instead of $0. Only cost moves; call counts are unchanged, so no
// PENDING_REDERIVE_PROVIDER_VERSIONS entry is needed.
export const DAILY_CACHE_VERSION = 55
const MIN_SUPPORTED_VERSION = 28
/// Providers whose per-day CALL COUNT means something different at

File diff suppressed because one or more lines are too long

View file

@ -28,6 +28,9 @@ export type ModelCosts = {
/// rate; a slot the source omitted keeps the base. Optional: absent on
/// models without a published tier and on tuples predating the extension.
longContextTier?: LongContextTier
/// The Flex service tier's rates (LiteLLM `<rate>_flex`), priced instead of
/// these when a call runs under Flex. Absent: Flex bills at standard.
flex?: ModelCosts
}
/** Long-context pricing tier, e.g. OpenAI's above-272k or Anthropic's
@ -78,13 +81,14 @@ type LiteLLMEntry = {
provider_specific_entry?: { fast?: number }
}
// [input, output, cacheWrite, cacheRead, fastMultiplier, longContextTier?].
// [input, output, cacheWrite, cacheRead, fastMultiplier, longContextTier?, flex?].
// The trailing fast multiplier is carried straight from LiteLLM's
// provider_specific_entry.fast so new models pick it up automatically — no
// hand-maintained per-model table. The optional sixth slot carries the
// vendor's long-context tier; older bundles without it parse unchanged.
// vendor's long-context tier; older bundles without it parse unchanged. The
// optional seventh is the Flex tier's own tuple, present only where published.
type SnapshotTier = { threshold: number, input: number, output: number, cacheWrite: number | null, cacheRead: number | null, fast?: number }
type SnapshotEntry = [number, number, number | null, number | null, (number | null)?, (SnapshotTier | null)?]
type SnapshotEntry = [number, number, number | null, number | null, (number | null)?, (SnapshotTier | null)?, (SnapshotEntry | null)?]
const LITELLM_URL = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json'
const CACHE_TTL_MS = 24 * 60 * 60 * 1000
@ -100,7 +104,8 @@ const CACHE_TTL_MS = 24 * 60 * 60 * 1000
// 6: fastMultiplier is now derived from LiteLLM's `<rate>_priority` keys when the
// source publishes no `provider_specific_entry.fast` (#1616), so a cached costs
// object can carry a multiplier the pre-fix fetch left at 1.
export const CACHE_SCHEMA_VERSION = 6
// 7: ModelCosts carries the Flex tier's rates (`flex`), read from `<rate>_flex`.
export const CACHE_SCHEMA_VERSION = 7
const WEB_SEARCH_COST = 0.01
const ONE_HOUR_CACHE_WRITE_MULTIPLIER_FROM_FIVE_MINUTE_RATE = 1.6
@ -128,6 +133,7 @@ function buildCosts(
cacheRead: number | null | undefined,
fast: number | null | undefined,
tier?: SnapshotTier | null,
flex?: SnapshotEntry | null,
): ModelCosts {
return {
inputCostPerToken: input,
@ -145,6 +151,7 @@ function buildCosts(
...(tier.cacheRead !== null ? { cacheReadCostPerToken: tier.cacheRead } : {}),
...(tier.fast !== undefined ? { fastMultiplier: tier.fast } : {}),
} } : {}),
...(flex ? { flex: tupleToCosts(flex) } : {}),
}
}
// For grok-4.6, prompt tokens mean input tokens plus cached input tokens for a
@ -198,8 +205,8 @@ export function tieredCostsFor(model: string, baseCosts: ModelCosts, promptToken
function tupleToCosts(raw: SnapshotEntry): ModelCosts {
const [input, output, cacheWrite, cacheRead, fast, tier] = raw
return buildCosts(input, output, cacheWrite, cacheRead, fast, tier)
const [input, output, cacheWrite, cacheRead, fast, tier, flex] = raw
return buildCosts(input, output, cacheWrite, cacheRead, fast, tier, flex)
}
function applyBuiltinPriceOverrides(pricing: Map<string, ModelCosts>): Map<string, ModelCosts> {
@ -361,6 +368,34 @@ function tierOfLiteLLMEntry(entry: LiteLLMEntry): SnapshotTier | null {
}
}
// OpenAI's Flex processing tier (Codex service_tier "flex") ships as explicit
// `<rate>_flex` keys, `_above_<n>k_tokens_flex` for the long-context tier.
// Read as rates, not one ratio: gpt-5.4's flex cache read is $0.13/M, not half
// of $0.25/M, so a priority-style agreement check would drop it. Without both
// input and output flex rates the row has no Flex price and Flex bills at
// standard; any other bucket without a flex rate keeps its standard rate.
// Mirrored in scripts/bundle-litellm.mjs flexOf.
const FLEX_KEY_SUFFIX = '_flex'
function flexOf(entry: LiteLLMEntry, cacheWrite: number | null, cacheRead: number | null, tier: SnapshotTier | null): SnapshotEntry | null {
const record = entry as Record<string, unknown>
const rate = (key: string) => {
const value = record[key + FLEX_KEY_SUFFIX]
return typeof value === 'number' ? safePerTokenRate(value) : null
}
const input = rate('input_cost_per_token')
const output = rate('output_cost_per_token')
if (input === null || output === null) return null
const above = (key: string) => `${key}_above_${tier!.threshold / 1000}k_tokens`
return [input, output, rate('cache_creation_input_token_cost') ?? cacheWrite, rate('cache_read_input_token_cost') ?? cacheRead, null, tier ? {
threshold: tier.threshold,
input: rate(above('input_cost_per_token')) ?? tier.input,
output: rate(above('output_cost_per_token')) ?? tier.output,
cacheWrite: rate(above('cache_creation_input_token_cost')) ?? tier.cacheWrite,
cacheRead: rate(above('cache_read_input_token_cost')) ?? tier.cacheRead,
} : null]
}
export function parseLiteLLMEntry(entry: LiteLLMEntry): ModelCosts | null {
// The live LiteLLM map is remote JSON; a null (or non-object) value for a
// model would make the field reads below throw and abort the whole pricing
@ -375,13 +410,16 @@ export function parseLiteLLMEntry(entry: LiteLLMEntry): ModelCosts | null {
if (tier && priorityFast !== null && !Object.keys(entry).some(k => k.endsWith(`_above_${tier.threshold / 1000}k_tokens${PRIORITY_KEY_SUFFIX}`))) {
tier.fast = 1
}
const cacheWrite = safePerTokenRate(entry.cache_creation_input_token_cost)
const cacheRead = safePerTokenRate(entry.cache_read_input_token_cost)
return buildCosts(
inputCost,
outputCost,
safePerTokenRate(entry.cache_creation_input_token_cost),
safePerTokenRate(entry.cache_read_input_token_cost),
cacheWrite,
cacheRead,
explicitFast ?? priorityFast,
tier,
flexOf(entry, cacheWrite, cacheRead, tier),
)
}
@ -554,6 +592,15 @@ const BUILTIN_ALIASES: Record<string, string> = {
// A rollout that records the real model (API-key auth writes gpt-5.6-luna)
// is priced as recorded. Display stays on autoModelNames.
'codex-auto-review': 'gpt-5.6-luna',
// Luna Reserve: the quota Codex falls back to once ordinary usage runs out
// (openai/codex#42372, LUNA_RESERVE_MODEL in codex-rs/tui/src/model_catalog.rs).
// The backend picks the real model and rollouts don't record it. GPT-5.6 Luna
// was the client fallback until 22 Sep 2026 (then GPT-6 Luna, half the
// price), so this is an upper-bound estimate and the codex parser marks it so.
'gpt-reserve': 'gpt-5.6-luna',
// Short spelling of the only 5.3 Spark model (openai/codex uses it as a
// config model id in codex-rs/app-server/tests/suite/v2/config_rpc.rs).
'gpt-5.3-spark': 'gpt-5.3-codex-spark',
'grok-build': 'grok-build-0.1',
// Grok Bot's desktop app serves opaque `sand-*` aliases and records no model
// id at all, so there is nothing truthful to price it by. It is xAI's own
@ -791,7 +838,7 @@ export function calculateLocalModelSavings(
cacheCreationTokens: number,
cacheReadTokens: number,
webSearchRequests: number,
speed: 'standard' | 'fast' = 'standard',
speed: 'standard' | 'fast' | 'flex' = 'standard',
oneHourCacheCreationTokens = 0,
): { savingsUSD: number; baselineModel: string } | null {
const baseline = getLocalSavingsBaseline(rawModel)
@ -1400,7 +1447,7 @@ export function calculateCost(
cacheCreationTokens: number,
cacheReadTokens: number,
webSearchRequests: number,
speed: 'standard' | 'fast' = 'standard',
speed: 'standard' | 'fast' | 'flex' = 'standard',
oneHourCacheCreationTokens = 0,
provider?: string,
): number {
@ -1425,7 +1472,7 @@ export function calculateCost(
const safeCacheCreation = Math.max(safe(cacheCreationTokens), safeOneHourCacheCreation)
const safeFiveMinuteCacheCreation = Math.max(0, safeCacheCreation - safeOneHourCacheCreation)
const promptTokens = safe(inputTokens) + safe(cacheReadTokens)
const tieredCosts = tieredCostsFor(model, costs, promptTokens, provider)
const tieredCosts = tieredCostsFor(model, speed === 'flex' ? costs.flex ?? costs : costs, promptTokens, provider)
const multiplier = speed === 'fast' ? tieredCosts.fastMultiplier : 1
// Clamp negative inputs to 0. A corrupt JSONL that emits a negative token
@ -1467,6 +1514,7 @@ const autoModelNames: Record<string, string> = {
'qwen-auto': 'Qwen (auto)',
'kimi-auto': 'Kimi (auto)',
'codex-auto-review': 'Codex Auto Review',
'gpt-reserve': 'Luna Reserve',
}
const SHORT_NAMES: Record<string, string> = {

View file

@ -458,9 +458,11 @@ function serviceTierFromThreadSettings(payload: CodexEntry['payload']): string |
// The billable speed a service tier maps to (#1616). `"priority"` is Codex's
// Fast speed setting and bills through the priority tier; `"fast"` is accepted
// too in case a build writes the speed name where the tier name belongs.
// `"flex"` bills at the model's published Flex rates, or standard without them.
// Everything else - `"default"`, an unrecognised value, no tier at all - is
// standard, never a guess.
function speedForServiceTier(tier: string): 'standard' | 'fast' {
function speedForServiceTier(tier: string): 'standard' | 'fast' | 'flex' {
if (tier === 'flex') return 'flex'
return tier === 'priority' || tier === 'fast' ? 'fast' : 'standard'
}
@ -967,6 +969,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>, capture?: {
...(pendingLocAdded ? { locAdded: pendingLocAdded } : {}),
...(pendingLocRemoved ? { locRemoved: pendingLocRemoved } : {}),
...(pendingEditFailed ? { editFailed: pendingEditFailed } : {}),
...(model.toLowerCase() === 'gpt-reserve' ? { costIsEstimated: true } : {}),
})
taskGeneratedTokens += billableOutputTokens('codex', outputTokens, reasoningTokens)

View file

@ -63,7 +63,7 @@ export type ParsedProviderCall = {
// breakdown; optional since most providers don't expose it.
skills?: string[]
timestamp: string
speed: 'standard' | 'fast'
speed: 'standard' | 'fast' | 'flex'
deduplicationKey: string
// Lines added/removed by this call's edits, counted from the provider's diff
// records (Codex: `patch_apply_end.changes[*].unified_diff`). Numbers only;

View file

@ -33,7 +33,7 @@ export type CachedCall = {
/// True when `costUSD` (or the tokens it is priced from) is estimated rather
/// than metered. Persisted so the estimated-cost marker survives the cache.
isEstimated?: boolean
speed: 'standard' | 'fast'
speed: 'standard' | 'fast' | 'flex'
timestamp: string
tools: string[]
bashCommands: string[]
@ -416,8 +416,10 @@ export const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
// multiplier alone cannot reach them - they must re-parse to re-record it.
// codex-auto-review-date-v1: the cached cache-write split follows the
// auto-review model by date (gpt-5.4 before 30 Jul 2026, Luna after).
// codex-flex-reserve-v1: flex turns record speed 'flex' (cached calls hold
// 'standard'), and `gpt-reserve` now splits cache writes like GPT-5.6 Luna.
// Compose every suffix so cached sessions receive all accounting fixes.
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-fork-replay-burst-v1-codex-token-usage-record-v1-codex-priority-tier-v1-codex-auto-review-date-v1',
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-fork-replay-burst-v1-codex-token-usage-record-v1-codex-priority-tier-v1-codex-auto-review-date-v1-codex-flex-reserve-v1',
// reported-cost-v1: cached Crush calls stored costUSD: undefined and must
// re-parse to keep the recorded session cost.
crush: 'reported-cost-v1',
@ -1141,7 +1143,7 @@ function validateCall(c: unknown): c is CachedCall {
&& typeof o['model'] === 'string'
&& typeof o['deduplicationKey'] === 'string'
&& typeof o['timestamp'] === 'string'
&& (o['speed'] === 'standard' || o['speed'] === 'fast')
&& (o['speed'] === 'standard' || o['speed'] === 'fast' || o['speed'] === 'flex')
&& isOptionalNum(o['costUSD'])
&& isOptionalNum(o['fallbackCostUSD'])
&& isOptionalBool(o['isEstimated'])

View file

@ -36,7 +36,7 @@ export type ApiUsage = {
web_search_requests?: number
web_fetch_requests?: number
}
speed?: 'standard' | 'fast'
speed?: 'standard' | 'fast' | 'flex'
// Claude Code advisor tool (/advisor): per-turn sub-usage records. A record
// with type 'advisor_message' carries the advisor model's own tokens and is
// NOT included in the top-level totals above; type 'message' records mirror
@ -59,7 +59,7 @@ export type ApiUsageIteration = {
web_search_requests?: number
web_fetch_requests?: number
}
speed?: 'standard' | 'fast'
speed?: 'standard' | 'fast' | 'flex'
}
export type AssistantMessageContent = {
@ -119,7 +119,7 @@ export type ParsedApiCall = {
subagentTypes: string[]
hasAgentSpawn: boolean
hasPlanMode: boolean
speed: 'standard' | 'fast'
speed: 'standard' | 'fast' | 'flex'
timestamp: string
bashCommands: string[]
deduplicationKey: string

View file

@ -92,3 +92,32 @@ it('prices a pre-30-Jul auto-review as gpt-5.4 on a cold parse and a cache-rehyd
clearSessionCache()
expect(autoReviewCost(await parseAllSessions(undefined, 'codex'))).toBeCloseTo(expected, 12)
})
it('prices a flex turn at the flex rates on a cold parse and a cache-rehydrated read', async () => {
const sessionDir = join(CODEX_HOME, 'sessions', '2026', '09', '28')
await mkdir(sessionDir, { recursive: true })
await mkdir(CACHE_DIR, { recursive: true })
const usage = { input_tokens: 1000, cached_input_tokens: 200, output_tokens: 1000, reasoning_output_tokens: 400, total_tokens: 2000 }
await writeFile(join(sessionDir, 'rollout-flex.jsonl'), [
JSON.stringify({ type: 'session_meta', timestamp: '2026-09-28T10:00:00Z', payload: { session_id: 'sflex', model: 'gpt-5.4', cwd: '/Users/test/proj', originator: 'codex_cli_rs' } }),
JSON.stringify({ type: 'event_msg', timestamp: '2026-09-28T10:00:01Z', payload: { type: 'thread_settings_applied', thread_settings: { model: 'gpt-5.4', service_tier: 'flex' } } }),
JSON.stringify({ type: 'response_item', timestamp: '2026-09-28T10:00:10Z', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'flex' }] } }),
JSON.stringify({ type: 'event_msg', timestamp: '2026-09-28T10:01:00Z', payload: { type: 'token_count', info: { model: 'gpt-5.4', last_token_usage: usage, total_token_usage: usage } } }),
].join('\n') + '\n')
// gpt-5.4 flex: input 1.25e-6, output 7.5e-6, cacheRead 1.3e-7.
const expected = 800 * 1.25e-6 + 200 * 1.3e-7 + 1000 * 7.5e-6
const { clearSessionCache, parseAllSessions } = await import('../src/parser.js')
const flexCalls = (projects: Awaited<ReturnType<typeof parseAllSessions>>) => projects
.flatMap(p => p.sessions).flatMap(s => s.turns).flatMap(t => t.assistantCalls)
.filter(c => c.speed === 'flex')
clearSessionCache()
const cold = flexCalls(await parseAllSessions(undefined, 'codex'))
clearSessionCache()
const warm = flexCalls(await parseAllSessions(undefined, 'codex'))
expect(cold).toHaveLength(1)
expect(warm).toHaveLength(1)
expect(cold[0]!.costUSD).toBeCloseTo(expected, 12)
expect(warm[0]!.costUSD).toBeCloseTo(expected, 12)
})

View file

@ -1226,6 +1226,63 @@ describe('Codex activity ids (#1047)', () => {
})
})
describe('Codex model aliases without their own rate', () => {
it('prices gpt-reserve (Luna Reserve) as the bundled GPT-5.6 Luna object and keeps its own label', () => {
expect(getModelCosts('gpt-reserve')).toBe(getModelCosts('gpt-5.6-luna'))
expect(calculateCost('gpt-reserve', 1_000_000, 1_000_000, 0, 0, 0)).toBeCloseTo(0.2 + 1.2, 10)
expect(getShortModelName('gpt-reserve')).toBe('Luna Reserve')
})
it('prices gpt-5.3-spark as GPT-5.3 Codex Spark', () => {
const spark = getModelCosts('gpt-5.3-codex-spark')
expect(spark).not.toBeNull()
expect(getModelCosts('gpt-5.3-spark')).toBe(spark)
expect(calculateCost('gpt-5.3-spark', 1_000_000, 1_000_000, 0, 0, 0)).toBeCloseTo(1.75 + 14, 10)
expect(getShortModelName('gpt-5.3-spark')).toBe('GPT-5.3 Codex Spark')
})
})
describe('Flex service tier pricing', () => {
it('prices a flex call at the model\'s published flex rates (gpt-5.4: cached input $0.13/M, not half)', () => {
const flex = 800 * 1.25e-6 + 200 * 1.3e-7 + 500 * 7.5e-6
expect(calculateCost('gpt-5.4', 800, 500, 0, 200, 0, 'flex', 0, 'codex')).toBeCloseTo(flex, 15)
expect(calculateCost('gpt-5.4', 800, 500, 0, 200, 0, 'standard', 0, 'codex')).toBeCloseTo(800 * 2.5e-6 + 200 * 2.5e-7 + 500 * 15e-6, 15)
})
it('applies the flex long-context tier above 272k prompt tokens', () => {
expect(calculateCost('gpt-5.4', 300_000, 1000, 0, 0, 0, 'flex', 0, 'codex')).toBeCloseTo(300_000 * 2.5e-6 + 1000 * 11.25e-6, 12)
})
it('falls back to standard rates for a model with no flex rates', () => {
expect(getModelCosts('gpt-5.3-codex')?.flex).toBeUndefined()
expect(calculateCost('gpt-5.3-codex', 800, 500, 0, 200, 0, 'flex', 0, 'codex'))
.toBe(calculateCost('gpt-5.3-codex', 800, 500, 0, 200, 0, 'standard', 0, 'codex'))
})
it('leaves the priority tier unchanged', () => {
const standard = calculateCost('gpt-5.4', 800, 500, 0, 200, 0, 'standard', 0, 'codex')
expect(calculateCost('gpt-5.4', 800, 500, 0, 200, 0, 'fast', 0, 'codex')).toBeCloseTo(standard * 2, 15)
})
it('reads flex rates off a live LiteLLM row; buckets without one keep their standard rate', () => {
const costs = parseLiteLLMEntry({
input_cost_per_token: 2e-6,
output_cost_per_token: 1e-5,
cache_read_input_token_cost: 2e-7,
input_cost_per_token_above_272k_tokens: 4e-6,
output_cost_per_token_above_272k_tokens: 1.5e-5,
input_cost_per_token_flex: 1e-6,
output_cost_per_token_flex: 5e-6,
input_cost_per_token_above_272k_tokens_flex: 2e-6,
} as never)!
expect(costs.flex?.inputCostPerToken).toBe(1e-6)
expect(costs.flex?.outputCostPerToken).toBe(5e-6)
expect(costs.flex?.cacheReadCostPerToken).toBe(2e-7)
expect(costs.flex?.longContextTier).toMatchObject({ thresholdTokens: 272_000, inputCostPerToken: 2e-6, outputCostPerToken: 1.5e-5 })
expect(parseLiteLLMEntry({ input_cost_per_token: 2e-6, output_cost_per_token: 1e-5, input_cost_per_token_flex: 1e-6 } as never)!.flex).toBeUndefined()
})
})
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

@ -1872,6 +1872,46 @@ describe('codex provider - priority service tier (#1616)', () => {
expect(calls[0]!.speed).toBe('standard')
})
it('prices service_tier "flex" turns at the bundled flex rates, then priority at 2x', async () => {
const calls = await parseCalls([
sessionMeta({ session_id: 'sess-flex', model: 'gpt-5.4', timestamp: '2026-09-28T10:00:00Z' }),
threadSettings('flex', '2026-09-28T10:00:01Z'),
userMessage('flex turn', '2026-09-28T10:01:00Z'),
tokenCount({ timestamp: '2026-09-28T10:01:30Z', last: { input: 1000, cached: 200, output: 500 }, total: { input: 1000, cached: 200, output: 500, total: 1700 } }),
threadSettings('priority', '2026-09-28T10:02:00Z'),
userMessage('fast turn', '2026-09-28T10:03:00Z'),
tokenCount({ timestamp: '2026-09-28T10:03:30Z', last: { input: 1000, cached: 200, output: 500 }, total: { input: 2000, cached: 400, output: 1000, total: 3400 } }),
])
expect(calls.map(call => call.speed)).toEqual(['flex', 'fast'])
// gpt-5.4 flex: $1.25 in, $0.13 cached, $7.50 out per 1M.
expect(calls[0]!.costUSD).toBeCloseTo(800 * 1.25e-6 + 200 * 1.3e-7 + 500 * 7.5e-6, 12)
expect(calls[1]!.costUSD).toBeCloseTo((800 * GPT54.input + 200 * GPT54.cacheRead + 500 * GPT54.output) * 2, 12)
})
it('prices a flex turn at standard when the model publishes no flex rates', async () => {
const calls = await parseCalls([
sessionMeta({ session_id: 'sess-flex-none', model: 'gpt-5.3-codex', timestamp: '2026-09-28T10:00:00Z' }),
threadSettings('flex', '2026-09-28T10:00:01Z'),
userMessage('flex turn', '2026-09-28T10:01:00Z'),
tokenCount({ timestamp: '2026-09-28T10:01:30Z', last: { input: 1000, cached: 200, output: 500 }, total: { input: 1000, cached: 200, output: 500, total: 1700 } }),
])
expect(calls).toHaveLength(1)
expect(calls[0]!.speed).toBe('flex')
expect(calls[0]!.costUSD).toBeCloseTo(800 * 1.75e-6 + 200 * 1.75e-7 + 500 * 14e-6, 12)
})
it('marks gpt-reserve turns as estimated', async () => {
const calls = await parseCalls([
sessionMeta({ session_id: 'sess-reserve', model: 'gpt-reserve', timestamp: '2026-09-28T10:00:00Z' }),
userMessage('reserve turn', '2026-09-28T10:01:00Z'),
tokenCount({ timestamp: '2026-09-28T10:01:30Z', last: { input: 1000, cached: 200, output: 500 }, total: { input: 1000, cached: 200, output: 500, total: 1700 } }),
])
expect(calls).toHaveLength(1)
expect(calls[0]!.model).toBe('gpt-reserve')
expect(calls[0]!.costIsEstimated).toBe(true)
expect(calls[0]!.costUSD).toBeGreaterThan(0)
})
it('reads service_tier out of an oversized thread_settings record', async () => {
// The compact head decoder handles rollout lines past the buffer
// threshold; service_tier sits early in the payload, so it must survive