mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-21 05:23:19 +00:00
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:
parent
6d9d233d4b
commit
3afcbd9480
12 changed files with 241 additions and 27 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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> = {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 }],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue