Skip to content
Merged
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
22 changes: 15 additions & 7 deletions Sources/Runway/Providers/Codex/CodexLogUsageAggregation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ extension CodexLogUsageScanner {
private struct EventKey: Hashable {
var timestamp: Date
var model: String
var pricingModel: String?
var input: Int
var cached: Int
var output: Int
Expand All @@ -13,6 +14,7 @@ extension CodexLogUsageScanner {

private struct PricingContextKey: Hashable {
var model: String
var pricingModel: String?
var isFast: Bool
}

Expand Down Expand Up @@ -54,19 +56,23 @@ extension CodexLogUsageScanner {

for event in events where event.timestamp >= since {
let key = EventKey(
timestamp: event.timestamp, model: event.model, input: event.input,
cached: event.cached, output: event.output, reasoning: event.reasoning, total: event.total
timestamp: event.timestamp, model: event.model, pricingModel: event.pricingModel,
input: event.input, cached: event.cached, output: event.output,
reasoning: event.reasoning, total: event.total
)
guard seen.insert(key).inserted else { continue }

let day = dayKeys.key(for: event.timestamp)
let contextKey = PricingContextKey(model: event.model, isFast: event.isFast)
let contextKey = PricingContextKey(
model: event.model, pricingModel: event.pricingModel, isFast: event.isFast
)
let resolution: PricingResolution
if let cached = pricingContexts[contextKey] {
resolution = cached
} else {
resolution = resolvePricingContext(
rawModel: event.model,
pricingModel: event.pricingModel,
isFast: event.isFast,
pricing: pricing
)
Expand All @@ -93,16 +99,18 @@ extension CodexLogUsageScanner {

private static func resolvePricingContext(
rawModel: String,
pricingModel: String?,
isFast: Bool,
pricing: ModelPricing
) -> PricingResolution {
// One trimmed slug feeds pricing, the unknown-model warning, and the breakdown key alike —
// diverging spellings would let the warning triangle and hover panel disagree.
// The breakdown, unknown-model warning, and hover panel share the measured slug. Rates
// may come from a dated fallback (auto-review) that must not replace that identity.
guard let model = rawModel.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty else {
return .unpriced(model: nil)
}
let rateSource = pricingModel?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty ?? model

let canonicalModel = pricing.supplement.canonicalName(for: model) ?? model
let canonicalModel = pricing.supplement.canonicalName(for: rateSource) ?? rateSource
let isFastAlias = canonicalModel.hasSuffix("-fast")
let rateModel = isFastAlias ? String(canonicalModel.dropLast("-fast".count)) : canonicalModel

Expand All @@ -111,7 +119,7 @@ extension CodexLogUsageScanner {
// If a third-party fast-only model has no base entry, retain its already-scaled rate
// and do not apply a second speed multiplier.
let baseRates = pricing.resolve(model: rateModel)
guard let rates = baseRates ?? pricing.resolve(model: model) else {
guard let rates = baseRates ?? pricing.resolve(model: rateSource) else {
return .unpriced(model: model)
}
let appliesCodexFastTier = isFastAlias ? baseRates != nil : isFast
Expand Down
42 changes: 21 additions & 21 deletions Sources/Runway/Providers/Codex/CodexLogUsageScanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ import Foundation
/// - A `token_count` line whose cumulative `total_token_usage` is unchanged from the previous line
/// 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 retired `codex-auto-review`
/// slug maps to the codex model that was current at the line's date.
/// - 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.
/// - Identical events (same timestamp + model + token counts) appearing in multiple files (copied
/// session logs) count once.
/// - Cost per event: `(input - cached) x input rate + cached x cache-read rate + output x output
Expand Down Expand Up @@ -67,9 +67,12 @@ actor CodexLogUsageScanner {
/// One turn's token usage, normalized from a `token_count` line (deltas already applied).
/// `isFast` records whether the session was on the fast/priority service tier when the turn
/// ran, tracked from the session's own log; absent tier metadata means standard.
/// `pricingModel` is the dated GPT fallback used only for auto-review cost; nil when `model`
/// is already the rate key.
struct Event: Codable, Sendable, Equatable {
var timestamp: Date
var model: String
var pricingModel: String? = nil
var input: Int
var cached: Int
var output: Int
Expand All @@ -82,7 +85,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: 1)
persistence: JSONLScanCachePersistence(namespace: "codex", schemaVersion: 2)
)

static func flushPersistentCacheWrites() async {
Expand Down Expand Up @@ -366,13 +369,13 @@ actor CodexLogUsageScanner {
let parsedModel = modelName(in: payload) ?? info.flatMap(modelName(in:))
let model = resolveModel(
parsed: parsedModel,
timestamp: timestampRaw,
currentModel: &state.currentModel
)

events.append(Event(
timestamp: timestamp,
model: model,
pricingModel: model == Self.autoReviewModel ? autoReviewFallback(at: timestampRaw) : nil,
input: usage.input,
cached: min(usage.cached, usage.input),
output: usage.output,
Expand Down Expand Up @@ -508,38 +511,35 @@ actor CodexLogUsageScanner {
}
}

/// ccusage's model resolution: an explicit model on the line updates the session's current
/// model; otherwise the tracked model applies; a session with no metadata at all falls back to
/// `gpt-5`. The retired `codex-auto-review` slug maps to whichever codex model was current at
/// the line's date.
/// An explicit model on the line updates the session's current model. Otherwise the tracked
/// model applies, and a session with no metadata falls back to `gpt-5`.
static func resolveModel(
parsed: String?,
timestamp: String,
currentModel: inout String?
) -> String {
if let parsed {
currentModel = parsed
return parsed
}
var model: String
if let parsed {
model = parsed
} else if let current = currentModel {
model = current
} else {
currentModel = "gpt-5"
model = "gpt-5"
if let current = currentModel {
return current
}
if model == Self.autoReviewModel {
model = autoReviewFallback(at: timestamp)
}
return model
currentModel = "gpt-5"
return "gpt-5"
}

private static let autoReviewModel = "codex-auto-review"

/// `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"),
Expand Down
3 changes: 2 additions & 1 deletion Sources/Runway/Resources/pricing_supplement.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"$comment": "Runway pricing supplement. Prices Cursor-native models that no public catalog carries, supplies fast-variant multipliers the catalogs omit, and maps provider log/CSV model slugs to canonical pricing keys (LiteLLM keys, models.dev ids, or entries in this file). Cursor Router rows name the routed model in prose instead of a slug ('Opus 5 (Auto Balanced)'), so those labels get alias rules too. Published to update-feed by .github/workflows/pricing-supplement.yml on every change, so installed apps pick edits up within about an hour - no release needed. Rates are USD per million tokens. Sources: https://cursor.com/docs/models-and-pricing.md for Cursor-native entries; https://developers.openai.com/api/docs/models/daybreak-blue-latest and https://developers.openai.com/api/docs/pricing for Daybreak aliases and rates.",
"updated_at": "2026-08-22",
"updated_at": "2026-08-22T20:45:00Z",
"pricing": {
"auto": {
"input_per_million": 1.25,
Expand Down Expand Up @@ -201,6 +201,7 @@
{ "pattern": "^grok-build-0\\.1$", "canonical": "grok-build-0.1" },
{ "pattern": "^grok-code-fast-1$", "canonical": "grok-build-0.1" },
{ "pattern": "^grok-build$", "canonical": "grok-build-0.1" },
{ "pattern": "^grok-proxy$", "canonical": "grok-build-0.1" },
{ "pattern": "^grok-composer-2\\.5-fast$", "canonical": "composer-2.5-fast" },
{ "pattern": "^gpt-5\\.1-codex-max(?:-(?:low|medium|high|xhigh))?-fast$", "canonical": "gpt-5.1-codex-max-fast" },
{ "pattern": "^gpt-5\\.1-codex-max(?:-(?:low|medium|high|xhigh))?$", "canonical": "gpt-5.1-codex-max" },
Expand Down
66 changes: 62 additions & 4 deletions Tests/RunwayTests/CodexLogUsageScannerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -203,14 +203,17 @@ final class CodexLogUsageScannerTests: XCTestCase {
// MARK: - Auto-review fallbacks

func testAutoReviewSlugMapsToDatedCodexModel() {
XCTAssertEqual(CodexLogUsageScanner.autoReviewFallback(at: "2026-08-20T00:00:00Z"), "gpt-5.6-luna")
XCTAssertEqual(CodexLogUsageScanner.autoReviewFallback(at: "2026-07-09T00:00:00Z"), "gpt-5.6-luna")
XCTAssertEqual(CodexLogUsageScanner.autoReviewFallback(at: "2026-07-08T23:59:59Z"), "gpt-5.5")
XCTAssertEqual(CodexLogUsageScanner.autoReviewFallback(at: "2026-05-01T00:00:00Z"), "gpt-5.5")
XCTAssertEqual(CodexLogUsageScanner.autoReviewFallback(at: "2026-03-10T00:00:00Z"), "gpt-5.4")
XCTAssertEqual(CodexLogUsageScanner.autoReviewFallback(at: "2025-12-25T00:00:00Z"), "gpt-5.2-codex")
XCTAssertEqual(CodexLogUsageScanner.autoReviewFallback(at: "2025-01-01T00:00:00Z"), "gpt-5")
XCTAssertEqual(CodexLogUsageScanner.autoReviewFallback(at: "garbage"), "gpt-5")
}

func testAutoReviewLinesResolveByLineDate() {
func testAutoReviewLinesKeepSlugAndResolvePricingByLineDate() {
let lines = [
CodexLogFixture.turnContext(timestamp: "2026-03-10T08:00:00.000Z", model: "codex-auto-review"),
CodexLogFixture.tokenCount(
Expand All @@ -219,7 +222,23 @@ final class CodexLogUsageScannerTests: XCTestCase {
)
].joined(separator: "\n")

XCTAssertEqual(CodexLogUsageScanner.parseFile(Data(lines.utf8)).first?.model, "gpt-5.4")
let event = CodexLogUsageScanner.parseFile(Data(lines.utf8)).first
XCTAssertEqual(event?.model, "codex-auto-review")
XCTAssertEqual(event?.pricingModel, "gpt-5.4")
}

func testRecentAutoReviewLinesUseLunaPricing() {
let lines = [
CodexLogFixture.turnContext(timestamp: "2026-08-20T08:00:00.000Z", model: "codex-auto-review"),
CodexLogFixture.tokenCount(
timestamp: "2026-08-20T08:01:00.000Z",
last: CodexLogFixture.usage(input: 10, output: 5)
)
].joined(separator: "\n")

let event = CodexLogUsageScanner.parseFile(Data(lines.utf8)).first
XCTAssertEqual(event?.model, "codex-auto-review")
XCTAssertEqual(event?.pricingModel, "gpt-5.6-luna")
}

// MARK: - Chunked (tail) parsing
Expand Down Expand Up @@ -521,11 +540,11 @@ final class CodexLogUsageScannerTests: XCTestCase {

private func makeEvent(
_ timestamp: String, model: String = "gpt-5.2", input: Int = 100, cached: Int = 0,
output: Int = 50, reasoning: Int = 0, isFast: Bool = false
output: Int = 50, reasoning: Int = 0, isFast: Bool = false, pricingModel: String? = nil
) -> CodexLogUsageScanner.Event {
CodexLogUsageScanner.Event(
timestamp: RunwayISO8601.date(from: timestamp)!,
model: model, input: input, cached: cached, output: output, reasoning: reasoning,
model: model, pricingModel: pricingModel, input: input, cached: cached, output: output, reasoning: reasoning,
total: input + output, isFast: isFast
)
}
Expand All @@ -550,6 +569,45 @@ final class CodexLogUsageScannerTests: XCTestCase {
XCTAssertEqual(may12Models, [ModelUsageEntry(model: "gpt-5.2", totalTokens: 300, costUSD: 0.5)])
}

func testAggregateAttributesAutoReviewUsageToSlugWhileUsingFallbackPrice() {
let scan = CodexLogUsageScanner.aggregate(
events: [makeEvent(
"2026-05-12T08:00:00.000Z", model: "codex-auto-review",
pricingModel: "gpt-5.2"
)],
since: .distantPast, pricing: fixedRates()
)

XCTAssertEqual(scan.series.daily.first?.costUSD ?? 0, 0.25, accuracy: 0.0001)
XCTAssertEqual(
scan.modelUsage?.daily.first?.models,
[ModelUsageEntry(model: "codex-auto-review", totalTokens: 150, costUSD: 0.25)]
)
}

func testAggregatePricesAutoReviewAcrossLunaCutoffIndependently() {
let luna = makeEvent(
"2026-08-20T08:00:00.000Z", model: "codex-auto-review",
input: 100_000, output: 0, pricingModel: "gpt-5.6-luna"
)
let gpt55 = makeEvent(
"2026-07-08T08:00:00.000Z", model: "codex-auto-review",
input: 100_000, output: 0, pricingModel: "gpt-5.5"
)
let scan = CodexLogUsageScanner.aggregate(
events: [luna, gpt55], since: .distantPast, pricing: pricing
)

let lunaDay = scan.series.daily.first { $0.date == "2026-08-20" }
let gpt55Day = scan.series.daily.first { $0.date == "2026-07-08" }
XCTAssertEqual(lunaDay?.costUSD ?? 0, 0.02, accuracy: 0.0001)
XCTAssertEqual(gpt55Day?.costUSD ?? 0, 0.50, accuracy: 0.0001)
XCTAssertEqual(
scan.modelUsage?.daily.first { $0.date == "2026-08-20" }?.models,
[ModelUsageEntry(model: "codex-auto-review", totalTokens: 100_000, costUSD: 0.02)]
)
}

func testAggregateFeedsSingleModelTodayBreakdown() throws {
let now = Date()
let event = CodexLogUsageScanner.Event(
Expand Down
2 changes: 2 additions & 0 deletions Tests/RunwayTests/PricingBundledResourceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,8 @@ final class PricingBundledResourceTests: XCTestCase {
func testGrokCLIModelAliases() {
let pricing = Self.pricing
XCTAssertEqual(pricing.resolve(model: "grok-build")?.inputPerMillion, 1)
// grok-proxy is the recent Grok Build CLI log slug for the same model.
XCTAssertEqual(pricing.resolve(model: "grok-proxy"), pricing.resolve(model: "grok-build-0.1"))
XCTAssertEqual(pricing.resolve(model: "grok-composer-2.5-fast")?.inputPerMillion, 3)
XCTAssertEqual(pricing.resolve(model: "grok-4.5-build"), pricing.resolve(model: "grok-4.5"))
XCTAssertEqual(pricing.resolve(model: "grok-4.6-build"), pricing.resolve(model: "grok-4.6"))
Expand Down
2 changes: 1 addition & 1 deletion docs/providers/codex.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ Additional cards use stable ids such as `codex@ab12cd34`. You can rename any Cod

## The spend tiles

Today / Yesterday / Last 30 Days are computed **locally**: Runway reads the Codex CLI's session rollouts under `~/.codex/sessions/` and `archived_sessions/` (or `$CODEX_HOME`) itself — no external tools needed. Symlinks are followed, so a Codex home linked into a synced location (say, a Dropbox folder) is read all the same. Codex usage from the [pi](https://github.com/earendil-works/pi) coding agent counts too: Runway reads pi's session logs under `~/.pi/agent/sessions/` (or `$PI_CODING_AGENT_SESSION_DIR`) and folds any Codex usage there into the same tiles and trend. pi records its own per-message cost, so those dollars come straight from pi; Runway does not re-estimate them. Days are grouped in your Mac's local time zone, so they line up with your own calendar. Each period is one tile showing cost and tokens together (`$4.08 · 1.2M tokens`); a day with no usage reads **No data** rather than a misleading `$0.00 · 0 tokens` — the same as every other spend-tracking provider. The live Session and Weekly meters are unaffected. The dollars are estimated from token counts at API rates (that's the ⓘ) using the shared [model pricing](../pricing.md); sessions that ran on the fast/priority service tier — as recorded in each session's own log — use the fast rates for exactly those turns. Older logs without tier metadata, and everything else, price at standard rates; Runway does not consult the current `config.toml` setting, so a tier change never reprices past days. The token counts themselves are measured. Subagent and forked sessions copy their parent session's token history into their own log; Runway recognizes those copies and counts each token once, no matter how many subagents a session spawns. No log data leaves your Mac.
Today / Yesterday / Last 30 Days are computed **locally**: Runway reads the Codex CLI's session rollouts under `~/.codex/sessions/` and `archived_sessions/` (or `$CODEX_HOME`) itself — no external tools needed. Symlinks are followed, so a Codex home linked into a synced location (say, a Dropbox folder) is read all the same. Codex usage from the [pi](https://github.com/earendil-works/pi) coding agent counts too: Runway reads pi's session logs under `~/.pi/agent/sessions/` (or `$PI_CODING_AGENT_SESSION_DIR`) and folds any Codex usage there into the same tiles and trend. pi records its own per-message cost, so those dollars come straight from pi; Runway does not re-estimate them. Days are grouped in your Mac's local time zone, so they line up with your own calendar. Each period is one tile showing cost and tokens together (`$4.08 · 1.2M tokens`); a day with no usage reads **No data** rather than a misleading `$0.00 · 0 tokens` — the same as every other spend-tracking provider. The live Session and Weekly meters are unaffected. The dollars are estimated from token counts at API rates (that's the ⓘ) using the shared [model pricing](../pricing.md); sessions that ran on the fast/priority service tier — as recorded in each session's own log — use the fast rates for exactly those turns. Older logs without tier metadata, and everything else, price at standard rates; Runway does not consult the current `config.toml` setting, so a tier change never reprices past days. Auto-review usage keeps its `codex-auto-review` name in the model breakdown, while its cost uses the dated model fallback available for that event. The token counts themselves are measured. Subagent and forked sessions copy their parent session's token history into their own log; Runway recognizes those copies and counts each token once, no matter how many subagents a session spawns. No log data leaves your Mac.

For supported GPT-5.4, GPT-5.5, and GPT-5.6 models, requests above 272k input tokens use OpenAI's long-context rates for the whole request. Cached input uses the published cache-read discount when the pricing source provides one; otherwise it is estimated at the full input rate. Fast/priority estimates use each model's published Codex multiplier (for example, GPT-5.5 uses 2.5×); model names ending in `-fast` are normalized to their unscaled base rate before that multiplier is applied once.

Expand Down