Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,7 @@ struct CodexLogFileParser: Sendable {
events.append(CodexLogUsageScanner.Event(
timestamp: timestamp,
model: model,
pricingModel: model == "codex-auto-review"
? CodexLogUsageScanner.autoReviewFallback(at: timestampRaw)
: model == "gpt-reserve" ? CodexLogUsageScanner.reservePricingModel : nil,
pricingModel: model == "gpt-reserve" ? CodexLogUsageScanner.reservePricingModel : nil,
input: usage.input,
cached: min(usage.cached, usage.input),
output: usage.output,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,9 @@ extension CodexLogUsageScanner {
guard let model = trimmedModel else {
continue
}
let pricingModel = event.pricingModel ?? model
let resolution = CodexUsagePricing.resolveRates(pricing: pricing, model: pricingModel)
// Date-aware auto-review pricing also handles caches with a paid or absent pricing model.
let pricingModel = model == CodexUsagePricing.autoReviewModel ? model : event.pricingModel ?? model
let resolution = CodexUsagePricing.resolveRates(pricing: pricing, model: pricingModel, at: event.timestamp)
var rateModel = resolution.rateModel
var resolvedRates = resolution.rates
var appliesCodexFastTier = resolution.isFastAlias ? resolution.hasBaseRates : event.isFast
Expand Down
32 changes: 3 additions & 29 deletions Sources/OpenUsage/Providers/Codex/CodexLogUsageScanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ import Foundation
/// is a re-emitted stale snapshot, not new usage, and is skipped even when it carries a
/// `last_token_usage`.
/// - Early sessions without model metadata fall back to `gpt-5`. The `codex-auto-review` slug stays
/// visible in usage breakdowns and carries a dated fallback model only for cost estimation.
/// visible in usage breakdowns with its measured tokens. It uses dated model estimates before
/// October 6, 2026 (00:00 UTC), and zero cost from then on.
/// The `gpt-reserve` slug (Luna Reserve fallback after regular usage is exhausted) stays visible
/// the same way and prices at `gpt-5.6-luna` rates.
/// - Identical events (same timestamp + model + token counts) appearing in multiple files (copied
Expand Down Expand Up @@ -78,7 +79,7 @@ actor CodexLogUsageScanner {
/// once. The version is the parser schema version; bump it when `Event` semantics change.
private static let sharedScanner = IncrementalJSONLScanner<Event>(
logTag: LogTag.plugin("codex"),
persistence: JSONLScanCachePersistence(namespace: "codex", schemaVersion: 5)
persistence: JSONLScanCachePersistence(namespace: "codex", schemaVersion: 6)
)

static func flushPersistentCacheWrites() async {
Expand Down Expand Up @@ -309,33 +310,6 @@ actor CodexLogUsageScanner {
return model
}

/// `codex-auto-review` release timeline (newest first), from ccusage's embedded snapshot: a
/// line dated on/after a release prices as that codex model.
///
/// The `gpt-5.6-luna` entry is ours; ccusage's snapshot still stops at gpt-5.5. OpenAI moved
/// auto-review onto the GPT-5.6 family when it shipped on 2026-07-09, and the Codex model
/// catalog (`~/.codex/models_cache.json`) lists `codex-auto-review` with Luna's exact profile.
/// Without this entry every auto-review event since July prices at gpt-5.5 rates, which are 25x
/// Luna's across input, cache reads and output alike.
private static let autoReviewFallbacks: [(releasedOn: String, model: String)] = [
("2026-07-09", "gpt-5.6-luna"),
("2026-04-23", "gpt-5.5"),
("2026-03-05", "gpt-5.4"),
("2026-02-05", "gpt-5.3-codex"),
("2025-12-11", "gpt-5.2-codex"),
("2025-11-13", "gpt-5.1-codex"),
("2025-09-15", "gpt-5-codex"),
("2025-08-07", "gpt-5")
]

static func autoReviewFallback(at timestamp: String) -> String {
let date = String(timestamp.prefix(10))
guard date.count == 10, date.range(of: #"^\d{4}-\d{2}-\d{2}$"#, options: .regularExpression) != nil else {
return "gpt-5"
}
return autoReviewFallbacks.first(where: { date >= $0.releasedOn })?.model ?? "gpt-5"
}

/// Luna Reserve keeps its `gpt-reserve` slug in breakdowns while using Luna's cost estimates.
static let reservePricingModel = "gpt-5.6-luna"

Expand Down
2 changes: 1 addition & 1 deletion Sources/OpenUsage/Providers/Codex/CodexProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ final class CodexProvider: ProviderRuntime {
)
async let pi = claimsPiUsage ? piUsageScanner.scan(
cardID: piCardID, now: now(), pricing: pricing,
estimateCost: { CodexUsagePricing.estimatedCost(pricing: pricing, model: $0, tokens: $1) }
estimateCost: { CodexUsagePricing.estimatedCost(pricing: pricing, model: $0, tokens: $1, at: $2) }
) : nil
async let openCode = claims.ownsDefaultLogin ? openCodeUsageScanner.scan(now: now(), pricing: pricing) : nil
let (nativeScan, piScan, openCodeScan) = await (native, pi, openCode)
Expand Down
58 changes: 48 additions & 10 deletions Sources/OpenUsage/Providers/Codex/CodexUsagePricing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,34 @@ import Foundation
/// prompt-cache, and priority-tier rules that must be applied consistently regardless of which local
/// tool produced the request.
enum CodexUsagePricing {
/// Everything Codex pricing derives from the model slug alone, resolved once so a scanner pricing
static let autoReviewModel = "codex-auto-review"
/// The announcement establishes a calendar date, not a precise activation time. Use that UTC
/// day's start, without making earlier estimates free: https://x.com/thsottiaux/status/2107368734981517634.
static let autoReviewFreeSince = OpenUsageISO8601.date(from: "2026-10-06T00:00:00Z")!

/// Preserve the dated model estimates used before auto-review became free.
private static let autoReviewFallbacks: [(releasedOn: Date, model: String)] = [
("2026-07-09", "gpt-5.6-luna"),
("2026-04-23", "gpt-5.5"),
("2026-03-05", "gpt-5.4"),
("2026-02-05", "gpt-5.3-codex"),
("2025-12-11", "gpt-5.2-codex"),
("2025-11-13", "gpt-5.1-codex"),
("2025-09-15", "gpt-5-codex"),
("2025-08-07", "gpt-5")
].map { (OpenUsageISO8601.date(from: $0.0 + "T00:00:00Z")!, $0.1) }

static func isFreeAutoReview(model: String, at timestamp: Date) -> Bool {
model == autoReviewModel && timestamp >= autoReviewFreeSince
}

/// Also identifies a preparation-cache entry: auto-review's historical model or its free era.
static func pricingModel(for model: String, at timestamp: Date) -> String {
guard model == autoReviewModel, timestamp < autoReviewFreeSince else { return model }
return autoReviewFallbacks.first(where: { timestamp >= $0.releasedOn })?.model ?? "gpt-5"
}

/// Codex pricing for one effective model and pricing era, resolved once so a scanner pricing
/// thousands of requests does not re-walk the supplement's alias rules per row.
struct Prepared: Sendable {
/// Base rates with Codex's long-context, cache, and priority adjustments already applied.
Expand All @@ -27,23 +54,34 @@ enum CodexUsagePricing {
/// Codex speed is a provider tier, not Cursor's `-fast` price variant. Resolve a fast alias through
/// its unscaled base rates so the Codex multiplier applies once; if a fast-only model has no base
/// entry, its already-scaled rate is retained and no second multiplier is applied.
static func resolveRates(pricing: ModelPricing, model: String) -> RateResolution {
let canonicalModel = pricing.canonicalName(for: model)
static func resolveRates(pricing: ModelPricing, model: String, at timestamp: Date) -> RateResolution {
// Free requests keep their measured tokens and model name, without a catalog or paid fallback.
if isFreeAutoReview(model: model, at: timestamp) {
return RateResolution(
rates: ModelRates(inputPerMillion: 0, outputPerMillion: 0,
cacheWritePerMillion: 0, cacheReadPerMillion: 0),
rateModel: model,
isFastAlias: false,
hasBaseRates: true
)
}
let effectiveModel = pricingModel(for: model, at: timestamp)
let canonicalModel = pricing.canonicalName(for: effectiveModel)
let isFastAlias = canonicalModel.hasSuffix("-fast")
let rateModel = isFastAlias ? String(canonicalModel.dropLast("-fast".count)) : canonicalModel
let baseRates = pricing.resolve(model: rateModel)
return RateResolution(
rates: baseRates ?? pricing.resolve(model: model),
rates: baseRates ?? pricing.resolve(model: effectiveModel),
rateModel: rateModel,
isFastAlias: isFastAlias,
hasBaseRates: baseRates != nil
)
}

/// Resolves the model once. Callers pricing many requests should hold the result and reuse it
/// rather than calling `estimatedCost` per request.
static func prepare(pricing: ModelPricing, model: String) -> Prepared? {
let resolution = resolveRates(pricing: pricing, model: model)
/// Resolves a timestamped request's model. Reuse only for requests with the same effective
/// `pricingModel(for:at:)`, so historical and free auto-review never share prepared rates.
static func prepare(pricing: ModelPricing, model: String, at timestamp: Date) -> Prepared? {
let resolution = resolveRates(pricing: pricing, model: model, at: timestamp)
guard let rates = resolution.rates else { return nil }
return Prepared(
rates: adjusted(rates, model: resolution.rateModel),
Expand All @@ -53,8 +91,8 @@ enum CodexUsagePricing {

/// Prices an already normalized request. Unlike native Codex rollout events, `tokens.input` here
/// is non-cached input; cache reads/writes are disjoint buckets in `TokenBreakdown`.
static func estimatedCost(pricing: ModelPricing, model: String, tokens: TokenBreakdown) -> Double? {
guard let prepared = prepare(pricing: pricing, model: model) else { return nil }
static func estimatedCost(pricing: ModelPricing, model: String, tokens: TokenBreakdown, at timestamp: Date) -> Double? {
guard let prepared = prepare(pricing: pricing, model: model, at: timestamp) else { return nil }
return cost(prepared: prepared, tokens: tokens)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,18 +83,19 @@ struct OpenCodeCodexUsageScanner: Sendable {
guard anyOAuth, readAny || failures.isEmpty else { return nil }

var accumulator = DailyUsageAccumulator()
// Codex pricing depends only on the model slug, and resolving one walks every supplement alias
// rule. Real histories run to thousands of rows across a handful of models, so resolve once each.
// Resolve once per effective pricing model. Auto-review changes rates over time, so caching
// by its raw slug alone would let historical and free requests reuse each other's rates.
var preparedByModel: [String: CodexUsagePricing.Prepared?] = [:]
for row in Self.deduplicated(rows) where row.timestamp >= since {
let day = DailyUsageAccumulator.dayKey(from: row.timestamp)
guard let model = row.model.nilIfEmpty else { continue }
let pricingModel = CodexUsagePricing.pricingModel(for: model, at: row.timestamp)
let prepared: CodexUsagePricing.Prepared?
if let cached = preparedByModel[model] {
if let cached = preparedByModel[pricingModel] {
prepared = cached
} else {
prepared = CodexUsagePricing.prepare(pricing: pricing, model: model)
preparedByModel[model] = prepared
prepared = CodexUsagePricing.prepare(pricing: pricing, model: model, at: row.timestamp)
preparedByModel[pricingModel] = prepared
}
guard let prepared else {
if row.reportedTotalTokens > 0 {
Expand Down
22 changes: 13 additions & 9 deletions Sources/OpenUsage/Providers/Pi/PiUsageScanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import Foundation
///
/// Pi records an authoritative per-message `usage.cost.total` (like OpenCode), so that carried cost is
/// used when present; when pi logs a `$0` cost (subscription usage it doesn't impute), the tokens are
/// priced through the shared engine instead — the same `carried cost, else price` rule the Claude and
/// Codex log scanners use. Pi's usage shape differs from Claude Code's (`usage.input`/`output`,
/// priced through the shared engine instead. Codex auto-review requests from its free date use $0
/// even when pi carries an estimate. Pi's usage shape differs from Claude Code's (`usage.input`/`output`,
/// nested `usage.cost.total`), so it has its own parser rather than routing through those scanners.
///
/// An actor holding the versioned incremental parse cache (keyed path + size + mtime) in memory and
Expand All @@ -17,7 +17,7 @@ actor PiUsageScanner {
/// How a card prices a pi request that carries no cost of its own. Providers with their own
/// request rules (Codex's long-context and priority tiers) supply their estimator; the rest use
/// the shared pricing engine.
typealias CostEstimator = @Sendable (String, TokenBreakdown) -> Double?
typealias CostEstimator = @Sendable (String, TokenBreakdown, Date) -> Double?

static let shared = PiUsageScanner()

Expand Down Expand Up @@ -51,7 +51,7 @@ actor PiUsageScanner {
var timestamp: Date
var cardID: String
var model: String
/// pi's own `usage.cost.total`, used directly when > 0; nil/0 falls through to engine pricing.
/// pi's own `usage.cost.total`, used when > 0 except free Codex auto-review; nil/0 is estimated.
var carriedCost: Double?
/// The token buckets, for pricing the fall-through case.
var tokens: TokenBreakdown
Expand Down Expand Up @@ -151,25 +151,29 @@ actor PiUsageScanner {
return out
}

/// Bucket the card's entries into local calendar days. Cost is pi's carried total when it recorded
/// one, else the tokens priced through `pricing`; a model that can't be priced and carries no cost
/// Bucket entries into local calendar days. Cost is pi's carried total when it recorded one,
/// except free Codex auto-review, else the timestamped tokens priced through `pricing`.
/// A model that can't be priced and carries no cost
/// is excluded from the totals and surfaced as the tile's unknown-model warning, matching the log
/// scanners.
static func aggregate(
entries: [Entry], cardID: String, since: Date, pricing: ModelPricing,
estimateCost: CostEstimator? = nil
) -> LogUsageScan {
let estimate = estimateCost ?? { pricing.estimatedCostDollars(model: $0, tokens: $1) }
let estimate = estimateCost ?? { model, tokens, _ in pricing.estimatedCostDollars(model: model, tokens: tokens) }
var accumulator = DailyUsageAccumulator()
for entry in entries where entry.cardID == cardID && entry.timestamp >= since {
let day = DailyUsageAccumulator.dayKey(from: entry.timestamp)
let trimmedModel = entry.model.nilIfEmpty
let modelName = trimmedModel ?? ModelUsageEntry.unattributedModelName

let cost: Double
if let carried = entry.carriedCost, carried > 0 {
if cardID == "codex", CodexUsagePricing.isFreeAutoReview(model: modelName, at: entry.timestamp) {
// A carried estimate must not charge for free ChatGPT auto-review requests.
cost = 0
} else if let carried = entry.carriedCost, carried > 0 {
cost = carried
} else if let model = trimmedModel, let estimated = estimate(model, entry.tokens) {
} else if let model = trimmedModel, let estimated = estimate(model, entry.tokens, entry.timestamp) {
cost = estimated
} else {
if let model = trimmedModel, entry.reportedTotalTokens > 0 {
Expand Down
6 changes: 6 additions & 0 deletions Sources/OpenUsage/Providers/SpendTileMapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,12 @@ enum SpendTileMapper {
var namedCount = 0

for entry in entries {
// Keep auto-review named even when a period combines paid historical and free
// requests: its small aggregate cost must not fold their measured tokens into Other.
if entry.model == CodexUsagePricing.autoReviewModel {
visible.append(entry)
continue
}
// Tokens the logs couldn't tie to a model (Grok) read as noise under their own
// "Unattributed" row — the panel is an insight, not an accounting ledger, so they just
// count into Other however large they are.
Expand Down
Loading
Loading