mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-21 05:23:19 +00:00
Price Codex Fast (priority tier) turns at priority rates (#1628)
Some checks are pending
CI / semgrep (push) Waiting to run
Tests / test (22.13.0) (push) Waiting to run
Tests / test (22) (push) Waiting to run
Tests / test (24) (push) Waiting to run
Tests / test (26) (push) Waiting to run
Tests / test-platforms (macos-latest) (push) Waiting to run
Tests / test-platforms (windows-latest) (push) Waiting to run
Tests / desktop (push) Waiting to run
Some checks are pending
CI / semgrep (push) Waiting to run
Tests / test (22.13.0) (push) Waiting to run
Tests / test (22) (push) Waiting to run
Tests / test (24) (push) Waiting to run
Tests / test (26) (push) Waiting to run
Tests / test-platforms (macos-latest) (push) Waiting to run
Tests / test-platforms (windows-latest) (push) Waiting to run
Tests / desktop (push) Waiting to run
* fix(codex): price Fast (priority tier) turns at priority rates Codex bills turns run under the Fast speed setting through the priority service tier, which the rollout files record per thread in thread_settings_applied events. Track that tier through the parse (and across append resumes and fork replays), mark the affected turns as fast, and derive a fast multiplier from the LiteLLM priority rate keys so those turns are priced at the priority rates instead of the standard ones. Cache versions are bumped so already-cached Codex results are re-priced. * snapshot: priority slots only, no upstream drift --------- Co-authored-by: Resham Joshi <65915470+iamtoruk@users.noreply.github.com>
This commit is contained in:
parent
47087b0d53
commit
ee23e94726
12 changed files with 876 additions and 19 deletions
|
|
@ -59,8 +59,8 @@ const MANUAL_ENTRIES = {
|
|||
// 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, null, { 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, null, { 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 }],
|
||||
'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 }],
|
||||
// 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
|
||||
|
|
@ -84,6 +84,39 @@ const entries = Object.entries(data).filter(([k]) => k !== 'sample_spec')
|
|||
// threshold field (#1076). Mirrored in src/models.ts parseLiteLLMEntry.
|
||||
const TIER_KEY_RE = /^(input_cost_per_token|output_cost_per_token|cache_read_input_token_cost|cache_creation_input_token_cost)_above_(\d+)k_tokens$/
|
||||
|
||||
// OpenAI's priority processing tier ships as explicit `<rate>_priority` keys
|
||||
// beside the standard ones (and `_above_<n>k_tokens_priority` for gpt-5.6's
|
||||
// long-context tier). Codex's Fast speed setting bills through it (#1616), so
|
||||
// slot 5 falls back to that ratio when the row carries no
|
||||
// `provider_specific_entry.fast`. Derived only where every bucket the row
|
||||
// prices agrees on one ratio — models without priority keys, or rows whose
|
||||
// ratios disagree (azure/gpt-5.5: 2.5x base, 2x above 272k), stay null (1x).
|
||||
// A tier without its own priority keys (gpt-5.4, gpt-5.5) gets `fast: 1` so it
|
||||
// stays at standard tier rates: OpenAI quotes no Fast long-context price there.
|
||||
// Never a hand-picked number. Mirrored in src/models.ts parseLiteLLMEntry.
|
||||
const PRIORITY_KEY_SUFFIX = '_priority'
|
||||
const MAX_DERIVED_FAST_MULTIPLIER = 100
|
||||
|
||||
function priorityMultiplierOf(entry) {
|
||||
const ratios = []
|
||||
let inputRatio
|
||||
let outputRatio
|
||||
for (const [key, value] of Object.entries(entry)) {
|
||||
if (!key.endsWith(PRIORITY_KEY_SUFFIX)) continue
|
||||
const base = entry[key.slice(0, -PRIORITY_KEY_SUFFIX.length)]
|
||||
if (typeof value !== 'number' || typeof base !== 'number') continue
|
||||
if (!Number.isFinite(value) || !Number.isFinite(base) || value <= 0 || base <= 0) continue
|
||||
const ratio = value / base
|
||||
if (key === 'input_cost_per_token_priority') inputRatio = ratio
|
||||
else if (key === 'output_cost_per_token_priority') outputRatio = ratio
|
||||
ratios.push(ratio)
|
||||
}
|
||||
if (inputRatio === undefined || outputRatio === undefined) return null
|
||||
const agreed = ratios.every((r) => Math.abs(r - inputRatio) <= 1e-9 * Math.max(r, inputRatio))
|
||||
// Rounded to 4 decimals so division noise (1.7999999999999998) never ships.
|
||||
return agreed && inputRatio <= MAX_DERIVED_FAST_MULTIPLIER ? Math.round(inputRatio * 1e4) / 1e4 : null
|
||||
}
|
||||
|
||||
function tierOf(entry) {
|
||||
// Rates are read ONLY from the largest threshold a model carries, so a
|
||||
// hypothetical entry with two tiers can never mix a smaller tier's rates
|
||||
|
|
@ -112,7 +145,13 @@ function toVal(entry) {
|
|||
const inp = entry.input_cost_per_token
|
||||
const out = entry.output_cost_per_token
|
||||
if (inp == null || out == null) return null
|
||||
return [inp, out, entry.cache_creation_input_token_cost ?? null, entry.cache_read_input_token_cost ?? null, entry.provider_specific_entry?.fast ?? null, tierOf(entry)]
|
||||
const explicitFast = entry.provider_specific_entry?.fast
|
||||
const priorityFast = explicitFast == null ? priorityMultiplierOf(entry) : null
|
||||
const tier = tierOf(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]
|
||||
}
|
||||
|
||||
// Pass 1: direct entries (no prefix) get priority
|
||||
|
|
|
|||
|
|
@ -57,7 +57,11 @@ import { isWslUncPath } from './wsl.js'
|
|||
// v18: read response-level token_usage_record and ignore later token_count
|
||||
// twins. v17 entries miss interrupted/compaction usage and can include counts
|
||||
// now suppressed after the source handover, so they must reparse.
|
||||
export const CODEX_CACHE_VERSION = 18
|
||||
// v19: codex priority-tier pricing (#1616) - turns run under Codex's Fast
|
||||
// speed setting (thread_settings_applied service_tier "priority") now bill at
|
||||
// the priority rates. This file stores each call's costUSD and speed verbatim,
|
||||
// so v18 entries carry the standard-rate cost and must be re-derived.
|
||||
export const CODEX_CACHE_VERSION = 19
|
||||
export const CODEX_LEGACY_CACHE_FILE = 'codex-results.json'
|
||||
export function codexCacheFileName(version = CODEX_CACHE_VERSION): string {
|
||||
return `codex-results.v${version}.json`
|
||||
|
|
|
|||
|
|
@ -259,7 +259,13 @@ import type { DateRange, ProjectSummary } from './types.js'
|
|||
// v52: DSH session format v4 (dsh 0.2.0-rc.2) is read; days finalized while
|
||||
// those sessions were skipped re-derive. Calls only rise, so no
|
||||
// PENDING_REDERIVE_PROVIDER_VERSIONS entry is needed.
|
||||
export const DAILY_CACHE_VERSION = 52
|
||||
// v53: #1616 Codex priority-tier pricing. Turns run under Codex's Fast speed
|
||||
// setting bill at the published priority rates (2x on most gpt-5/6 rows, 2.5x
|
||||
// on gpt-5.5; per-row, derived from LiteLLM's priority keys), so settled days
|
||||
// under-price them; the bump re-derives surviving days off the warm session
|
||||
// cache. MIN_SUPPORTED_VERSION stays at 28 (#1478's convention: a version bump
|
||||
// alone re-derives warm caches, so raising the floor buys nothing).
|
||||
export const DAILY_CACHE_VERSION = 53
|
||||
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
|
|
@ -39,6 +39,8 @@ export type LongContextTier = {
|
|||
outputCostPerToken: number
|
||||
cacheWriteCostPerToken?: number
|
||||
cacheReadCostPerToken?: number
|
||||
/// Replaces the base fastMultiplier above the threshold; absent inherits it.
|
||||
fastMultiplier?: number
|
||||
}
|
||||
|
||||
/// Providers whose reported `reasoningTokens` are a SUBSET of `outputTokens`
|
||||
|
|
@ -81,7 +83,7 @@ type LiteLLMEntry = {
|
|||
// 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.
|
||||
type SnapshotTier = { threshold: number, input: number, output: number, cacheWrite: number | null, cacheRead: number | null }
|
||||
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)?]
|
||||
|
||||
const LITELLM_URL = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json'
|
||||
|
|
@ -95,7 +97,10 @@ const CACHE_TTL_MS = 24 * 60 * 60 * 1000
|
|||
// the on-disk LiteLLM cache, not just the on-disk cache itself.
|
||||
// 4: calculateCost bills an implicit cache-write rate as input for non-Anthropic models.
|
||||
// 5: longContextTier rides ModelCosts, so a cached costs object is tier-aware (#1076).
|
||||
export const CACHE_SCHEMA_VERSION = 5
|
||||
// 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
|
||||
const WEB_SEARCH_COST = 0.01
|
||||
const ONE_HOUR_CACHE_WRITE_MULTIPLIER_FROM_FIVE_MINUTE_RATE = 1.6
|
||||
|
||||
|
|
@ -138,6 +143,7 @@ function buildCosts(
|
|||
outputCostPerToken: tier.output,
|
||||
...(tier.cacheWrite !== null ? { cacheWriteCostPerToken: tier.cacheWrite } : {}),
|
||||
...(tier.cacheRead !== null ? { cacheReadCostPerToken: tier.cacheRead } : {}),
|
||||
...(tier.fast !== undefined ? { fastMultiplier: tier.fast } : {}),
|
||||
} } : {}),
|
||||
}
|
||||
}
|
||||
|
|
@ -184,6 +190,7 @@ export function tieredCostsFor(model: string, baseCosts: ModelCosts, promptToken
|
|||
outputCostPerToken: tier.outputCostPerToken,
|
||||
...(tier.cacheWriteCostPerToken !== undefined ? { cacheWriteCostPerToken: tier.cacheWriteCostPerToken } : {}),
|
||||
...(tier.cacheReadCostPerToken !== undefined ? { cacheReadCostPerToken: tier.cacheReadCostPerToken } : {}),
|
||||
...(tier.fastMultiplier !== undefined ? { fastMultiplier: tier.fastMultiplier } : {}),
|
||||
}
|
||||
}
|
||||
return baseCosts
|
||||
|
|
@ -282,6 +289,52 @@ function safePerTokenRate(n: number | undefined): number | null {
|
|||
// because the bundler is a standalone .mjs script.
|
||||
const TIER_KEY_RE = /^(input_cost_per_token|output_cost_per_token|cache_read_input_token_cost|cache_creation_input_token_cost)_above_(\d+)k_tokens$/
|
||||
|
||||
// OpenAI bills its priority processing tier through explicit `<rate>_priority`
|
||||
// keys sat beside the standard ones (input/output/cache-read/cache-write, plus
|
||||
// the `_above_<n>k_tokens_priority` variants for gpt-5.6's long-context tier).
|
||||
// Codex's Fast speed setting runs on that tier (#1616), so those keys are where
|
||||
// its multiplier comes from — LiteLLM publishes none as a
|
||||
// `provider_specific_entry.fast` for OpenAI models. Derived, never invented:
|
||||
// only a ratio the source publishes for EVERY bucket it prices is used, so a
|
||||
// model with no priority keys (gpt-5-codex, gpt-5.1-codex) or one whose ratios
|
||||
// disagree between buckets (azure/gpt-5.5: 2.5x base, 2x above 272k) stays at
|
||||
// 1x. Where the tier publishes priority rates they join the same agreement
|
||||
// check, so the one multiplier prices both regimes (gpt-5.6). Where it publishes
|
||||
// none (gpt-5.4, gpt-5.5) OpenAI quotes no Fast long-context price, so the tier
|
||||
// carries fast 1 and stays at its standard rates rather than a guessed product.
|
||||
// `provider_specific_entry.fast` (Anthropic's own multiplier) always wins where
|
||||
// the source ships one and is left to cover the tier as before.
|
||||
const PRIORITY_KEY_SUFFIX = '_priority'
|
||||
// Generous bound: the largest ratio any vendor actually publishes is 2.5x, so
|
||||
// anything past this is a corrupted or hostile upstream row, not a price.
|
||||
const MAX_DERIVED_FAST_MULTIPLIER = 100
|
||||
|
||||
function priorityMultiplierOf(entry: LiteLLMEntry): number | null {
|
||||
const record = entry as Record<string, unknown>
|
||||
const ratios: number[] = []
|
||||
let inputRatio: number | undefined
|
||||
let outputRatio: number | undefined
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
if (!key.endsWith(PRIORITY_KEY_SUFFIX)) continue
|
||||
const base = record[key.slice(0, -PRIORITY_KEY_SUFFIX.length)]
|
||||
if (typeof value !== 'number' || typeof base !== 'number') continue
|
||||
if (!Number.isFinite(value) || !Number.isFinite(base) || value <= 0 || base <= 0) continue
|
||||
const ratio = value / base
|
||||
if (key === 'input_cost_per_token_priority') inputRatio = ratio
|
||||
else if (key === 'output_cost_per_token_priority') outputRatio = ratio
|
||||
ratios.push(ratio)
|
||||
}
|
||||
// No priority input AND output rate means there is no priority price to
|
||||
// scale the bill by, whatever stray priority keys the row carries.
|
||||
if (inputRatio === undefined || outputRatio === undefined) return null
|
||||
// "Within rounding": published ratios agree exactly in the JSON but can pick
|
||||
// up a few ulps in the division (2.5 vs 2.4999999999999996), so compare
|
||||
// relatively rather than for bitwise equality.
|
||||
const agreed = ratios.every(r => Math.abs(r - inputRatio!) <= 1e-9 * Math.max(r, inputRatio!))
|
||||
// Rounded to 4 decimals so division noise (1.7999999999999998) never ships.
|
||||
return agreed && inputRatio <= MAX_DERIVED_FAST_MULTIPLIER ? Math.round(inputRatio * 1e4) / 1e4 : null
|
||||
}
|
||||
|
||||
function tierOfLiteLLMEntry(entry: LiteLLMEntry): SnapshotTier | null {
|
||||
// Rates are read ONLY from the largest threshold a model carries, mirroring
|
||||
// scripts/bundle-litellm.mjs tierOf, so a two-tier entry can never mix a
|
||||
|
|
@ -316,13 +369,19 @@ export function parseLiteLLMEntry(entry: LiteLLMEntry): ModelCosts | null {
|
|||
const inputCost = safePerTokenRate(entry.input_cost_per_token)
|
||||
const outputCost = safePerTokenRate(entry.output_cost_per_token)
|
||||
if (inputCost === null || outputCost === null) return null
|
||||
const explicitFast = entry.provider_specific_entry?.fast
|
||||
const priorityFast = explicitFast == null ? priorityMultiplierOf(entry) : null
|
||||
const tier = tierOfLiteLLMEntry(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 buildCosts(
|
||||
inputCost,
|
||||
outputCost,
|
||||
safePerTokenRate(entry.cache_creation_input_token_cost),
|
||||
safePerTokenRate(entry.cache_read_input_token_cost),
|
||||
entry.provider_specific_entry?.fast,
|
||||
tierOfLiteLLMEntry(entry),
|
||||
explicitFast ?? priorityFast,
|
||||
tier,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -150,6 +150,13 @@ type CodexEntry = {
|
|||
source?: { subagent?: { thread_spawn?: { parent_thread_id?: string } } }
|
||||
model?: string
|
||||
name?: string
|
||||
/// event_msg/thread_settings_applied: the thread's service tier, flattened
|
||||
/// out of `thread_settings.service_tier` (#1616) so both the full-JSON and
|
||||
/// the compact-head decoders surface it at payload depth 1.
|
||||
serviceTier?: string
|
||||
/// Raw shape of the `thread_settings_applied` payload; only the service
|
||||
/// tier is read off it.
|
||||
thread_settings?: { service_tier?: string }
|
||||
invocation?: { server?: string; tool?: string }
|
||||
content?: Array<{ type?: string; text?: string }>
|
||||
info?: {
|
||||
|
|
@ -440,12 +447,35 @@ function countFirstJsonText(source: Buffer): number {
|
|||
return countJsonStringBytes(source, qStart + 1)
|
||||
}
|
||||
|
||||
// `thread_settings.service_tier` (#1616). The field comes off an unchecked
|
||||
// JSON.parse cast, so only a genuine non-empty string reads as a tier; anything
|
||||
// else is "the rollout states no tier", which prices as standard.
|
||||
function serviceTierFromThreadSettings(payload: CodexEntry['payload']): string | undefined {
|
||||
const tier = payload?.thread_settings?.service_tier
|
||||
return typeof tier === 'string' && tier ? tier : undefined
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Everything else - `"default"`, an unrecognised value, no tier at all - is
|
||||
// standard, never a guess.
|
||||
function speedForServiceTier(tier: string): 'standard' | 'fast' {
|
||||
return tier === 'priority' || tier === 'fast' ? 'fast' : 'standard'
|
||||
}
|
||||
|
||||
function parseCodexLine(line: string | Buffer): CodexEntry | null {
|
||||
if (typeof line === 'string') {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) return null
|
||||
try {
|
||||
return JSON.parse(trimmed) as CodexEntry
|
||||
const entry = JSON.parse(trimmed) as CodexEntry
|
||||
// #1616: `thread_settings.service_tier` sits at payload depth 2, which
|
||||
// the compact path below cannot express; flatten it the same way for the
|
||||
// fully-parsed path so both decoders agree.
|
||||
const tier = serviceTierFromThreadSettings(entry.payload)
|
||||
if (tier !== undefined && entry.payload) entry.payload.serviceTier = tier
|
||||
return entry
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
|
@ -512,6 +542,19 @@ function parseCodexLine(line: string | Buffer): CodexEntry | null {
|
|||
invocation,
|
||||
call_id: getRawJsonStringField(pHead, 'call_id'),
|
||||
turn_id: getRawJsonStringField(pHead, 'turn_id'),
|
||||
// #1616: oversized thread_settings_applied records (a fat permission
|
||||
// profile or plugin list can push them past the buffer threshold) still
|
||||
// carry `service_tier` within the first few hundred bytes of the
|
||||
// thread_settings object, so the windowed scan reaches it. Scanning
|
||||
// inside the payload-depth-1 `thread_settings` window - the session_meta
|
||||
// treatment above - keeps this decoder agreeing with the full-JSON path
|
||||
// (which reads the nested field) even if a same-name key ever appears in
|
||||
// an earlier payload field, and the payload-type guard keeps a
|
||||
// coincidental same-name key inside another event's bulk from reading as
|
||||
// a tier.
|
||||
serviceTier: type === 'event_msg' && payloadType === 'thread_settings_applied'
|
||||
? getRawJsonStringField(getRawPayloadFieldWindow(line, 'thread_settings') ?? '', 'service_tier')
|
||||
: undefined,
|
||||
// On mcp_tool_call_end a coincidental `duration_ms` inside the large
|
||||
// invocation.arguments object can shadow the payload-level duration, so the
|
||||
// depth-aware value wins. The naive scan stays as the fallback for
|
||||
|
|
@ -659,6 +702,11 @@ type CodexResumeState = {
|
|||
/// Newer Codex builds emit one token_usage_record before each token_count
|
||||
/// twin. Persist the source handover across append resumes.
|
||||
hasTokenUsageRecord?: boolean
|
||||
/// #1616: service tier the thread currently runs under
|
||||
/// (`thread_settings_applied`), so an append resume keeps pricing Fast
|
||||
/// (priority) turns as Fast. Optional so a resume state written before the
|
||||
/// field existed still decodes; absent reads as the default (standard) tier.
|
||||
serviceTier?: string
|
||||
prevInput: number
|
||||
prevCached: number
|
||||
prevCacheWrite: number
|
||||
|
|
@ -697,6 +745,7 @@ function isResumeState(value: unknown): value is CodexResumeState {
|
|||
&& (v['prevCumulativeTotal'] === null || typeof v['prevCumulativeTotal'] === 'number')
|
||||
&& (v['prevInfoIdentity'] === undefined || v['prevInfoIdentity'] === null || typeof v['prevInfoIdentity'] === 'string')
|
||||
&& (v['hasTokenUsageRecord'] === undefined || typeof v['hasTokenUsageRecord'] === 'boolean')
|
||||
&& (v['serviceTier'] === undefined || typeof v['serviceTier'] === 'string')
|
||||
&& typeof v['prevInput'] === 'number'
|
||||
&& typeof v['prevCached'] === 'number'
|
||||
&& typeof v['prevCacheWrite'] === 'number'
|
||||
|
|
@ -759,6 +808,11 @@ function createParser(source: SessionSource, seenKeys: Set<string>, capture?: {
|
|||
let prevCumulativeTotal: number | null = resume?.state.prevCumulativeTotal ?? null
|
||||
let prevInfoIdentity: string | null = resume?.state.prevInfoIdentity ?? null
|
||||
let hasTokenUsageRecord = resume?.state.hasTokenUsageRecord ?? false
|
||||
// #1616: the service tier `thread_settings_applied` last set. Everything
|
||||
// the loop reads from a PRIOR line must ride CodexResumeState, and this
|
||||
// one can change mid-file, so a resume that starts after the change must
|
||||
// not fall back to standard.
|
||||
let serviceTier: string = resume?.state.serviceTier ?? 'default'
|
||||
let prevInput = resume?.state.prevInput ?? 0
|
||||
let prevCached = resume?.state.prevCached ?? 0
|
||||
let prevCacheWrite = resume?.state.prevCacheWrite ?? 0
|
||||
|
|
@ -872,6 +926,9 @@ function createParser(source: SessionSource, seenKeys: Set<string>, capture?: {
|
|||
// added here. The cache-rehydration twin of this line lives in
|
||||
// src/parser.ts (cachedCallToApiCall); both call billableOutputTokens
|
||||
// so a fresh parse and a cache read can never price differently.
|
||||
// #1616: a turn running under the priority service tier (Codex's Fast
|
||||
// speed setting) bills at the priority rates via the fast multiplier.
|
||||
const speed = speedForServiceTier(serviceTier)
|
||||
const costUSD = calculateCost(
|
||||
model,
|
||||
billedInputTokens,
|
||||
|
|
@ -879,7 +936,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>, capture?: {
|
|||
billedCacheWriteTokens,
|
||||
cachedInputTokens,
|
||||
0,
|
||||
'standard',
|
||||
speed,
|
||||
0,
|
||||
'codex',
|
||||
)
|
||||
|
|
@ -898,7 +955,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>, capture?: {
|
|||
tools: pendingTools,
|
||||
bashCommands: [],
|
||||
timestamp,
|
||||
speed: 'standard',
|
||||
speed,
|
||||
deduplicationKey: dedupKey,
|
||||
turnId: currentTurnId,
|
||||
toolSequence: pendingToolSequence.length > 0 ? pendingToolSequence : undefined,
|
||||
|
|
@ -991,6 +1048,20 @@ function createParser(source: SessionSource, seenKeys: Set<string>, capture?: {
|
|||
continue
|
||||
}
|
||||
|
||||
// #1616: Codex's Fast speed setting bills through OpenAI's priority
|
||||
// service tier, which the rollout records per THREAD SETTINGS change
|
||||
// (event_msg/thread_settings_applied), never per call. The setting
|
||||
// covers every turn after it in file order, so track the latest value;
|
||||
// a settings event with no `service_tier` (older builds never wrote
|
||||
// one) reads as the default tier. Deliberately NOT skipped during a
|
||||
// fork replay: the replayed parent events carry the setting the fork
|
||||
// inherited, so tracking them keeps the fork's first own turn on the
|
||||
// parent's tier.
|
||||
if (entry.type === 'event_msg' && entry.payload?.type === 'thread_settings_applied') {
|
||||
serviceTier = entry.payload.serviceTier ?? 'default'
|
||||
continue
|
||||
}
|
||||
|
||||
if (isForkReplay && (
|
||||
entry.payload?.type === 'task_started' ||
|
||||
entry.payload?.type === 'task_complete' ||
|
||||
|
|
@ -1055,6 +1126,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>, capture?: {
|
|||
prevCumulativeTotal,
|
||||
prevInfoIdentity,
|
||||
hasTokenUsageRecord,
|
||||
serviceTier,
|
||||
prevInput,
|
||||
prevCached,
|
||||
prevCacheWrite,
|
||||
|
|
@ -1276,8 +1348,10 @@ function createParser(source: SessionSource, seenKeys: Set<string>, capture?: {
|
|||
seenKeys.add(dedupKey)
|
||||
|
||||
// An estimated prompt can cross a long-context threshold and tier
|
||||
// itself; measured corpora hold no estimated calls that cross.
|
||||
const costUSD = calculateCost(model, estInput, estOutput, 0, 0, 0, 'standard', 0, 'codex')
|
||||
// itself; measured corpora hold no estimated calls that cross. The
|
||||
// service tier applies to it exactly as to a measured turn (#1616).
|
||||
const speed = speedForServiceTier(serviceTier)
|
||||
const costUSD = calculateCost(model, estInput, estOutput, 0, 0, 0, speed, 0, 'codex')
|
||||
|
||||
pendingTaskCalls.push({
|
||||
provider: 'codex',
|
||||
|
|
@ -1294,7 +1368,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>, capture?: {
|
|||
tools: pendingTools,
|
||||
bashCommands: [],
|
||||
timestamp,
|
||||
speed: 'standard',
|
||||
speed,
|
||||
deduplicationKey: dedupKey,
|
||||
turnId: currentTurnId,
|
||||
toolSequence: pendingToolSequence.length > 0 ? pendingToolSequence : undefined,
|
||||
|
|
|
|||
|
|
@ -410,7 +410,12 @@ export const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
|
|||
// rollouts and retain the legacy-to-record handover state. Cached turns must
|
||||
// reparse because session-cache otherwise bypasses the provider parser.
|
||||
// Compose both suffixes so cached sessions receive both 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 (#1616): turns under Codex's Fast speed setting
|
||||
// (service_tier "priority") now bill at the priority rates. Cached calls
|
||||
// hold speed: 'standard' and are re-priced from that field on read, so the
|
||||
// multiplier alone cannot reach them - they must re-parse to re-record it.
|
||||
// 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',
|
||||
// reported-cost-v1: cached Crush calls stored costUSD: undefined and must
|
||||
// re-parse to keep the recorded session cost.
|
||||
crush: 'reported-cost-v1',
|
||||
|
|
|
|||
|
|
@ -141,3 +141,102 @@ it('adds context tiers without repricing base rows or losing exact-key carry-for
|
|||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
// #1616: Codex's Fast speed setting bills through OpenAI's priority tier, whose
|
||||
// rates LiteLLM publishes as `<rate>_priority` keys. Slot 5 (the fast
|
||||
// multiplier) derives from those when the row carries no
|
||||
// `provider_specific_entry.fast`, so the next routine snapshot refresh carries
|
||||
// the multiplier offline. Mirrors the live-path assertions in models.test.ts.
|
||||
it('derives the fast multiplier from priority rates without inventing one (#1616)', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'codeburn-bundle-priority-'))
|
||||
try {
|
||||
mkdirSync(join(dir, 'scripts'))
|
||||
mkdirSync(join(dir, 'src/data'), { recursive: true })
|
||||
copyFileSync(fileURLToPath(new URL('../scripts/bundle-litellm.mjs', import.meta.url)), join(dir, 'scripts/bundle-litellm.mjs'))
|
||||
writeFileSync(join(dir, 'src/data/litellm-snapshot.json'), '{}')
|
||||
writeFileSync(join(dir, 'src/data/pricing-fallback.json'), '{}')
|
||||
const row = (input: number, output: number, extra = {}) => ({
|
||||
input_cost_per_token: input, output_cost_per_token: output, ...extra,
|
||||
})
|
||||
const source = {
|
||||
// gpt-5.4 as quoted live: 2x on input, output and cache read.
|
||||
'gpt-5.4': row(2.5e-6, 15e-6, {
|
||||
cache_read_input_token_cost: 2.5e-7,
|
||||
input_cost_per_token_priority: 5e-6,
|
||||
output_cost_per_token_priority: 30e-6,
|
||||
cache_read_input_token_cost_priority: 5e-7,
|
||||
}),
|
||||
// gpt-5.5 as quoted live: 2.5x on every published bucket.
|
||||
'gpt-5.5': row(5e-6, 30e-6, {
|
||||
cache_read_input_token_cost: 5e-7,
|
||||
input_cost_per_token_priority: 1.25e-5,
|
||||
output_cost_per_token_priority: 7.5e-5,
|
||||
cache_read_input_token_cost_priority: 1.25e-6,
|
||||
}),
|
||||
// A tier without priority keys: OpenAI quotes no Fast long-context price.
|
||||
'gpt-5.5-tiered': row(5e-6, 30e-6, {
|
||||
input_cost_per_token_above_272k_tokens: 1e-5,
|
||||
output_cost_per_token_above_272k_tokens: 4.5e-5,
|
||||
input_cost_per_token_priority: 1.25e-5,
|
||||
output_cost_per_token_priority: 7.5e-5,
|
||||
}),
|
||||
// A tier with agreeing priority keys inherits the base multiplier.
|
||||
'gpt-5.6-tiered': row(4e-6, 20e-6, {
|
||||
input_cost_per_token_above_272k_tokens: 8e-6,
|
||||
output_cost_per_token_above_272k_tokens: 3e-5,
|
||||
input_cost_per_token_priority: 8e-6,
|
||||
output_cost_per_token_priority: 4e-5,
|
||||
input_cost_per_token_above_272k_tokens_priority: 1.6e-5,
|
||||
output_cost_per_token_above_272k_tokens_priority: 6e-5,
|
||||
}),
|
||||
// Division noise (1.7999999999999998) is rounded away.
|
||||
'gemini-2.5-pro': row(1.25e-6, 1e-5, {
|
||||
input_cost_per_token_priority: 2.25e-6,
|
||||
output_cost_per_token_priority: 1.8e-5,
|
||||
}),
|
||||
// No priority keys at all: stays 1x (null slot).
|
||||
'gpt-5-codex': row(1.25e-6, 1e-5, { cache_read_input_token_cost: 1.25e-7 }),
|
||||
// azure/gpt-5.5 as quoted live: 2.5x base but 2x above 272k, so no single
|
||||
// multiplier prices both regimes and none is guessed.
|
||||
'azure/gpt-5.5': row(5e-6, 30e-6, {
|
||||
cache_read_input_token_cost: 5e-7,
|
||||
cache_read_input_token_cost_above_272k_tokens: 1e-6,
|
||||
input_cost_per_token_priority: 1.25e-5,
|
||||
output_cost_per_token_priority: 7.5e-5,
|
||||
cache_read_input_token_cost_priority: 1.25e-6,
|
||||
input_cost_per_token_above_272k_tokens_priority: 2e-5,
|
||||
output_cost_per_token_above_272k_tokens_priority: 6e-5,
|
||||
cache_read_input_token_cost_above_272k_tokens_priority: 2e-6,
|
||||
}),
|
||||
// Anthropic's explicit multiplier wins even when priority keys exist.
|
||||
'claude-opus-4-8': row(5e-6, 25e-6, {
|
||||
input_cost_per_token_priority: 1e-5,
|
||||
output_cost_per_token_priority: 5e-5,
|
||||
provider_specific_entry: { fast: 1.4 },
|
||||
}),
|
||||
}
|
||||
writeFileSync(join(dir, 'source.json'), JSON.stringify(source))
|
||||
const run = spawnSync(process.execPath, ['--input-type=module', '-e', `
|
||||
import { readFileSync } from 'node:fs';
|
||||
const source = JSON.parse(readFileSync('source.json', 'utf8'));
|
||||
globalThis.fetch = async (url) => ({ ok: true, json: async () =>
|
||||
url.includes('raw.githubusercontent.com') ? source : url.includes('models.dev') ? {} : { data: [] }
|
||||
});
|
||||
await import('./scripts/bundle-litellm.mjs');
|
||||
`], { cwd: dir, encoding: 'utf8', timeout: 10_000 })
|
||||
expect(run.status, run.stderr).toBe(0)
|
||||
const snapshot = JSON.parse(readFileSync(join(dir, 'src/data/litellm-snapshot.json'), 'utf8'))
|
||||
expect(snapshot['gpt-5.4']![4]).toBe(2)
|
||||
expect(snapshot['gpt-5.5']![4]).toBe(2.5)
|
||||
expect(snapshot['gpt-5.5-tiered']![4]).toBe(2.5)
|
||||
expect(snapshot['gpt-5.5-tiered']![5].fast).toBe(1)
|
||||
expect(snapshot['gpt-5.6-tiered']![4]).toBe(2)
|
||||
expect(snapshot['gpt-5.6-tiered']![5]).not.toHaveProperty('fast')
|
||||
expect(snapshot['gemini-2.5-pro']![4]).toBe(1.8)
|
||||
expect(snapshot['gpt-5-codex']![4]).toBeNull()
|
||||
expect(snapshot['azure/gpt-5.5']![4]).toBeNull()
|
||||
expect(snapshot['claude-opus-4-8']![4]).toBe(1.4)
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -13,7 +13,8 @@ import { createHash } from 'crypto'
|
|||
import { join } from 'path'
|
||||
|
||||
import { clearSessionCache, parseAllSessions } from '../src/parser.js'
|
||||
import type { SessionCache } from '../src/session-cache.js'
|
||||
import { PROVIDER_PARSE_VERSIONS, type SessionCache } from '../src/session-cache.js'
|
||||
import { parseLiteLLMEntry, restorePricingState, snapshotPricingState } from '../src/models.js'
|
||||
import { readCacheOnDisk, writeCacheOnDisk } from './fixtures/session-cache-io.js'
|
||||
|
||||
const testRoot = vi.hoisted(() => {
|
||||
|
|
@ -42,6 +43,16 @@ function preUsageRecordFingerprint(): string {
|
|||
.slice(0, 16)
|
||||
}
|
||||
|
||||
// Exact fingerprint emitted before #1616 appended codex-priority-tier-v1 —
|
||||
// what every warm user session-cache holds when upgrading.
|
||||
function prePriorityTierFingerprint(): string {
|
||||
const parseVersion = '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'
|
||||
return createHash('sha256')
|
||||
.update(`CODEX_HOME=${CODEX_HOME}\0parser=${parseVersion}`)
|
||||
.digest('hex')
|
||||
.slice(0, 16)
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await rm(CACHE_DIR, { recursive: true, force: true })
|
||||
await rm(CODEX_HOME, { recursive: true, force: true })
|
||||
|
|
@ -151,4 +162,77 @@ describe('codex parser change invalidates stale session-cache (#478/#513)', () =
|
|||
const migratedSession = migrated.flatMap(project => project.sessions).find(session => session.sessionId === 'sess-usage-record-cache')
|
||||
expect(migratedSession && migratedSession.totalInputTokens + migratedSession.totalOutputTokens).toBe(1200)
|
||||
})
|
||||
|
||||
// #1616's session-cache layer: the codex-priority-tier-v1 suffix is what
|
||||
// makes already-cached SESSIONS re-record `speed` (read-time re-pricing uses
|
||||
// the stored speed, so the multiplier alone cannot reach them). The
|
||||
// codex-results layer is pinned in tests/providers/codex.test.ts; this pins
|
||||
// the layer above it end to end through parseAllSessions.
|
||||
it('re-parses warm session-cache turns after the priority-tier suffix (#1616)', async () => {
|
||||
const sessionDir = join(CODEX_HOME, 'sessions', '2026', '09', '28')
|
||||
await mkdir(sessionDir, { recursive: true })
|
||||
await mkdir(CACHE_DIR, { recursive: true })
|
||||
const lines = [
|
||||
JSON.stringify({ type: 'session_meta', timestamp: '2026-09-28T10:00:00Z', payload: { session_id: 'sess-tier-cache', 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: 'priority', cwd: '/Users/test/proj' } } }),
|
||||
JSON.stringify({ type: 'response_item', timestamp: '2026-09-28T10:01:00Z', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'fast turn via the session cache' }] } }),
|
||||
JSON.stringify({ type: 'event_msg', timestamp: '2026-09-28T10:01:30Z', payload: { type: 'token_count', info: { last_token_usage: { input_tokens: 1000, cached_input_tokens: 200, output_tokens: 500, reasoning_output_tokens: 0, total_tokens: 1700 }, total_token_usage: { input_tokens: 1000, cached_input_tokens: 200, output_tokens: 500, reasoning_output_tokens: 0, total_tokens: 1700 } } } }),
|
||||
]
|
||||
await writeFile(join(sessionDir, 'rollout-tier-cache.jsonl'), lines.join('\n') + '\n')
|
||||
|
||||
// gpt-5.4 as quoted from LiteLLM's live table, priority tier exactly 2x on
|
||||
// all three rates (same row as tests/providers/codex.test.ts). The bundled
|
||||
// snapshot cannot carry the derived multiplier until its next routine
|
||||
// refresh, so install the live-parsed row for the duration of the test
|
||||
// exactly as fetchAndCachePricing would.
|
||||
const GPT54 = { input: 2.5e-6, output: 15e-6, cacheRead: 2.5e-7 }
|
||||
const standard = 800 * GPT54.input + 200 * GPT54.cacheRead + 500 * GPT54.output
|
||||
const snap = snapshotPricingState()
|
||||
const pricing = new Map(snap.pricing)
|
||||
const costs = parseLiteLLMEntry({
|
||||
input_cost_per_token: GPT54.input,
|
||||
output_cost_per_token: GPT54.output,
|
||||
cache_read_input_token_cost: GPT54.cacheRead,
|
||||
input_cost_per_token_priority: 5e-6,
|
||||
output_cost_per_token_priority: 30e-6,
|
||||
cache_read_input_token_cost_priority: 5e-7,
|
||||
} as never)
|
||||
if (costs) pricing.set('gpt-5.4', costs)
|
||||
restorePricingState({ ...snap, pricing })
|
||||
try {
|
||||
// Run 1: cold cache. Sanity — the priority turn is recorded fast at 2x.
|
||||
clearSessionCache()
|
||||
const fresh = await parseAllSessions(undefined, 'codex')
|
||||
const freshSession = fresh.flatMap(project => project.sessions).find(session => session.sessionId === 'sess-tier-cache')
|
||||
expect(freshSession && freshSession.totalCostUSD).toBeCloseTo(standard * 2, 12)
|
||||
|
||||
// Simulate a warm session-cache written by the PRE-#1616 release: the
|
||||
// codex fingerprint predates codex-priority-tier-v1 and the stored call
|
||||
// holds speed 'standard'. Without the suffix this fingerprint matches,
|
||||
// the stale turns are served as-is (re-priced from the stored standard
|
||||
// speed), and the multiplier can never reach them.
|
||||
expect(PROVIDER_PARSE_VERSIONS['codex']!.endsWith('codex-priority-tier-v1')).toBe(true)
|
||||
const cache = await readCacheOnDisk() as SessionCache
|
||||
cache.providers['codex']!.envFingerprint = prePriorityTierFingerprint()
|
||||
for (const file of Object.values(cache.providers['codex']!.files)) {
|
||||
for (const turn of file.turns) {
|
||||
for (const call of turn.calls) call.speed = 'standard'
|
||||
}
|
||||
}
|
||||
await writeCacheOnDisk(cache)
|
||||
|
||||
clearSessionCache()
|
||||
const second = await parseAllSessions(undefined, 'codex')
|
||||
const migratedSession = second.flatMap(project => project.sessions).find(session => session.sessionId === 'sess-tier-cache')
|
||||
expect(migratedSession && migratedSession.totalCostUSD).toBeCloseTo(standard * 2, 12)
|
||||
|
||||
// The rebuilt cache re-recorded the speed itself, not just the cost.
|
||||
const rebuilt = await readCacheOnDisk() as SessionCache
|
||||
const storedSpeeds = Object.values(rebuilt.providers['codex']!.files)
|
||||
.flatMap(file => file.turns.flatMap(turn => turn.calls.map(call => call.speed)))
|
||||
expect(storedSpeeds).toEqual(['fast'])
|
||||
} finally {
|
||||
restorePricingState(snap)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ import {
|
|||
cacheWriteCostPerToken,
|
||||
tieredCostsFor,
|
||||
modelKeyMatches,
|
||||
snapshotPricingState,
|
||||
restorePricingState,
|
||||
} from '../src/models.js'
|
||||
import { getDailyCacheConfigHash } from '../src/usage-aggregator.js'
|
||||
import snapshotData from '../src/data/litellm-snapshot.json' with { type: 'json' }
|
||||
|
|
@ -240,6 +242,183 @@ describe('getModelCosts', () => {
|
|||
})
|
||||
})
|
||||
|
||||
// #1616: Codex's Fast speed setting bills through OpenAI's priority service
|
||||
// tier, which LiteLLM publishes as `<rate>_priority` keys beside the standard
|
||||
// ones (and `_above_<n>k_tokens_priority` for gpt-5.6's long-context tier)
|
||||
// rather than as a `provider_specific_entry.fast`. The entries below are
|
||||
// quoted from the live model_prices_and_context_window.json (2026-10-05
|
||||
// refresh). The bundled snapshot carries the same derived slots; these pin the
|
||||
// live path, and the bundled-rate cases below pin the snapshot.
|
||||
describe('priority service-tier fast multipliers (#1616)', () => {
|
||||
/// Install `entries` as the live pricing rows for the duration of `run`,
|
||||
/// exactly as fetchAndCachePricing would, so calculateCost's full pipeline
|
||||
/// (getModelCosts -> tieredCostsFor -> multiplier) prices off them.
|
||||
function withLiveEntries(entries: Record<string, Record<string, number>>, run: () => void): void {
|
||||
const snap = snapshotPricingState()
|
||||
const pricing = new Map(snap.pricing)
|
||||
for (const [name, entry] of Object.entries(entries)) {
|
||||
const costs = parseLiteLLMEntry(entry as never)
|
||||
if (costs) pricing.set(name, costs)
|
||||
}
|
||||
restorePricingState({ ...snap, pricing })
|
||||
try {
|
||||
run()
|
||||
} finally {
|
||||
restorePricingState(snap)
|
||||
}
|
||||
}
|
||||
|
||||
it('derives gpt-5.4\'s uniform 2x ratio as the fast multiplier', () => {
|
||||
const costs = parseLiteLLMEntry({
|
||||
input_cost_per_token: 2.5e-6,
|
||||
output_cost_per_token: 15e-6,
|
||||
cache_read_input_token_cost: 2.5e-7,
|
||||
input_cost_per_token_priority: 5e-6,
|
||||
output_cost_per_token_priority: 30e-6,
|
||||
cache_read_input_token_cost_priority: 5e-7,
|
||||
} as never)
|
||||
expect(costs?.fastMultiplier).toBe(2)
|
||||
})
|
||||
|
||||
it('derives gpt-5.5\'s uniform 2.5x ratio as the fast multiplier', () => {
|
||||
const costs = parseLiteLLMEntry({
|
||||
input_cost_per_token: 5e-6,
|
||||
output_cost_per_token: 30e-6,
|
||||
cache_read_input_token_cost: 5e-7,
|
||||
input_cost_per_token_priority: 1.25e-5,
|
||||
output_cost_per_token_priority: 7.5e-5,
|
||||
cache_read_input_token_cost_priority: 1.25e-6,
|
||||
} as never)
|
||||
expect(costs?.fastMultiplier).toBe(2.5)
|
||||
})
|
||||
|
||||
it('prices a gpt-5.6 fast call past 272k at the published priority tier rates', () => {
|
||||
// gpt-5.6 quotes the priority long-context rates as exactly 2x its
|
||||
// standard tier, so the single base multiplier carries the tier too.
|
||||
withLiveEntries({
|
||||
'gpt-5.6': {
|
||||
input_cost_per_token: 4e-6,
|
||||
output_cost_per_token: 20e-6,
|
||||
cache_creation_input_token_cost: 5e-6,
|
||||
cache_read_input_token_cost: 4e-7,
|
||||
input_cost_per_token_above_272k_tokens: 8e-6,
|
||||
output_cost_per_token_above_272k_tokens: 30e-6,
|
||||
cache_creation_input_token_cost_above_272k_tokens: 1e-5,
|
||||
cache_read_input_token_cost_above_272k_tokens: 8e-7,
|
||||
input_cost_per_token_priority: 8e-6,
|
||||
output_cost_per_token_priority: 40e-6,
|
||||
cache_creation_input_token_cost_priority: 1e-5,
|
||||
cache_read_input_token_cost_priority: 8e-7,
|
||||
input_cost_per_token_above_272k_tokens_priority: 1.6e-5,
|
||||
output_cost_per_token_above_272k_tokens_priority: 6e-5,
|
||||
cache_creation_input_token_cost_above_272k_tokens_priority: 2e-5,
|
||||
cache_read_input_token_cost_above_272k_tokens_priority: 1.6e-6,
|
||||
},
|
||||
}, () => {
|
||||
const fast = calculateCost('gpt-5.6', 300_000, 1_000, 500, 2_000, 0, 'fast', 0, 'codex')
|
||||
// The literal priority tier rates, not 2x-of-nothing: 300k input (past
|
||||
// the 272k threshold), 1k output, 500 cache write, 2k cache read.
|
||||
expect(fast).toBeCloseTo(300_000 * 1.6e-5 + 1_000 * 6e-5 + 500 * 2e-5 + 2_000 * 1.6e-6, 12)
|
||||
const standard = calculateCost('gpt-5.6', 300_000, 1_000, 500, 2_000, 0, 'standard', 0, 'codex')
|
||||
expect(standard).toBeCloseTo(300_000 * 8e-6 + 1_000 * 3e-5 + 500 * 1e-5 + 2_000 * 8e-7, 12)
|
||||
expect(fast).toBeCloseTo(standard * 2, 12)
|
||||
})
|
||||
})
|
||||
|
||||
it('rounds a derived ratio to 4 decimals', () => {
|
||||
// gemini-2.5-pro's published rates divide to 1.7999999999999998.
|
||||
const costs = parseLiteLLMEntry({
|
||||
input_cost_per_token: 1.25e-6,
|
||||
output_cost_per_token: 1e-5,
|
||||
input_cost_per_token_priority: 2.25e-6,
|
||||
output_cost_per_token_priority: 1.8e-5,
|
||||
} as never)
|
||||
expect(costs?.fastMultiplier).toBe(1.8)
|
||||
})
|
||||
|
||||
it('leaves a model without priority keys at 1x', () => {
|
||||
// gpt-5-codex / gpt-5.1-codex publish no priority rates upstream; no
|
||||
// multiplier may be invented for them.
|
||||
const costs = parseLiteLLMEntry({
|
||||
input_cost_per_token: 1.25e-6,
|
||||
output_cost_per_token: 1e-5,
|
||||
cache_read_input_token_cost: 1.25e-7,
|
||||
} as never)
|
||||
expect(costs?.fastMultiplier).toBe(1)
|
||||
})
|
||||
|
||||
it('does not guess when the published ratios disagree', () => {
|
||||
// azure/gpt-5.5 as quoted live: 2.5x on every base rate but 2x on the
|
||||
// above-272k tier, so no single multiplier can price both regimes. The
|
||||
// honest answer is 1x (standard rates), never an average.
|
||||
const costs = parseLiteLLMEntry({
|
||||
input_cost_per_token: 5e-6,
|
||||
output_cost_per_token: 30e-6,
|
||||
cache_read_input_token_cost: 5e-7,
|
||||
cache_read_input_token_cost_above_272k_tokens: 1e-6,
|
||||
input_cost_per_token_priority: 1.25e-5,
|
||||
output_cost_per_token_priority: 7.5e-5,
|
||||
cache_read_input_token_cost_priority: 1.25e-6,
|
||||
input_cost_per_token_above_272k_tokens_priority: 2e-5,
|
||||
output_cost_per_token_above_272k_tokens_priority: 6e-5,
|
||||
cache_read_input_token_cost_above_272k_tokens_priority: 2e-6,
|
||||
} as never)
|
||||
expect(costs?.fastMultiplier).toBe(1)
|
||||
// A row with only a stray priority cache-read rate (no input/output pair)
|
||||
// is equally unusable.
|
||||
const stray = parseLiteLLMEntry({
|
||||
input_cost_per_token: 5e-8,
|
||||
output_cost_per_token: 4e-7,
|
||||
cache_read_input_token_cost: 5e-9,
|
||||
input_cost_per_token_priority: 2.5e-6,
|
||||
} as never)
|
||||
expect(stray?.fastMultiplier).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps gpt-5.5\'s long-context tier at standard rates when no Fast tier price is published', () => {
|
||||
const costs = parseLiteLLMEntry({
|
||||
input_cost_per_token: 5e-6,
|
||||
output_cost_per_token: 30e-6,
|
||||
cache_read_input_token_cost: 5e-7,
|
||||
input_cost_per_token_above_272k_tokens: 1e-5,
|
||||
output_cost_per_token_above_272k_tokens: 4.5e-5,
|
||||
cache_read_input_token_cost_above_272k_tokens: 1e-6,
|
||||
input_cost_per_token_priority: 1.25e-5,
|
||||
output_cost_per_token_priority: 7.5e-5,
|
||||
cache_read_input_token_cost_priority: 1.25e-6,
|
||||
} as never)
|
||||
expect(costs?.fastMultiplier).toBe(2.5)
|
||||
expect(costs?.longContextTier?.fastMultiplier).toBe(1)
|
||||
})
|
||||
|
||||
it('prices bundled gpt-5.5 Fast past 272k at the standard long-context rate', () => {
|
||||
const fast = calculateCost('gpt-5.5', 300_000, 1_000, 0, 2_000, 0, 'fast', 0, 'codex')
|
||||
expect(fast).toBeCloseTo(300_000 * 1e-5 + 1_000 * 4.5e-5 + 2_000 * 1e-6, 12)
|
||||
expect(fast).toBeCloseTo(calculateCost('gpt-5.5', 300_000, 1_000, 0, 2_000, 0, 'standard', 0, 'codex'), 12)
|
||||
// Below the threshold Fast still bills at the 2.5x priority rate.
|
||||
expect(calculateCost('gpt-5.5', 100_000, 1_000, 0, 0, 0, 'fast', 0, 'codex'))
|
||||
.toBeCloseTo((100_000 * 5e-6 + 1_000 * 3e-5) * 2.5, 12)
|
||||
})
|
||||
|
||||
it('prices bundled gpt-5.6-sol Fast past 272k at the published Fast tier', () => {
|
||||
expect(calculateCost('gpt-5.6-sol', 300_000, 1_000, 0, 0, 0, 'fast', 0, 'codex'))
|
||||
.toBeCloseTo(300_000 * 16e-6 + 1_000 * 60e-6, 12)
|
||||
})
|
||||
|
||||
it('keeps provider_specific_entry.fast (Anthropic) winning over a derived ratio', () => {
|
||||
const costs = parseLiteLLMEntry({
|
||||
input_cost_per_token: 5e-6,
|
||||
output_cost_per_token: 25e-6,
|
||||
cache_read_input_token_cost: 5e-7,
|
||||
input_cost_per_token_priority: 1e-5,
|
||||
output_cost_per_token_priority: 5e-5,
|
||||
cache_read_input_token_cost_priority: 1e-6,
|
||||
provider_specific_entry: { fast: 1.4 },
|
||||
} as never)
|
||||
expect(costs?.fastMultiplier).toBe(1.4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('grok-4.6 prompt tier', () => {
|
||||
it('uses the low tier below 200000 prompt tokens', () => {
|
||||
// Base input is 1.25e-6 since LiteLLM's 2026-09 reprice (was 2e-6); the
|
||||
|
|
|
|||
|
|
@ -293,6 +293,35 @@ const FORK_LINES = [
|
|||
...richTask(12, { tokens: 'empty' }),
|
||||
]
|
||||
|
||||
// #1616: the service tier changes mid-file via thread_settings_applied, so a
|
||||
// resume boundary can fall on either side of a switch.
|
||||
const tierEvent = (tier: string, timestamp: string) => J({
|
||||
type: 'event_msg', timestamp,
|
||||
payload: { type: 'thread_settings_applied', thread_settings: { model: 'gpt-5.3-codex', service_tier: tier } },
|
||||
})
|
||||
|
||||
const TIER_LINES = [
|
||||
J({ type: 'session_meta', timestamp: ts(0, 0), payload: { cwd: '/Users/test/proj', originator: 'codex-cli', session_id: 'sess-1', model: 'gpt-5.3-codex' } }),
|
||||
tierEvent('priority', ts(0, 1)),
|
||||
...richTask(1),
|
||||
...richTask(2, { tokens: 'empty' }),
|
||||
tierEvent('default', ts(30, 0)),
|
||||
...richTask(3, { model: 'gpt-5.3-codex-mini' }),
|
||||
...richTask(4, { noComplete: true }),
|
||||
tierEvent('priority', ts(45, 0)),
|
||||
...richTask(6),
|
||||
]
|
||||
|
||||
const TIER_FORK_LINES = [
|
||||
J({ type: 'session_meta', timestamp: ts(0, 0), payload: { cwd: '/Users/test/proj', originator: 'codex-cli', session_id: 'sess-2', forked_from_id: 'sess-1', model: 'gpt-5.3-codex' } }),
|
||||
// The parent's tier setting is part of the replayed history: the fork's own
|
||||
// first turn must inherit it, not fall back to standard.
|
||||
tierEvent('priority', '2026-04-14T10:00:01Z'),
|
||||
...richTask(0).map(l => l.replace(/2026-04-14T10:00:0\d/g, '2026-04-14T10:00:01')),
|
||||
...richTask(11),
|
||||
...richTask(12, { tokens: 'empty' }),
|
||||
]
|
||||
|
||||
describe('codex resume differential', () => {
|
||||
async function rollout(lines: string[]): Promise<{ codexDir: string; path: string }> {
|
||||
const codexDir = await mkdtemp(join(tmpdir(), 'codex-split-'))
|
||||
|
|
@ -325,4 +354,61 @@ describe('codex resume differential', () => {
|
|||
it('matches a full re-parse at every line boundary of a forked session', async () => {
|
||||
await assertEverySplitMatches(FORK_LINES)
|
||||
}, 60_000)
|
||||
|
||||
it('matches a full re-parse at every line boundary of a tier-switching session (#1616)', async () => {
|
||||
await assertEverySplitMatches(TIER_LINES)
|
||||
// The fixture really does straddle both tiers, so the differential above is
|
||||
// about tier state and not about five identical standard calls.
|
||||
const base = await rollout(TIER_LINES)
|
||||
const full = await parse(await mkdtemp(join(tmpdir(), 'codex-c-')), base.codexDir)
|
||||
expect(full.map(call => call.speed)).toEqual(['fast', 'fast', 'standard', 'standard', 'fast'])
|
||||
}, 60_000)
|
||||
|
||||
it('a fork inherits the parent tier from the replayed settings, at every split (#1616)', async () => {
|
||||
await assertEverySplitMatches(TIER_FORK_LINES)
|
||||
// The fork's own two tasks must price as Fast: the replayed parent event
|
||||
// set the tier, and the burst's end did not reset it.
|
||||
const base = await rollout(TIER_FORK_LINES)
|
||||
const full = await parse(await mkdtemp(join(tmpdir(), 'codex-c-')), base.codexDir)
|
||||
expect(full.map(call => call.speed)).toEqual(['fast', 'fast'])
|
||||
}, 60_000)
|
||||
})
|
||||
|
||||
// #1616: the thread's service tier is state the decode reads from a PRIOR
|
||||
// line, so it has to ride CodexResumeState — otherwise an append that lands
|
||||
// after a priority switch silently re-prices the tail at standard rates.
|
||||
describe('codex incremental resume across a service-tier change (#1616)', () => {
|
||||
it('keeps the priority tier across an append and matches a full re-parse', async () => {
|
||||
const warmCache = join(tmpDir, 'cache-warm')
|
||||
const coldCache = join(tmpDir, 'cache-cold')
|
||||
|
||||
sessionPath = await writeRollout([meta(), tierEvent('priority', '2026-04-14T10:00:01Z'), ...tasks(1, 2)])
|
||||
const first = await parse(warmCache)
|
||||
expect(first.map(call => call.speed)).toEqual(['fast', 'fast'])
|
||||
|
||||
// The switch back lands in the appended tail, after the resume boundary.
|
||||
await appendFile(sessionPath, [tierEvent('default', '2026-04-14T10:30:00Z'), ...tasks(3, 4)].join('\n') + '\n')
|
||||
|
||||
readLineCalls.length = 0
|
||||
const resumed = await parse(warmCache)
|
||||
const resumeReads = readLineCalls.filter(c => c.filePath === sessionPath)
|
||||
expect(resumeReads.some(c => (c.startByteOffset ?? 0) > 0)).toBe(true)
|
||||
expect(resumed.map(call => call.speed)).toEqual(['fast', 'fast', 'standard', 'standard'])
|
||||
expect(JSON.stringify(resumed)).toBe(JSON.stringify(await parse(coldCache)))
|
||||
})
|
||||
|
||||
it('picks the tier up mid-file when the resume boundary predates the switch', async () => {
|
||||
const warmCache = join(tmpDir, 'cache-warm')
|
||||
|
||||
sessionPath = await writeRollout([meta(), ...tasks(1, 2)])
|
||||
await parse(warmCache)
|
||||
// A tier switch followed by a tail with no task boundary of its own: the
|
||||
// next resume restarts at the last boundary and re-reads the switch.
|
||||
await appendFile(sessionPath, [tierEvent('priority', '2026-04-14T10:30:00Z'), ...tasks(3, 3).slice(1)].join('\n') + '\n')
|
||||
|
||||
const resumed = await parse(warmCache)
|
||||
const full = await parse(join(tmpDir, 'cache-cold'))
|
||||
expect(JSON.stringify(resumed)).toBe(JSON.stringify(full))
|
||||
expect(resumed.map(call => call.speed)).toEqual(['standard', 'standard', 'fast'])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ 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 { calculateCost, parseLiteLLMEntry, restorePricingState, snapshotPricingState } from '../../src/models.js'
|
||||
import type { ParsedProviderCall } from '../../src/providers/types.js'
|
||||
|
||||
let tmpDir: string
|
||||
|
|
@ -1706,3 +1706,225 @@ describe('codex auto-review pricing (#1047)', () => {
|
|||
}
|
||||
})
|
||||
})
|
||||
|
||||
// #1616: Codex's Fast speed setting bills through the priority service tier,
|
||||
// which the rollout records per thread settings change
|
||||
// (event_msg/thread_settings_applied -> payload.thread_settings.service_tier),
|
||||
// never per call. The setting covers every turn after it in file order.
|
||||
describe('codex provider - priority service tier (#1616)', () => {
|
||||
// gpt-5.4 as quoted from LiteLLM's live table: 2.5e-6 in / 15e-6 out /
|
||||
// 2.5e-7 cached read, with the priority tier exactly 2x on all three. The
|
||||
// bundled snapshot cannot carry the derived multiplier until its next routine
|
||||
// refresh, so install the live-parsed row for the duration of a test exactly
|
||||
// as fetchAndCachePricing would.
|
||||
const GPT54 = { input: 2.5e-6, output: 15e-6, cacheRead: 2.5e-7 }
|
||||
|
||||
async function withLiveGpt54<T>(run: () => Promise<T>): Promise<T> {
|
||||
const snap = snapshotPricingState()
|
||||
const pricing = new Map(snap.pricing)
|
||||
const costs = parseLiteLLMEntry({
|
||||
input_cost_per_token: GPT54.input,
|
||||
output_cost_per_token: GPT54.output,
|
||||
cache_read_input_token_cost: GPT54.cacheRead,
|
||||
input_cost_per_token_priority: 5e-6,
|
||||
output_cost_per_token_priority: 30e-6,
|
||||
cache_read_input_token_cost_priority: 5e-7,
|
||||
} as never)
|
||||
if (costs) pricing.set('gpt-5.4', costs)
|
||||
restorePricingState({ ...snap, pricing })
|
||||
try {
|
||||
return await run()
|
||||
} finally {
|
||||
restorePricingState(snap)
|
||||
}
|
||||
}
|
||||
|
||||
function threadSettings(tier: string | null, timestamp: string): string {
|
||||
return JSON.stringify({
|
||||
type: 'event_msg',
|
||||
timestamp,
|
||||
payload: {
|
||||
type: 'thread_settings_applied',
|
||||
// Older builds (and a settings record that predates the tier toggle)
|
||||
// carry no service_tier at all; `null` here omits the key entirely.
|
||||
thread_settings: {
|
||||
model: 'gpt-5.4',
|
||||
...(tier === null ? {} : { service_tier: tier }),
|
||||
cwd: '/Users/test/myproject',
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function parseCalls(lines: string[]): Promise<ParsedProviderCall[]> {
|
||||
const filePath = await writeSession(tmpDir, '2026-09-28', 'rollout-tier.jsonl', lines)
|
||||
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)
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
it('prices turns under service_tier "priority" at 2x and turns after a switch back at 1x', async () => {
|
||||
// calculateCost must run inside the injected-pricing window too, so the
|
||||
// reference values are captured alongside the parse.
|
||||
const { calls, fastRef, standardRef } = await withLiveGpt54(async () => ({
|
||||
calls: await parseCalls([
|
||||
sessionMeta({ session_id: 'sess-tier', model: 'gpt-5.4', timestamp: '2026-09-28T10:00:00Z' }),
|
||||
threadSettings('priority', '2026-09-28T10:00:01Z'),
|
||||
userMessage('fast turn one', '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 } }),
|
||||
userMessage('fast turn two', '2026-09-28T10:02:00Z'),
|
||||
tokenCount({ timestamp: '2026-09-28T10:02:30Z', last: { input: 1200, cached: 100, output: 400 }, total: { input: 2200, cached: 300, output: 900, total: 3400 } }),
|
||||
threadSettings('default', '2026-09-28T10:03:00Z'),
|
||||
userMessage('standard turn', '2026-09-28T10:04:00Z'),
|
||||
tokenCount({ timestamp: '2026-09-28T10:04:30Z', last: { input: 1000, cached: 200, output: 500 }, total: { input: 3200, cached: 500, output: 1400, total: 5100 } }),
|
||||
]),
|
||||
fastRef: calculateCost('gpt-5.4', 800, 500, 0, 200, 0, 'fast', 0, 'codex'),
|
||||
standardRef: calculateCost('gpt-5.4', 800, 500, 0, 200, 0, 'standard', 0, 'codex'),
|
||||
}))
|
||||
|
||||
expect(calls.map(call => call.speed)).toEqual(['fast', 'fast', 'standard'])
|
||||
|
||||
// Explicit arithmetic, not self-consistency with calculateCost: the first
|
||||
// turn is 800 uncached input + 200 cached + 500 output, and the priority
|
||||
// tier doubles every published rate.
|
||||
const standard = 800 * GPT54.input + 200 * GPT54.cacheRead + 500 * GPT54.output
|
||||
expect(calls[0]!.costUSD).toBeCloseTo(standard * 2, 12)
|
||||
expect(calls[2]!.costUSD).toBeCloseTo(standard, 12)
|
||||
expect(calls[0]!.costUSD).toBe(fastRef)
|
||||
expect(calls[2]!.costUSD).toBe(standardRef)
|
||||
})
|
||||
|
||||
it('accepts service_tier "fast" as an alias of "priority" (spec: a build may write the speed name)', async () => {
|
||||
// The spec's acceptance clause: `"fast"` must bill like `"priority"`. A
|
||||
// future "simplification" of speedForServiceTier to `tier === 'priority'`
|
||||
// would silently drop the alias, so pin it with the same 2x arithmetic.
|
||||
const { calls, fastRef } = await withLiveGpt54(async () => ({
|
||||
calls: await parseCalls([
|
||||
sessionMeta({ session_id: 'sess-tier-fast-alias', model: 'gpt-5.4', timestamp: '2026-09-28T10:00:00Z' }),
|
||||
threadSettings('fast', '2026-09-28T10:00:01Z'),
|
||||
userMessage('aliased fast 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 } }),
|
||||
]),
|
||||
fastRef: calculateCost('gpt-5.4', 800, 500, 0, 200, 0, 'fast', 0, 'codex'),
|
||||
}))
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.speed).toBe('fast')
|
||||
const standard = 800 * GPT54.input + 200 * GPT54.cacheRead + 500 * GPT54.output
|
||||
expect(calls[0]!.costUSD).toBeCloseTo(standard * 2, 12)
|
||||
expect(calls[0]!.costUSD).toBe(fastRef)
|
||||
})
|
||||
|
||||
it('treats a settings record with no service_tier as the default tier', async () => {
|
||||
const calls = await parseCalls([
|
||||
sessionMeta({ session_id: 'sess-no-tier', model: 'gpt-5.4', timestamp: '2026-09-28T10:00:00Z' }),
|
||||
threadSettings(null, '2026-09-28T10:00:01Z'),
|
||||
userMessage('plain 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('standard')
|
||||
})
|
||||
|
||||
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
|
||||
// that path too. The padding sits in a later field, exactly where a fat
|
||||
// permission profile or plugin list sits in a real record. The decoy
|
||||
// same-name key in an EARLIER payload field pins the depth-aware scan: a
|
||||
// naive first-match sweep of the head would read it as the tier, while
|
||||
// the full-JSON path only ever reads thread_settings.service_tier.
|
||||
const line = JSON.stringify({
|
||||
type: 'event_msg',
|
||||
timestamp: '2026-09-28T10:00:01Z',
|
||||
payload: {
|
||||
type: 'thread_settings_applied',
|
||||
editor_context: { service_tier: 'default' },
|
||||
thread_settings: {
|
||||
model: 'gpt-5.4',
|
||||
service_tier: 'priority',
|
||||
cwd: '/Users/test/myproject',
|
||||
permission_profile: 'x'.repeat(70 * 1024),
|
||||
},
|
||||
},
|
||||
})
|
||||
const calls = await parseCalls([
|
||||
sessionMeta({ session_id: 'sess-big-tier', model: 'gpt-5.4', timestamp: '2026-09-28T10:00:00Z' }),
|
||||
line,
|
||||
userMessage('big settings 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('fast')
|
||||
})
|
||||
|
||||
it('discards a warm v18 exact hit so priority turns reprice (#1075 pattern)', 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-09-28', 'rollout-stale-tier.jsonl', [
|
||||
sessionMeta({ session_id: 'sess-stale-tier', model: 'gpt-5.4', timestamp: '2026-09-28T10:00:00Z' }),
|
||||
threadSettings('priority', '2026-09-28T10:00:01Z'),
|
||||
userMessage('fast 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 } }),
|
||||
])
|
||||
const st = await stat(filePath)
|
||||
// Main's #1618 already owns v18, and that file holds the pre-fix
|
||||
// standard-rate cost (and speed) verbatim, so it must not be served.
|
||||
expect(CODEX_CACHE_VERSION).toBeGreaterThan(18)
|
||||
const standardCost = 800 * GPT54.input + 200 * GPT54.cacheRead + 500 * GPT54.output
|
||||
await writeFile(join(cacheDir, codexCacheFileName(18)), JSON.stringify({
|
||||
version: 18,
|
||||
files: {
|
||||
[filePath]: {
|
||||
mtimeMs: st.mtimeMs,
|
||||
sizeBytes: st.size,
|
||||
project: 'test',
|
||||
calls: [{
|
||||
provider: 'codex',
|
||||
model: 'gpt-5.4',
|
||||
inputTokens: 800,
|
||||
outputTokens: 500,
|
||||
cacheCreationInputTokens: 0,
|
||||
cacheReadInputTokens: 200,
|
||||
cachedInputTokens: 200,
|
||||
reasoningTokens: 0,
|
||||
webSearchRequests: 0,
|
||||
costUSD: standardCost,
|
||||
tools: [],
|
||||
bashCommands: [],
|
||||
timestamp: '2026-09-28T10:01:30Z',
|
||||
speed: 'standard',
|
||||
deduplicationKey: 'stale-tier',
|
||||
}],
|
||||
},
|
||||
},
|
||||
}))
|
||||
clearCodexMemCaches()
|
||||
|
||||
const calls = await withLiveGpt54(() => {
|
||||
const provider = createCodexProvider(tmpDir)
|
||||
const parsed: ParsedProviderCall[] = []
|
||||
return (async () => {
|
||||
for await (const call of provider.createSessionParser({ path: filePath, project: 'test', provider: 'codex' }, new Set()).parse()) {
|
||||
parsed.push(call)
|
||||
}
|
||||
return parsed
|
||||
})()
|
||||
})
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.speed).toBe('fast')
|
||||
expect(calls[0]!.costUSD).toBeCloseTo(standardCost * 2, 12)
|
||||
} finally {
|
||||
clearCodexMemCaches()
|
||||
if (prev === undefined) delete process.env['CODEBURN_CACHE_DIR']
|
||||
else process.env['CODEBURN_CACHE_DIR'] = prev
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue