From 91522d332d2b8e0fccc5c1403bac165550064c3e Mon Sep 17 00:00:00 2001 From: Bryan Font Date: Sun, 10 May 2026 16:32:49 -0400 Subject: [PATCH 1/7] feat: add Codex tiered and priority pricing --- .../CostUsage/CodexPriorityTraceScanner.swift | 171 ++++++++++++++++ .../Vendored/CostUsage/CostUsageCache.swift | 3 +- .../Vendored/CostUsage/CostUsagePricing.swift | 112 +++++++++- .../Vendored/CostUsage/CostUsageScanner.swift | 161 ++++++++++++++- .../CodexPriorityTraceScannerTests.swift | 176 ++++++++++++++++ Tests/CodexBarTests/CostUsageCacheTests.swift | 2 +- .../CodexBarTests/CostUsagePricingTests.swift | 120 +++++++++++ .../CostUsageScannerPriorityTests.swift | 192 ++++++++++++++++++ 8 files changed, 920 insertions(+), 17 deletions(-) create mode 100644 Sources/CodexBarCore/Vendored/CostUsage/CodexPriorityTraceScanner.swift create mode 100644 Tests/CodexBarTests/CodexPriorityTraceScannerTests.swift create mode 100644 Tests/CodexBarTests/CostUsageScannerPriorityTests.swift diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CodexPriorityTraceScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CodexPriorityTraceScanner.swift new file mode 100644 index 0000000000..e4205af563 --- /dev/null +++ b/Sources/CodexBarCore/Vendored/CostUsage/CodexPriorityTraceScanner.swift @@ -0,0 +1,171 @@ +import Foundation +#if canImport(SQLite3) +import SQLite3 +#endif + +struct CodexPriorityTurnMetadata: Codable, Equatable, Sendable { + var threadID: String? + var turnID: String + var model: String? + var timestamp: String? +} + +enum CodexPriorityTraceScanner { + private static let requestMarker = "websocket request:" + + static func defaultDatabaseURL() -> URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".codex", isDirectory: true) + .appendingPathComponent("logs_2.sqlite", isDirectory: false) + } + + static func priorityTurns( + databaseURL: URL? = nil, + sinceDayKey: String? = nil, + untilDayKey: String? = nil) -> [String: CodexPriorityTurnMetadata] + { + let url = databaseURL ?? self.defaultDatabaseURL() + guard FileManager.default.fileExists(atPath: url.path) else { return [:] } + + #if canImport(SQLite3) + var db: OpaquePointer? + guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { + sqlite3_close(db) + return [:] + } + defer { sqlite3_close(db) } + sqlite3_busy_timeout(db, 250) + + let query = if sinceDayKey != nil || untilDayKey != nil { + """ + select ts, feedback_log_body + from logs + where ts >= ? and ts < ? and feedback_log_body like '%websocket request:%' + """ + } else { + """ + select ts, feedback_log_body + from logs + where feedback_log_body like '%websocket request:%' + """ + } + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, query, -1, &stmt, nil) == SQLITE_OK else { return [:] } + defer { sqlite3_finalize(stmt) } + + if sinceDayKey != nil || untilDayKey != nil { + let start = self.epochSeconds(forDayKey: sinceDayKey ?? "0000-01-01") ?? 0 + let end = self.epochSeconds(forDayKey: self.nextDayKey(after: untilDayKey ?? "9999-12-30")) + ?? Int64.max + sqlite3_bind_int64(stmt, 1, start) + sqlite3_bind_int64(stmt, 2, end) + } + + var turns: [String: CodexPriorityTurnMetadata] = [:] + while sqlite3_step(stmt) == SQLITE_ROW { + let timestamp = self.timestamp(stmt: stmt, index: 0) + guard self.timestamp(timestamp, isInRangeSince: sinceDayKey, until: untilDayKey), + let body = self.text(stmt: stmt, index: 1), + let parsed = self.parseTraceRow(timestamp: timestamp, body: body) + else { continue } + turns[parsed.turnID] = parsed + } + return turns + #else + return [:] + #endif + } + + static func parseTraceRow(timestamp: String?, body: String) -> CodexPriorityTurnMetadata? { + guard let markerRange = body.range(of: self.requestMarker) else { return nil } + let prefix = String(body[.. String? { + guard let range = text.range(of: "\(name)=") else { return nil } + let tail = text[range.upperBound...] + let value = tail.prefix { char in + !char.isWhitespace && char != "," && char != "]" && char != ")" + } + return value.isEmpty ? nil : String(value) + } + + #if canImport(SQLite3) + private static func text(stmt: OpaquePointer?, index: Int32) -> String? { + guard sqlite3_column_type(stmt, index) != SQLITE_NULL, + let cString = sqlite3_column_text(stmt, index) + else { return nil } + return String(cString: cString) + } + + private static func timestamp(stmt: OpaquePointer?, index: Int32) -> String? { + guard sqlite3_column_type(stmt, index) != SQLITE_NULL else { return nil } + if sqlite3_column_type(stmt, index) == SQLITE_INTEGER { + return String(sqlite3_column_int64(stmt, index)) + } + return self.text(stmt: stmt, index: index) + } + #endif + + private static func timestamp(_ timestamp: String?, isInRangeSince since: String?, until: String?) -> Bool { + guard since != nil || until != nil else { return true } + guard let dayKey = self.dayKey(fromTimestamp: timestamp) else { return false } + if let since, dayKey < since { return false } + if let until, dayKey > until { return false } + return true + } + + private static func dayKey(fromTimestamp timestamp: String?) -> String? { + guard let timestamp else { return nil } + if let seconds = Int64(timestamp) { + return CostUsageScanner.CostUsageDayRange.dayKey( + from: Date(timeIntervalSince1970: TimeInterval(seconds))) + } + let dayKey = timestamp.prefix(10) + return dayKey.count == 10 ? String(dayKey) : nil + } + + private static func nextDayKey(after dayKey: String) -> String { + guard let date = self.localDate(forDayKey: dayKey), + let next = Calendar.current.date(byAdding: .day, value: 1, to: date) + else { return dayKey } + return CostUsageScanner.CostUsageDayRange.dayKey(from: next) + } + + private static func epochSeconds(forDayKey dayKey: String) -> Int64? { + guard let date = self.localDate(forDayKey: dayKey) else { return nil } + return Int64(date.timeIntervalSince1970) + } + + private static func localDate(forDayKey dayKey: String) -> Date? { + let parts = dayKey.split(separator: "-") + guard parts.count == 3, + let year = Int(parts[0]), + let month = Int(parts[1]), + let day = Int(parts[2]) + else { return nil } + var components = DateComponents() + components.calendar = Calendar.current + components.year = year + components.month = month + components.day = day + return components.date + } +} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index 73ccb4c594..5779a50a40 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -4,7 +4,7 @@ enum CostUsageCacheIO { private static func artifactVersion(for provider: UsageProvider) -> Int { switch provider { case .codex: - 4 + 5 case .claude, .vertexai: 2 default: @@ -78,6 +78,7 @@ struct CostUsageFileUsage: Codable { var lastTotals: CostUsageCodexTotals? var sessionId: String? var forkedFromId: String? + var codexRows: [CostUsageScanner.CodexUsageRow]? var claudeRows: [CostUsageScanner.ClaudeUsageRow]? } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift index e40ba08cb4..6179d5de01 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift @@ -1,11 +1,47 @@ import Foundation enum CostUsagePricing { + private static let codexPriorityInputTokenLimit = 400_000 + struct CodexPricing { let inputCostPerToken: Double let outputCostPerToken: Double let cacheReadInputCostPerToken: Double? let displayLabel: String? + + let thresholdTokens: Int? + let inputCostPerTokenAboveThreshold: Double? + let outputCostPerTokenAboveThreshold: Double? + let cacheReadInputCostPerTokenAboveThreshold: Double? + let priorityInputCostPerToken: Double? + let priorityOutputCostPerToken: Double? + let priorityCacheReadInputCostPerToken: Double? + + init( + inputCostPerToken: Double, + outputCostPerToken: Double, + cacheReadInputCostPerToken: Double?, + displayLabel: String?, + thresholdTokens: Int? = nil, + inputCostPerTokenAboveThreshold: Double? = nil, + outputCostPerTokenAboveThreshold: Double? = nil, + cacheReadInputCostPerTokenAboveThreshold: Double? = nil, + priorityInputCostPerToken: Double? = nil, + priorityOutputCostPerToken: Double? = nil, + priorityCacheReadInputCostPerToken: Double? = nil) + { + self.inputCostPerToken = inputCostPerToken + self.outputCostPerToken = outputCostPerToken + self.cacheReadInputCostPerToken = cacheReadInputCostPerToken + self.displayLabel = displayLabel + self.thresholdTokens = thresholdTokens + self.inputCostPerTokenAboveThreshold = inputCostPerTokenAboveThreshold + self.outputCostPerTokenAboveThreshold = outputCostPerTokenAboveThreshold + self.cacheReadInputCostPerTokenAboveThreshold = cacheReadInputCostPerTokenAboveThreshold + self.priorityInputCostPerToken = priorityInputCostPerToken + self.priorityOutputCostPerToken = priorityOutputCostPerToken + self.priorityCacheReadInputCostPerToken = priorityCacheReadInputCostPerToken + } } struct ClaudePricing { @@ -96,12 +132,22 @@ enum CostUsagePricing { inputCostPerToken: 2.5e-6, outputCostPerToken: 1.5e-5, cacheReadInputCostPerToken: 2.5e-7, - displayLabel: nil), + displayLabel: nil, + thresholdTokens: 272_000, + inputCostPerTokenAboveThreshold: 5e-6, + outputCostPerTokenAboveThreshold: 2.25e-5, + cacheReadInputCostPerTokenAboveThreshold: 5e-7, + priorityInputCostPerToken: 5e-6, + priorityOutputCostPerToken: 3e-5, + priorityCacheReadInputCostPerToken: 5e-7), "gpt-5.4-mini": CodexPricing( inputCostPerToken: 7.5e-7, outputCostPerToken: 4.5e-6, cacheReadInputCostPerToken: 7.5e-8, - displayLabel: nil), + displayLabel: nil, + priorityInputCostPerToken: 1.5e-6, + priorityOutputCostPerToken: 9e-6, + priorityCacheReadInputCostPerToken: 1.5e-7), "gpt-5.4-nano": CodexPricing( inputCostPerToken: 2e-7, outputCostPerToken: 1.25e-6, @@ -116,7 +162,14 @@ enum CostUsagePricing { inputCostPerToken: 5e-6, outputCostPerToken: 3e-5, cacheReadInputCostPerToken: 5e-7, - displayLabel: nil), + displayLabel: nil, + thresholdTokens: 272_000, + inputCostPerTokenAboveThreshold: 1e-5, + outputCostPerTokenAboveThreshold: 4.5e-5, + cacheReadInputCostPerTokenAboveThreshold: 1e-6, + priorityInputCostPerToken: 1.25e-5, + priorityOutputCostPerToken: 7.5e-5, + priorityCacheReadInputCostPerToken: 1.25e-6), "gpt-5.5-pro": CodexPricing( inputCostPerToken: 3e-5, outputCostPerToken: 1.8e-4, @@ -330,6 +383,7 @@ enum CostUsagePricing { { return self.codexCostUSD( pricing: lookup.pricing, + thresholdTokens: self.codex[key]?.thresholdTokens, inputTokens: inputTokens, cachedInputTokens: cachedInputTokens, outputTokens: outputTokens) @@ -343,6 +397,33 @@ enum CostUsagePricing { outputTokens: outputTokens) } + static func codexPriorityCostUSD( + model: String, + inputTokens: Int, + cachedInputTokens: Int = 0, + outputTokens: Int) -> Double? + { + let key = self.normalizeCodexModel(model) + guard let pricing = self.codex[key], + let priorityInputCostPerToken = pricing.priorityInputCostPerToken, + let priorityOutputCostPerToken = pricing.priorityOutputCostPerToken + else { return nil } + if max(0, inputTokens) > self.codexPriorityInputTokenLimit { + return nil + } + + let priorityPricing = CodexPricing( + inputCostPerToken: priorityInputCostPerToken, + outputCostPerToken: priorityOutputCostPerToken, + cacheReadInputCostPerToken: pricing.priorityCacheReadInputCostPerToken, + displayLabel: nil) + return self.codexCostUSD( + pricing: priorityPricing, + inputTokens: inputTokens, + cachedInputTokens: cachedInputTokens, + outputTokens: outputTokens) + } + private static func codexCostUSD( pricing: CodexPricing, inputTokens: Int, @@ -352,13 +433,26 @@ enum CostUsagePricing { let cached = min(max(0, cachedInputTokens), max(0, inputTokens)) let nonCached = max(0, inputTokens - cached) let cachedRate = pricing.cacheReadInputCostPerToken ?? pricing.inputCostPerToken - return Double(nonCached) * pricing.inputCostPerToken - + Double(cached) * cachedRate - + Double(max(0, outputTokens)) * pricing.outputCostPerToken + + let useLongContextRates = pricing.thresholdTokens.map { max(0, inputTokens) > $0 } ?? false + let inputRate = useLongContextRates + ? pricing.inputCostPerTokenAboveThreshold ?? pricing.inputCostPerToken + : pricing.inputCostPerToken + let cachedInputRate = useLongContextRates + ? pricing.cacheReadInputCostPerTokenAboveThreshold ?? cachedRate + : cachedRate + let outputRate = useLongContextRates + ? pricing.outputCostPerTokenAboveThreshold ?? pricing.outputCostPerToken + : pricing.outputCostPerToken + + return Double(nonCached) * inputRate + + Double(cached) * cachedInputRate + + Double(max(0, outputTokens)) * outputRate } private static func codexCostUSD( pricing: ModelsDevPricingInfo, + thresholdTokens: Int? = nil, inputTokens: Int, cachedInputTokens: Int, outputTokens: Int) -> Double @@ -368,7 +462,11 @@ enum CostUsagePricing { inputCostPerToken: pricing.inputCostPerToken, outputCostPerToken: pricing.outputCostPerToken, cacheReadInputCostPerToken: pricing.cacheReadInputCostPerToken, - displayLabel: nil), + displayLabel: nil, + thresholdTokens: thresholdTokens ?? pricing.thresholdTokens, + inputCostPerTokenAboveThreshold: pricing.inputCostPerTokenAboveThreshold, + outputCostPerTokenAboveThreshold: pricing.outputCostPerTokenAboveThreshold, + cacheReadInputCostPerTokenAboveThreshold: pricing.cacheReadInputCostPerTokenAboveThreshold), inputTokens: inputTokens, cachedInputTokens: cachedInputTokens, outputTokens: outputTokens) diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 9789165d88..c97ef7f3da 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -14,6 +14,7 @@ enum CostUsageScanner { var codexSessionsRoot: URL? var claudeProjectsRoots: [URL]? var cacheRoot: URL? + var codexTraceDatabaseURL: URL? var refreshMinIntervalSeconds: TimeInterval = 60 var claudeLogProviderFilter: ClaudeLogProviderFilter = .all /// Force a full rescan, ignoring per-file cache and incremental offsets. @@ -23,12 +24,14 @@ enum CostUsageScanner { codexSessionsRoot: URL? = nil, claudeProjectsRoots: [URL]? = nil, cacheRoot: URL? = nil, + codexTraceDatabaseURL: URL? = nil, claudeLogProviderFilter: ClaudeLogProviderFilter = .all, forceRescan: Bool = false) { self.codexSessionsRoot = codexSessionsRoot self.claudeProjectsRoots = claudeProjectsRoots self.cacheRoot = cacheRoot + self.codexTraceDatabaseURL = codexTraceDatabaseURL self.claudeLogProviderFilter = claudeLogProviderFilter self.forceRescan = forceRescan } @@ -41,6 +44,16 @@ enum CostUsageScanner { let lastTotals: CostUsageCodexTotals? let sessionId: String? let forkedFromId: String? + let rows: [CodexUsageRow] + } + + struct CodexUsageRow: Codable, Equatable { + let day: String + let model: String + let turnID: String? + let input: Int + let cached: Int + let output: Int } private struct CodexScanState { @@ -656,8 +669,10 @@ enum CostUsageScanner { var forkedFromId: String? var inheritedTotals: CostUsageCodexTotals? var remainingInheritedTotals: CostUsageCodexTotals? + var currentTurnID: String? var days: [String: [String: [Int]]] = [:] + var rows: [CodexUsageRow] = [] func add(dayKey: String, model: String, input: Int, cached: Int, output: Int) { guard CostUsageDayRange.isInRange(dayKey: dayKey, since: range.scanSinceKey, until: range.scanUntilKey) @@ -707,7 +722,10 @@ enum CostUsageScanner { || line.bytes.containsAscii(#""type":"session_meta""#) else { return } - if line.bytes.containsAscii(#""type":"event_msg""#), !line.bytes.containsAscii(#""token_count""#) { + if line.bytes.containsAscii(#""type":"event_msg""#), + !line.bytes.containsAscii(#""token_count""#), + !line.bytes.containsAscii(#""task_started""#) + { return } @@ -762,6 +780,10 @@ enum CostUsageScanner { guard type == "event_msg" else { return } guard let payload = obj["payload"] as? [String: Any] else { return } + if (payload["type"] as? String) == "task_started" { + currentTurnID = Self.codexTurnID(from: payload) + return + } guard (payload["type"] as? String) == "token_count" else { return } let info = payload["info"] as? [String: Any] @@ -846,7 +868,26 @@ enum CostUsageScanner { if deltaInput == 0, deltaCached == 0, deltaOutput == 0 { return } let cachedClamp = min(deltaCached, deltaInput) - add(dayKey: dayKey, model: model, input: deltaInput, cached: cachedClamp, output: deltaOutput) + let normModel = CostUsagePricing.normalizeCodexModel(model) + add( + dayKey: dayKey, + model: normModel, + input: deltaInput, + cached: cachedClamp, + output: deltaOutput) + if CostUsageDayRange.isInRange( + dayKey: dayKey, + since: range.scanSinceKey, + until: range.scanUntilKey) + { + rows.append(CodexUsageRow( + day: dayKey, + model: normModel, + turnID: Self.codexTurnID(from: payload) ?? currentTurnID, + input: deltaInput, + cached: cachedClamp, + output: deltaOutput)) + } } }) } catch { @@ -862,7 +903,18 @@ enum CostUsageScanner { lastModel: currentModel, lastTotals: previousTotals, sessionId: sessionId, - forkedFromId: forkedFromId) + forkedFromId: forkedFromId, + rows: rows) + } + + private static func codexTurnID(from payload: [String: Any]) -> String? { + if let turnID = payload["turn_id"] as? String ?? payload["turnId"] as? String ?? payload["id"] as? String { + return turnID + } + if let info = payload["info"] as? [String: Any] { + return info["turn_id"] as? String ?? info["turnId"] as? String ?? info["id"] as? String + } + return nil } private static func scanCodexFile( @@ -944,7 +996,8 @@ enum CostUsageScanner { lastModel: delta.lastModel, lastTotals: delta.lastTotals, sessionId: sessionId, - forkedFromId: delta.forkedFromId ?? cached.forkedFromId) + forkedFromId: delta.forkedFromId ?? cached.forkedFromId, + codexRows: (cached.codexRows ?? []) + delta.rows) if let sessionId { state.seenSessionIds.insert(sessionId) resources.fileIndex.remember(fileURL: fileURL, sessionId: sessionId) @@ -978,7 +1031,8 @@ enum CostUsageScanner { lastModel: parsed.lastModel, lastTotals: parsed.lastTotals, sessionId: sessionId, - forkedFromId: parsed.forkedFromId) + forkedFromId: parsed.forkedFromId, + codexRows: parsed.rows) cache.files[path] = usage Self.applyFileDays(cache: &cache, fileDays: usage.days, sign: 1) if let sessionId { @@ -1080,18 +1134,24 @@ enum CostUsageScanner { } let modelsDevCatalog = CostUsagePricing.modelsDevCatalog(now: now, cacheRoot: options.cacheRoot) + let priorityTurns = CodexPriorityTraceScanner.priorityTurns( + databaseURL: options.codexTraceDatabaseURL, + sinceDayKey: range.sinceKey, + untilDayKey: range.untilKey) return Self.buildCodexReportFromCache( cache: cache, range: range, modelsDevCatalog: modelsDevCatalog, - modelsDevCacheRoot: options.cacheRoot) + modelsDevCacheRoot: options.cacheRoot, + priorityTurns: priorityTurns) } private static func buildCodexReportFromCache( cache: CostUsageCache, range: CostUsageDayRange, modelsDevCatalog: ModelsDevCatalog? = nil, - modelsDevCacheRoot: URL? = nil) -> CostUsageDailyReport + modelsDevCacheRoot: URL? = nil, + priorityTurns: [String: CodexPriorityTurnMetadata] = [:]) -> CostUsageDailyReport { var entries: [CostUsageDailyReport.Entry] = [] var totalInput = 0 @@ -1103,6 +1163,7 @@ enum CostUsageScanner { let dayKeys = cache.days.keys.sorted().filter { CostUsageDayRange.isInRange(dayKey: $0, since: range.sinceKey, until: range.untilKey) } + let rowsByDayModel = self.codexRowsByDayModel(cache: cache, range: range) for day in dayKeys { guard let models = cache.days[day] else { continue } @@ -1125,13 +1186,29 @@ enum CostUsageScanner { dayInput += input dayOutput += output - let cost = CostUsagePricing.codexCostUSD( + let rows = rowsByDayModel[day]?[model] + var cost = rows.flatMap { + self.codexRowsCostUSD( + rows: $0, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + } ?? CostUsagePricing.codexCostUSD( model: model, inputTokens: input, cachedInputTokens: cached, outputTokens: output, modelsDevCatalog: modelsDevCatalog, modelsDevCacheRoot: modelsDevCacheRoot) + if !priorityTurns.isEmpty, + let rows, + let surcharge = self.codexPrioritySurchargeUSD( + rows: rows, + priorityTurns: priorityTurns, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + { + cost = (cost ?? 0) + surcharge + } breakdown.append( CostUsageDailyReport.ModelBreakdown( modelName: model, @@ -1176,6 +1253,72 @@ enum CostUsageScanner { return CostUsageDailyReport(data: entries, summary: summary) } + private static func codexRowsByDayModel( + cache: CostUsageCache, + range: CostUsageDayRange) -> [String: [String: [CodexUsageRow]]] + { + var rowsByDayModel: [String: [String: [CodexUsageRow]]] = [:] + for usage in cache.files.values { + for row in usage.codexRows ?? [] { + guard CostUsageDayRange.isInRange(dayKey: row.day, since: range.sinceKey, until: range.untilKey) + else { continue } + rowsByDayModel[row.day, default: [:]][row.model, default: []].append(row) + } + } + return rowsByDayModel + } + + private static func codexRowsCostUSD( + rows: [CodexUsageRow], + modelsDevCatalog: ModelsDevCatalog?, + modelsDevCacheRoot: URL?) -> Double? + { + var total: Double = 0 + var seen = false + for row in rows { + guard let cost = CostUsagePricing.codexCostUSD( + model: row.model, + inputTokens: row.input, + cachedInputTokens: row.cached, + outputTokens: row.output, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + else { continue } + total += cost + seen = true + } + return seen ? total : nil + } + + private static func codexPrioritySurchargeUSD( + rows: [CodexUsageRow], + priorityTurns: [String: CodexPriorityTurnMetadata], + modelsDevCatalog: ModelsDevCatalog?, + modelsDevCacheRoot: URL?) -> Double? + { + var total: Double = 0 + var seen = false + for row in rows { + guard let turnID = row.turnID, priorityTurns[turnID] != nil else { continue } + guard let baseCost = CostUsagePricing.codexCostUSD( + model: row.model, + inputTokens: row.input, + cachedInputTokens: row.cached, + outputTokens: row.output, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot), + let priorityCost = CostUsagePricing.codexPriorityCostUSD( + model: row.model, + inputTokens: row.input, + cachedInputTokens: row.cached, + outputTokens: row.output) + else { continue } + total += max(priorityCost - baseCost, 0) + seen = true + } + return seen ? total : nil + } + // MARK: - Shared cache mutations static func makeFileUsage( @@ -1187,6 +1330,7 @@ enum CostUsageScanner { lastTotals: CostUsageCodexTotals? = nil, sessionId: String? = nil, forkedFromId: String? = nil, + codexRows: [CodexUsageRow]? = nil, claudeRows: [ClaudeUsageRow]? = nil) -> CostUsageFileUsage { CostUsageFileUsage( @@ -1198,6 +1342,7 @@ enum CostUsageScanner { lastTotals: lastTotals, sessionId: sessionId, forkedFromId: forkedFromId, + codexRows: codexRows, claudeRows: claudeRows) } diff --git a/Tests/CodexBarTests/CodexPriorityTraceScannerTests.swift b/Tests/CodexBarTests/CodexPriorityTraceScannerTests.swift new file mode 100644 index 0000000000..7fe453b146 --- /dev/null +++ b/Tests/CodexBarTests/CodexPriorityTraceScannerTests.swift @@ -0,0 +1,176 @@ +import Foundation +#if canImport(SQLite3) +import SQLite3 +import Testing +@testable import CodexBarCore + +struct CodexPriorityTraceScannerTests { + @Test + func `parses priority turn metadata without exposing request body`() { + let body = "INFO thread_id=11111111-1111-1111-1111-111111111111 " + + "turn.id=22222222-2222-2222-2222-222222222222 websocket request: " + + #"{"type":"response.create","model":"gpt-5.5","service_tier":"priority","instructions":"secret prompt"}"# + + let parsed = CodexPriorityTraceScanner.parseTraceRow(timestamp: "2026-05-10T12:00:00Z", body: body) + + #expect(parsed?.threadID == "11111111-1111-1111-1111-111111111111") + #expect(parsed?.turnID == "22222222-2222-2222-2222-222222222222") + #expect(parsed?.model == "gpt-5.5") + #expect(parsed?.timestamp == "2026-05-10T12:00:00Z") + } + + @Test + func `ignores non priority malformed and non response request rows`() { + let prefix = "thread_id=thread turn.id=turn websocket request: " + + #expect(CodexPriorityTraceScanner.parseTraceRow( + timestamp: nil, + body: prefix + #"{"type":"session.update","service_tier":"priority"}"#) == nil) + #expect(CodexPriorityTraceScanner.parseTraceRow( + timestamp: nil, + body: prefix + #"{"type":"response.create"}"#) == nil) + #expect(CodexPriorityTraceScanner.parseTraceRow( + timestamp: nil, + body: prefix + #"{"type":"response.create","service_tier":"default"}"#) == nil) + #expect(CodexPriorityTraceScanner.parseTraceRow( + timestamp: nil, + body: prefix + #"{"#) == nil) + } + + @Test + func `reads priority turns from sqlite logs table`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let dbURL = env.root.appendingPathComponent("logs_2.sqlite") + try Self.createTestLogsDatabase(at: dbURL) + try Self.insertTestLog( + dbURL: dbURL, + timestamp: "2026-05-10T12:00:00Z", + body: "thread_id=thread-a turn.id=turn-a websocket request: " + + #"{"type":"response.create","model":"gpt-5.5","service_tier":"priority","input":"private"}"#) + try Self.insertTestLog( + dbURL: dbURL, + timestamp: "2026-05-10T12:01:00Z", + body: """ + thread_id=thread-b turn.id=turn-b websocket request: {"type":"response.create","model":"gpt-5.5"} + """) + + let turns = CodexPriorityTraceScanner.priorityTurns(databaseURL: dbURL) + + #expect(turns.keys.sorted() == ["turn-a"]) + #expect(turns["turn-a"]?.threadID == "thread-a") + #expect(turns["turn-a"]?.model == "gpt-5.5") + } + + @Test + func `sqlite scan only returns priority turns in requested day range`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let dbURL = env.root.appendingPathComponent("logs_2.sqlite") + try Self.createTestLogsDatabase(at: dbURL) + try Self.insertTestLog( + dbURL: dbURL, + timestamp: "2026-05-09T23:59:59Z", + body: "thread_id=thread-old turn.id=turn-old websocket request: " + + #"{"type":"response.create","model":"gpt-5.5","service_tier":"priority"}"#) + try Self.insertTestLog( + dbURL: dbURL, + timestamp: "2026-05-10T12:00:00Z", + body: "thread_id=thread-new turn.id=turn-new websocket request: " + + #"{"type":"response.create","model":"gpt-5.5","service_tier":"priority"}"#) + + let turns = CodexPriorityTraceScanner.priorityTurns( + databaseURL: dbURL, + sinceDayKey: "2026-05-10", + untilDayKey: "2026-05-10") + + #expect(turns.keys.sorted() == ["turn-new"]) + } + + @Test + func `sqlite scan uses local day boundaries for integer timestamps`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let dbURL = env.root.appendingPathComponent("logs_2.sqlite") + try Self.createTestLogsDatabase(at: dbURL) + + var components = DateComponents() + components.calendar = Calendar.current + components.year = 2026 + components.month = 5 + components.day = 10 + let dayStart = try #require(components.date) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: dayStart) + let previousSecond = try #require(Calendar.current.date(byAdding: .second, value: -1, to: dayStart)) + let nextSecond = try #require(Calendar.current.date(byAdding: .second, value: 1, to: dayStart)) + + try Self.insertTestLog( + dbURL: dbURL, + epochSeconds: Int64(previousSecond.timeIntervalSince1970), + body: "thread_id=thread-before turn.id=turn-before websocket request: " + + #"{"type":"response.create","model":"gpt-5.5","service_tier":"priority"}"#) + try Self.insertTestLog( + dbURL: dbURL, + epochSeconds: Int64(nextSecond.timeIntervalSince1970), + body: "thread_id=thread-after turn.id=turn-after websocket request: " + + #"{"type":"response.create","model":"gpt-5.5","service_tier":"priority"}"#) + + let turns = CodexPriorityTraceScanner.priorityTurns( + databaseURL: dbURL, + sinceDayKey: dayKey, + untilDayKey: dayKey) + + #expect(turns.keys.sorted() == ["turn-after"]) + } + + static func createTestLogsDatabase(at dbURL: URL) throws { + var db: OpaquePointer? + guard sqlite3_open(dbURL.path, &db) == SQLITE_OK else { throw SQLiteTestError.open } + defer { sqlite3_close(db) } + try self.exec(db, "create table logs (ts integer not null, feedback_log_body text)") + } + + static func insertTestLog(dbURL: URL, timestamp: String, body: String) throws { + try self.insertTestLog(dbURL: dbURL, epochSeconds: self.epochSeconds(timestamp), body: body) + } + + static func insertTestLog(dbURL: URL, epochSeconds: Int64, body: String) throws { + var db: OpaquePointer? + guard sqlite3_open(dbURL.path, &db) == SQLITE_OK else { throw SQLiteTestError.open } + defer { sqlite3_close(db) } + + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, "insert into logs (ts, feedback_log_body) values (?, ?)", -1, &stmt, nil) + == SQLITE_OK + else { throw SQLiteTestError.prepare } + defer { sqlite3_finalize(stmt) } + + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_int64(stmt, 1, epochSeconds) + sqlite3_bind_text(stmt, 2, body, -1, transient) + guard sqlite3_step(stmt) == SQLITE_DONE else { throw SQLiteTestError.step } + } + + private static func epochSeconds(_ timestamp: String) -> Int64 { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + guard let date = formatter.date(from: timestamp) else { return 0 } + return Int64(date.timeIntervalSince1970) + } + + private static func exec(_ db: OpaquePointer?, _ sql: String) throws { + var message: UnsafeMutablePointer? + guard sqlite3_exec(db, sql, nil, nil, &message) == SQLITE_OK else { + sqlite3_free(message) + throw SQLiteTestError.exec + } + } + + private enum SQLiteTestError: Error { + case open + case prepare + case step + case exec + } +} +#endif diff --git a/Tests/CodexBarTests/CostUsageCacheTests.swift b/Tests/CodexBarTests/CostUsageCacheTests.swift index fddb9bcf16..533ad05770 100644 --- a/Tests/CodexBarTests/CostUsageCacheTests.swift +++ b/Tests/CodexBarTests/CostUsageCacheTests.swift @@ -10,7 +10,7 @@ struct CostUsageCacheTests { let codexURL = CostUsageCacheIO.cacheFileURL(provider: .codex, cacheRoot: root) let claudeURL = CostUsageCacheIO.cacheFileURL(provider: .claude, cacheRoot: root) - #expect(codexURL.lastPathComponent == "codex-v4.json") + #expect(codexURL.lastPathComponent == "codex-v5.json") #expect(claudeURL.lastPathComponent == "claude-v2.json") } } diff --git a/Tests/CodexBarTests/CostUsagePricingTests.swift b/Tests/CodexBarTests/CostUsagePricingTests.swift index c62edd09fe..ff087ca25a 100644 --- a/Tests/CodexBarTests/CostUsagePricingTests.swift +++ b/Tests/CodexBarTests/CostUsagePricingTests.swift @@ -68,6 +68,126 @@ struct CostUsagePricingTests { #expect(cost == expected) } + @Test + func `codex cost applies gpt54 and gpt55 long context tiers`() throws { + let root = try Self.cacheRoot() + let gpt54 = CostUsagePricing.codexCostUSD( + model: "gpt-5.4", + inputTokens: 272_001, + cachedInputTokens: 0, + outputTokens: 10, + modelsDevCacheRoot: root) + let gpt55 = CostUsagePricing.codexCostUSD( + model: "gpt-5.5", + inputTokens: 272_001, + cachedInputTokens: 0, + outputTokens: 10, + modelsDevCacheRoot: root) + + #expect(gpt54 == (272_001.0 * 5e-6) + (10.0 * 2.25e-5)) + #expect(gpt55 == (272_001.0 * 1e-5) + (10.0 * 4.5e-5)) + } + + @Test + func `codex cost keeps normal rates at long context input boundary`() throws { + let root = try Self.cacheRoot() + let gpt55 = CostUsagePricing.codexCostUSD( + model: "gpt-5.5", + inputTokens: 272_000, + cachedInputTokens: 0, + outputTokens: 128_000, + modelsDevCacheRoot: root) + + #expect(gpt55 == (272_000.0 * 5e-6) + (128_000.0 * 3e-5)) + } + + @Test + func `codex priority cost applies model specific fast rates`() { + let gpt54 = CostUsagePricing.codexPriorityCostUSD( + model: "gpt-5.4", + inputTokens: 100, + cachedInputTokens: 20, + outputTokens: 10) + let gpt55 = CostUsagePricing.codexPriorityCostUSD( + model: "gpt-5.5", + inputTokens: 100, + cachedInputTokens: 20, + outputTokens: 10) + + #expect(gpt54 == (80.0 * 5e-6) + (20.0 * 5e-7) + (10.0 * 3e-5)) + #expect(gpt55 == (80.0 * 1.25e-5) + (20.0 * 1.25e-6) + (10.0 * 7.5e-5)) + } + + @Test + func `codex priority cost is unavailable for long context requests`() { + let gpt55 = CostUsagePricing.codexPriorityCostUSD( + model: "gpt-5.5", + inputTokens: 400_001, + cachedInputTokens: 0, + outputTokens: 10) + let gpt54Mini = CostUsagePricing.codexPriorityCostUSD( + model: "gpt-5.4-mini", + inputTokens: 400_001, + cachedInputTokens: 0, + outputTokens: 10) + + #expect(gpt55 == nil) + #expect(gpt54Mini == nil) + } + + @Test + func `codex priority cost remains available at long context input boundary`() { + let gpt55 = CostUsagePricing.codexPriorityCostUSD( + model: "gpt-5.5", + inputTokens: 400_000, + cachedInputTokens: 0, + outputTokens: 10) + + #expect(gpt55 == (400_000.0 * 1.25e-5) + (10.0 * 7.5e-5)) + } + + @Test + func `codex models dev pricing uses codex long context threshold`() throws { + let root = try Self.seedModelsDevCache(""" + { + "openai": { + "id": "openai", + "models": { + "gpt-5.5": { + "id": "gpt-5.5", + "cost": { + "input": 5, + "output": 30, + "cache_read": 0.5, + "context_over_200k": { + "input": 10, + "output": 45, + "cache_read": 1 + } + } + } + } + } + } + """) + + let atBoundary = CostUsagePricing.codexCostUSD( + model: "gpt-5.5", + inputTokens: 272_000, + cachedInputTokens: 0, + outputTokens: 10, + modelsDevCacheRoot: root) + let aboveBoundary = CostUsagePricing.codexCostUSD( + model: "gpt-5.5", + inputTokens: 272_001, + cachedInputTokens: 0, + outputTokens: 10, + modelsDevCacheRoot: root) + + #expect(atBoundary == (272_000.0 * 5e-6) + (10.0 * 3e-5)) + #expect(aboveBoundary == (272_001.0 * 1e-5) + (10.0 * 4.5e-5)) + } + @Test func `codex cost supports gpt55 pro bundled fallback`() throws { let root = try Self.cacheRoot() diff --git a/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift b/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift new file mode 100644 index 0000000000..81fc2e9dc8 --- /dev/null +++ b/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift @@ -0,0 +1,192 @@ +import Foundation +#if canImport(SQLite3) +import Testing +@testable import CodexBarCore + +struct CostUsageScannerPriorityTests { + @Test + func `codex daily report applies gpt55 priority rates`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + let iso0 = env.isoString(for: day) + let iso1 = env.isoString(for: day.addingTimeInterval(1)) + let iso2 = env.isoString(for: day.addingTimeInterval(2)) + let iso3 = env.isoString(for: day.addingTimeInterval(3)) + + let entries: [[String: Any]] = [ + ["type": "turn_context", "timestamp": iso0, "payload": ["model": "gpt-5.5"]], + ["type": "event_msg", "timestamp": iso1, "payload": ["type": "task_started", "turn_id": "standard-turn"]], + self.tokenCount(timestamp: iso2, input: 100, cached: 20, output: 10), + ["type": "event_msg", "timestamp": iso3, "payload": ["type": "task_started", "turn_id": "priority-turn"]], + self.tokenCount(timestamp: iso3, input: 100, cached: 20, output: 10), + ] + _ = try env.writeCodexSessionFile(day: day, filename: "session.jsonl", contents: env.jsonl(entries)) + + let dbURL = env.root.appendingPathComponent("logs_2.sqlite") + try CodexPriorityTraceScannerTests.createTestLogsDatabase(at: dbURL) + try self.insertPriorityTrace(dbURL: dbURL, timestamp: iso3) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: dbURL) + options.refreshMinIntervalSeconds = 0 + + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let standardCost = (80.0 * 5e-6) + (20.0 * 5e-7) + (10.0 * 3e-5) + let priorityCost = (80.0 * 1.25e-5) + (20.0 * 1.25e-6) + (10.0 * 7.5e-5) + + #expect(report.summary?.totalCostUSD == standardCost + priorityCost) + } + + @Test + func `codex daily report applies gpt54 priority rates`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + let iso0 = env.isoString(for: day) + let iso1 = env.isoString(for: day.addingTimeInterval(1)) + let iso2 = env.isoString(for: day.addingTimeInterval(2)) + let iso3 = env.isoString(for: day.addingTimeInterval(3)) + + let entries: [[String: Any]] = [ + ["type": "turn_context", "timestamp": iso0, "payload": ["model": "gpt-5.4"]], + ["type": "event_msg", "timestamp": iso1, "payload": ["type": "task_started", "turn_id": "standard-turn"]], + self.tokenCount(timestamp: iso2, input: 100, cached: 20, output: 10), + ["type": "event_msg", "timestamp": iso3, "payload": ["type": "task_started", "turn_id": "priority-turn"]], + self.tokenCount(timestamp: iso3, input: 100, cached: 20, output: 10), + ] + _ = try env.writeCodexSessionFile(day: day, filename: "session.jsonl", contents: env.jsonl(entries)) + + let dbURL = env.root.appendingPathComponent("logs_2.sqlite") + try CodexPriorityTraceScannerTests.createTestLogsDatabase(at: dbURL) + try self.insertPriorityTrace(dbURL: dbURL, timestamp: iso3, model: "gpt-5.4") + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: dbURL) + options.refreshMinIntervalSeconds = 0 + + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let standardCost = (80.0 * 2.5e-6) + (20.0 * 2.5e-7) + (10.0 * 1.5e-5) + let priorityCost = (80.0 * 5e-6) + (20.0 * 5e-7) + (10.0 * 3e-5) + + #expect(report.summary?.totalCostUSD == standardCost + priorityCost) + } + + @Test + func `codex daily report keeps base cost when sqlite metadata is missing`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + let iso0 = env.isoString(for: day) + let iso1 = env.isoString(for: day.addingTimeInterval(1)) + let entries: [[String: Any]] = [ + ["type": "turn_context", "timestamp": iso0, "payload": ["model": "gpt-5.5"]], + ["type": "event_msg", "timestamp": iso1, "payload": ["type": "task_started", "turn_id": "priority-turn"]], + self.tokenCount(timestamp: iso1, input: 100, cached: 20, output: 10), + ] + _ = try env.writeCodexSessionFile(day: day, filename: "session.jsonl", contents: env.jsonl(entries)) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing.sqlite")) + options.refreshMinIntervalSeconds = 0 + + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let expected = (80.0 * 5e-6) + (20.0 * 5e-7) + (10.0 * 3e-5) + + #expect(report.summary?.totalCostUSD == expected) + } + + @Test + func `codex pricing applies long context tiers per token row`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + let iso0 = env.isoString(for: day) + let iso1 = env.isoString(for: day.addingTimeInterval(1)) + let iso2 = env.isoString(for: day.addingTimeInterval(2)) + let iso3 = env.isoString(for: day.addingTimeInterval(3)) + let entries: [[String: Any]] = [ + ["type": "turn_context", "timestamp": iso0, "payload": ["model": "gpt-5.5"]], + ["type": "event_msg", "timestamp": iso1, "payload": ["type": "task_started", "turn_id": "standard-turn"]], + self.tokenCount(timestamp: iso1, input: 272_001, cached: 0, output: 10), + ["type": "event_msg", "timestamp": iso2, "payload": ["type": "task_started", "turn_id": "priority-turn"]], + self.tokenCount(timestamp: iso2, input: 300_000, cached: 0, output: 5), + self.tokenCount(timestamp: iso3, input: 100_001, cached: 0, output: 5), + ] + _ = try env.writeCodexSessionFile(day: day, filename: "session.jsonl", contents: env.jsonl(entries)) + + let dbURL = env.root.appendingPathComponent("logs_2.sqlite") + try CodexPriorityTraceScannerTests.createTestLogsDatabase(at: dbURL) + try self.insertPriorityTrace(dbURL: dbURL, timestamp: iso2) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: dbURL) + options.refreshMinIntervalSeconds = 0 + + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let standardTurnBase = (272_001.0 * 1e-5) + (10.0 * 4.5e-5) + let priorityFirstRow = (300_000.0 * 1.25e-5) + (5.0 * 7.5e-5) + let prioritySecondRow = (100_001.0 * 1.25e-5) + (5.0 * 7.5e-5) + + let expected = standardTurnBase + priorityFirstRow + prioritySecondRow + #expect(abs((report.summary?.totalCostUSD ?? 0) - expected) < 0.000_000_001) + } + + private func tokenCount(timestamp: String, input: Int, cached: Int, output: Int) -> [String: Any] { + [ + "type": "event_msg", + "timestamp": timestamp, + "payload": [ + "type": "token_count", + "info": [ + "last_token_usage": [ + "input_tokens": input, + "cached_input_tokens": cached, + "output_tokens": output, + ], + ], + ], + ] + } + + private func insertPriorityTrace(dbURL: URL, timestamp: String, model: String = "gpt-5.5") throws { + try CodexPriorityTraceScannerTests.insertTestLog( + dbURL: dbURL, + timestamp: timestamp, + body: "thread_id=thread turn.id=priority-turn websocket request: " + + #"{"type":"response.create","model":""# + model + #"","service_tier":"priority"}"#) + } +} +#endif From db4352d4cb13d003b73a6378338e6b3db561c16c Mon Sep 17 00:00:00 2001 From: Bryan Font Date: Sun, 10 May 2026 20:12:13 -0400 Subject: [PATCH 2/7] Fix Codex long-context pricing rows --- .../Vendored/CostUsage/CostUsagePricing.swift | 36 ++++++----- .../CodexBarTests/CostUsagePricingTests.swift | 12 +++- .../CostUsageScannerPriorityTests.swift | 62 ++++++++++++++++++- 3 files changed, 92 insertions(+), 18 deletions(-) diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift index 6179d5de01..2e76da34b3 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift @@ -434,20 +434,28 @@ enum CostUsagePricing { let nonCached = max(0, inputTokens - cached) let cachedRate = pricing.cacheReadInputCostPerToken ?? pricing.inputCostPerToken - let useLongContextRates = pricing.thresholdTokens.map { max(0, inputTokens) > $0 } ?? false - let inputRate = useLongContextRates - ? pricing.inputCostPerTokenAboveThreshold ?? pricing.inputCostPerToken - : pricing.inputCostPerToken - let cachedInputRate = useLongContextRates - ? pricing.cacheReadInputCostPerTokenAboveThreshold ?? cachedRate - : cachedRate - let outputRate = useLongContextRates - ? pricing.outputCostPerTokenAboveThreshold ?? pricing.outputCostPerToken - : pricing.outputCostPerToken - - return Double(nonCached) * inputRate - + Double(cached) * cachedInputRate - + Double(max(0, outputTokens)) * outputRate + func tiered(_ tokens: Int, base: Double, above: Double?, threshold: Int?) -> Double { + guard let threshold, let above else { return Double(tokens) * base } + let below = min(tokens, threshold) + let over = max(tokens - threshold, 0) + return Double(below) * base + Double(over) * above + } + + return tiered( + nonCached, + base: pricing.inputCostPerToken, + above: pricing.inputCostPerTokenAboveThreshold, + threshold: pricing.thresholdTokens) + + tiered( + cached, + base: cachedRate, + above: pricing.cacheReadInputCostPerTokenAboveThreshold, + threshold: pricing.thresholdTokens) + + tiered( + max(0, outputTokens), + base: pricing.outputCostPerToken, + above: pricing.outputCostPerTokenAboveThreshold, + threshold: pricing.thresholdTokens) } private static func codexCostUSD( diff --git a/Tests/CodexBarTests/CostUsagePricingTests.swift b/Tests/CodexBarTests/CostUsagePricingTests.swift index ff087ca25a..474358364c 100644 --- a/Tests/CodexBarTests/CostUsagePricingTests.swift +++ b/Tests/CodexBarTests/CostUsagePricingTests.swift @@ -84,8 +84,8 @@ struct CostUsagePricingTests { outputTokens: 10, modelsDevCacheRoot: root) - #expect(gpt54 == (272_001.0 * 5e-6) + (10.0 * 2.25e-5)) - #expect(gpt55 == (272_001.0 * 1e-5) + (10.0 * 4.5e-5)) + #expect(gpt54 == (272_000.0 * 2.5e-6) + (1.0 * 5e-6) + (10.0 * 1.5e-5)) + #expect(gpt55 == (272_000.0 * 5e-6) + (1.0 * 1e-5) + (10.0 * 3e-5)) } @Test @@ -113,9 +113,15 @@ struct CostUsagePricingTests { inputTokens: 100, cachedInputTokens: 20, outputTokens: 10) + let gpt54Mini = CostUsagePricing.codexPriorityCostUSD( + model: "gpt-5.4-mini", + inputTokens: 100, + cachedInputTokens: 20, + outputTokens: 10) #expect(gpt54 == (80.0 * 5e-6) + (20.0 * 5e-7) + (10.0 * 3e-5)) #expect(gpt55 == (80.0 * 1.25e-5) + (20.0 * 1.25e-6) + (10.0 * 7.5e-5)) + #expect(gpt54Mini == (80.0 * 1.5e-6) + (20.0 * 1.5e-7) + (10.0 * 9e-6)) } @Test @@ -185,7 +191,7 @@ struct CostUsagePricingTests { modelsDevCacheRoot: root) #expect(atBoundary == (272_000.0 * 5e-6) + (10.0 * 3e-5)) - #expect(aboveBoundary == (272_001.0 * 1e-5) + (10.0 * 4.5e-5)) + #expect(aboveBoundary == (272_000.0 * 5e-6) + (1.0 * 1e-5) + (10.0 * 3e-5)) } @Test diff --git a/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift b/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift index 81fc2e9dc8..e0c9c51bc0 100644 --- a/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift @@ -156,7 +156,7 @@ struct CostUsageScannerPriorityTests { until: day, now: day, options: options) - let standardTurnBase = (272_001.0 * 1e-5) + (10.0 * 4.5e-5) + let standardTurnBase = (272_000.0 * 5e-6) + (1.0 * 1e-5) + (10.0 * 3e-5) let priorityFirstRow = (300_000.0 * 1.25e-5) + (5.0 * 7.5e-5) let prioritySecondRow = (100_001.0 * 1.25e-5) + (5.0 * 7.5e-5) @@ -164,6 +164,49 @@ struct CostUsageScannerPriorityTests { #expect(abs((report.summary?.totalCostUSD ?? 0) - expected) < 0.000_000_001) } + @Test + func `codex cumulative totals do not trigger long context pricing`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + let iso0 = env.isoString(for: day) + let iso1 = env.isoString(for: day.addingTimeInterval(1)) + let iso2 = env.isoString(for: day.addingTimeInterval(2)) + let iso3 = env.isoString(for: day.addingTimeInterval(3)) + let entries: [[String: Any]] = [ + ["type": "turn_context", "timestamp": iso0, "payload": ["model": "gpt-5.5"]], + ["type": "event_msg", "timestamp": iso1, "payload": ["type": "task_started", "turn_id": "standard-turn"]], + self.totalTokenCount(timestamp: iso1, input: 180_000, cached: 100_000, output: 100), + self.totalTokenCount(timestamp: iso2, input: 360_000, cached: 200_000, output: 200), + ["type": "event_msg", "timestamp": iso3, "payload": ["type": "task_started", "turn_id": "priority-turn"]], + self.totalTokenCount(timestamp: iso3, input: 540_000, cached: 300_000, output: 300), + ] + _ = try env.writeCodexSessionFile(day: day, filename: "session.jsonl", contents: env.jsonl(entries)) + + let dbURL = env.root.appendingPathComponent("logs_2.sqlite") + try CodexPriorityTraceScannerTests.createTestLogsDatabase(at: dbURL) + try self.insertPriorityTrace(dbURL: dbURL, timestamp: iso3) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: dbURL) + options.refreshMinIntervalSeconds = 0 + + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let standardRow = (Double(80000) * 5e-6) + (Double(100_000) * 5e-7) + (Double(100) * 3e-5) + let priorityRow = (Double(80000) * 1.25e-5) + (Double(100_000) * 1.25e-6) + (Double(100) * 7.5e-5) + let expected = standardRow + standardRow + priorityRow + + #expect(abs((report.summary?.totalCostUSD ?? 0) - expected) < 0.000_000_001) + } + private func tokenCount(timestamp: String, input: Int, cached: Int, output: Int) -> [String: Any] { [ "type": "event_msg", @@ -181,6 +224,23 @@ struct CostUsageScannerPriorityTests { ] } + private func totalTokenCount(timestamp: String, input: Int, cached: Int, output: Int) -> [String: Any] { + [ + "type": "event_msg", + "timestamp": timestamp, + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": input, + "cached_input_tokens": cached, + "output_tokens": output, + ], + ], + ], + ] + } + private func insertPriorityTrace(dbURL: URL, timestamp: String, model: String = "gpt-5.5") throws { try CodexPriorityTraceScannerTests.insertTestLog( dbURL: dbURL, From 0018b24df7cd9ee4f6dff1dd65f56bdbacc997e5 Mon Sep 17 00:00:00 2001 From: Bryan Font Date: Sun, 10 May 2026 20:28:55 -0400 Subject: [PATCH 3/7] Align Codex priority scanner naming --- ...t => CostUsageScanner+CodexPriority.swift} | 24 +++++++++---------- .../Vendored/CostUsage/CostUsageScanner.swift | 2 +- ... CostUsageScannerCodexPriorityTests.swift} | 18 +++++++------- .../CostUsageScannerPriorityTests.swift | 10 ++++---- 4 files changed, 27 insertions(+), 27 deletions(-) rename Sources/CodexBarCore/Vendored/CostUsage/{CodexPriorityTraceScanner.swift => CostUsageScanner+CodexPriority.swift} (91%) rename Tests/CodexBarTests/{CodexPriorityTraceScannerTests.swift => CostUsageScannerCodexPriorityTests.swift} (92%) diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CodexPriorityTraceScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexPriority.swift similarity index 91% rename from Sources/CodexBarCore/Vendored/CostUsage/CodexPriorityTraceScanner.swift rename to Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexPriority.swift index e4205af563..d1eaceac24 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CodexPriorityTraceScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexPriority.swift @@ -3,28 +3,28 @@ import Foundation import SQLite3 #endif -struct CodexPriorityTurnMetadata: Codable, Equatable, Sendable { - var threadID: String? - var turnID: String - var model: String? - var timestamp: String? -} +extension CostUsageScanner { + struct CodexPriorityTurnMetadata: Codable, Equatable, Sendable { + var threadID: String? + var turnID: String + var model: String? + var timestamp: String? + } -enum CodexPriorityTraceScanner { private static let requestMarker = "websocket request:" - static func defaultDatabaseURL() -> URL { + static func defaultCodexPriorityDatabaseURL() -> URL { FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent(".codex", isDirectory: true) .appendingPathComponent("logs_2.sqlite", isDirectory: false) } - static func priorityTurns( + static func codexPriorityTurns( databaseURL: URL? = nil, sinceDayKey: String? = nil, untilDayKey: String? = nil) -> [String: CodexPriorityTurnMetadata] { - let url = databaseURL ?? self.defaultDatabaseURL() + let url = databaseURL ?? self.defaultCodexPriorityDatabaseURL() guard FileManager.default.fileExists(atPath: url.path) else { return [:] } #if canImport(SQLite3) @@ -66,7 +66,7 @@ enum CodexPriorityTraceScanner { let timestamp = self.timestamp(stmt: stmt, index: 0) guard self.timestamp(timestamp, isInRangeSince: sinceDayKey, until: untilDayKey), let body = self.text(stmt: stmt, index: 1), - let parsed = self.parseTraceRow(timestamp: timestamp, body: body) + let parsed = self.parseCodexPriorityTraceRow(timestamp: timestamp, body: body) else { continue } turns[parsed.turnID] = parsed } @@ -76,7 +76,7 @@ enum CodexPriorityTraceScanner { #endif } - static func parseTraceRow(timestamp: String?, body: String) -> CodexPriorityTurnMetadata? { + static func parseCodexPriorityTraceRow(timestamp: String?, body: String) -> CodexPriorityTurnMetadata? { guard let markerRange = body.range(of: self.requestMarker) else { return nil } let prefix = String(body[.. Date: Sun, 10 May 2026 21:04:02 -0400 Subject: [PATCH 4/7] Cap Codex priority pricing at 272k --- .../Vendored/CostUsage/CostUsagePricing.swift | 2 +- Tests/CodexBarTests/CostUsagePricingTests.swift | 10 +++++----- .../CostUsageScannerPriorityTests.swift | 16 ++++++++-------- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift index 2e76da34b3..0c464e76a4 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift @@ -1,7 +1,7 @@ import Foundation enum CostUsagePricing { - private static let codexPriorityInputTokenLimit = 400_000 + private static let codexPriorityInputTokenLimit = 272_000 struct CodexPricing { let inputCostPerToken: Double diff --git a/Tests/CodexBarTests/CostUsagePricingTests.swift b/Tests/CodexBarTests/CostUsagePricingTests.swift index 474358364c..4ecb192cb2 100644 --- a/Tests/CodexBarTests/CostUsagePricingTests.swift +++ b/Tests/CodexBarTests/CostUsagePricingTests.swift @@ -128,12 +128,12 @@ struct CostUsagePricingTests { func `codex priority cost is unavailable for long context requests`() { let gpt55 = CostUsagePricing.codexPriorityCostUSD( model: "gpt-5.5", - inputTokens: 400_001, + inputTokens: 272_001, cachedInputTokens: 0, outputTokens: 10) let gpt54Mini = CostUsagePricing.codexPriorityCostUSD( model: "gpt-5.4-mini", - inputTokens: 400_001, + inputTokens: 272_001, cachedInputTokens: 0, outputTokens: 10) @@ -142,14 +142,14 @@ struct CostUsagePricingTests { } @Test - func `codex priority cost remains available at long context input boundary`() { + func `codex priority cost remains available at priority input boundary`() { let gpt55 = CostUsagePricing.codexPriorityCostUSD( model: "gpt-5.5", - inputTokens: 400_000, + inputTokens: 272_000, cachedInputTokens: 0, outputTokens: 10) - #expect(gpt55 == (400_000.0 * 1.25e-5) + (10.0 * 7.5e-5)) + #expect(gpt55 == (272_000.0 * 1.25e-5) + (10.0 * 7.5e-5)) } @Test diff --git a/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift b/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift index 7530e89514..c05203ce62 100644 --- a/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift @@ -121,7 +121,7 @@ struct CostUsageScannerPriorityTests { } @Test - func `codex pricing applies long context tiers per token row`() throws { + func `codex pricing skips priority surcharge for long context rows`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } @@ -157,10 +157,10 @@ struct CostUsageScannerPriorityTests { now: day, options: options) let standardTurnBase = (272_000.0 * 5e-6) + (1.0 * 1e-5) + (10.0 * 3e-5) - let priorityFirstRow = (300_000.0 * 1.25e-5) + (5.0 * 7.5e-5) + let standardFirstRow = (272_000.0 * 5e-6) + (28000.0 * 1e-5) + (5.0 * 3e-5) let prioritySecondRow = (100_001.0 * 1.25e-5) + (5.0 * 7.5e-5) - let expected = standardTurnBase + priorityFirstRow + prioritySecondRow + let expected = standardTurnBase + standardFirstRow + prioritySecondRow #expect(abs((report.summary?.totalCostUSD ?? 0) - expected) < 0.000_000_001) } @@ -177,10 +177,10 @@ struct CostUsageScannerPriorityTests { let entries: [[String: Any]] = [ ["type": "turn_context", "timestamp": iso0, "payload": ["model": "gpt-5.5"]], ["type": "event_msg", "timestamp": iso1, "payload": ["type": "task_started", "turn_id": "standard-turn"]], - self.totalTokenCount(timestamp: iso1, input: 180_000, cached: 100_000, output: 100), - self.totalTokenCount(timestamp: iso2, input: 360_000, cached: 200_000, output: 200), + self.totalTokenCount(timestamp: iso1, input: 120_000, cached: 60000, output: 100), + self.totalTokenCount(timestamp: iso2, input: 240_000, cached: 120_000, output: 200), ["type": "event_msg", "timestamp": iso3, "payload": ["type": "task_started", "turn_id": "priority-turn"]], - self.totalTokenCount(timestamp: iso3, input: 540_000, cached: 300_000, output: 300), + self.totalTokenCount(timestamp: iso3, input: 360_000, cached: 180_000, output: 300), ] _ = try env.writeCodexSessionFile(day: day, filename: "session.jsonl", contents: env.jsonl(entries)) @@ -200,8 +200,8 @@ struct CostUsageScannerPriorityTests { until: day, now: day, options: options) - let standardRow = (Double(80000) * 5e-6) + (Double(100_000) * 5e-7) + (Double(100) * 3e-5) - let priorityRow = (Double(80000) * 1.25e-5) + (Double(100_000) * 1.25e-6) + (Double(100) * 7.5e-5) + let standardRow = (Double(60000) * 5e-6) + (Double(60000) * 5e-7) + (Double(100) * 3e-5) + let priorityRow = (Double(60000) * 1.25e-5) + (Double(60000) * 1.25e-6) + (Double(100) * 7.5e-5) let expected = standardRow + standardRow + priorityRow #expect(abs((report.summary?.totalCostUSD ?? 0) - expected) < 0.000_000_001) From 403f9577fdf57e4dbd0d518b6e35ce82152e9c2c Mon Sep 17 00:00:00 2001 From: Bryan Font Date: Sun, 10 May 2026 22:14:31 -0400 Subject: [PATCH 5/7] Apply Codex threshold across cached input --- .../Vendored/CostUsage/CostUsagePricing.swift | 30 ++++++++++++------- .../CodexBarTests/CostUsagePricingTests.swift | 18 +++++++++++ 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift index 0c464e76a4..3c8f52d9ea 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift @@ -441,16 +441,26 @@ enum CostUsagePricing { return Double(below) * base + Double(over) * above } - return tiered( - nonCached, - base: pricing.inputCostPerToken, - above: pricing.inputCostPerTokenAboveThreshold, - threshold: pricing.thresholdTokens) - + tiered( - cached, - base: cachedRate, - above: pricing.cacheReadInputCostPerTokenAboveThreshold, - threshold: pricing.thresholdTokens) + func inputCost() -> Double { + guard let threshold = pricing.thresholdTokens else { + return (Double(nonCached) * pricing.inputCostPerToken) + (Double(cached) * cachedRate) + } + + let cachedBaseTokens = min(cached, threshold) + let cachedAboveTokens = max(cached - threshold, 0) + let nonCachedBaseAllowance = max(threshold - cached, 0) + let nonCachedBaseTokens = min(nonCached, nonCachedBaseAllowance) + let nonCachedAboveTokens = max(nonCached - nonCachedBaseTokens, 0) + let cachedAboveRate = pricing.cacheReadInputCostPerTokenAboveThreshold ?? cachedRate + let nonCachedAboveRate = pricing.inputCostPerTokenAboveThreshold ?? pricing.inputCostPerToken + + return (Double(cachedBaseTokens) * cachedRate) + + (Double(cachedAboveTokens) * cachedAboveRate) + + (Double(nonCachedBaseTokens) * pricing.inputCostPerToken) + + (Double(nonCachedAboveTokens) * nonCachedAboveRate) + } + + return inputCost() + tiered( max(0, outputTokens), base: pricing.outputCostPerToken, diff --git a/Tests/CodexBarTests/CostUsagePricingTests.swift b/Tests/CodexBarTests/CostUsagePricingTests.swift index 4ecb192cb2..ce25b84c3a 100644 --- a/Tests/CodexBarTests/CostUsagePricingTests.swift +++ b/Tests/CodexBarTests/CostUsagePricingTests.swift @@ -101,6 +101,24 @@ struct CostUsagePricingTests { #expect(gpt55 == (272_000.0 * 5e-6) + (128_000.0 * 3e-5)) } + @Test + func `codex cost applies long context threshold across cached and non cached input`() throws { + let root = try Self.cacheRoot() + let gpt55 = CostUsagePricing.codexCostUSD( + model: "gpt-5.5", + inputTokens: 300_000, + cachedInputTokens: 200_000, + outputTokens: 10, + modelsDevCacheRoot: root) + + let cachedBase = 200_000.0 * 5e-7 + let nonCachedBase = 72000.0 * 5e-6 + let nonCachedAbove = 28000.0 * 1e-5 + let output = 10.0 * 3e-5 + + #expect(gpt55 == cachedBase + nonCachedBase + nonCachedAbove + output) + } + @Test func `codex priority cost applies model specific fast rates`() { let gpt54 = CostUsagePricing.codexPriorityCostUSD( From 013399f4bf4c8ba2afd24accb98181ebf57ad036 Mon Sep 17 00:00:00 2001 From: Bryan Font Date: Mon, 11 May 2026 16:55:37 -0400 Subject: [PATCH 6/7] Persist Codex turn across incremental scans --- .../Vendored/CostUsage/CostUsageCache.swift | 3 +- .../Vendored/CostUsage/CostUsageScanner.swift | 12 ++- Tests/CodexBarTests/CostUsageCacheTests.swift | 2 +- .../CodexBarTests/CostUsageScannerTests.swift | 85 +++++++++++++++++++ 4 files changed, 98 insertions(+), 4 deletions(-) diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index 5779a50a40..f4885ed7a0 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -4,7 +4,7 @@ enum CostUsageCacheIO { private static func artifactVersion(for provider: UsageProvider) -> Int { switch provider { case .codex: - 5 + 6 case .claude, .vertexai: 2 default: @@ -76,6 +76,7 @@ struct CostUsageFileUsage: Codable { var parsedBytes: Int64? var lastModel: String? var lastTotals: CostUsageCodexTotals? + var lastCodexTurnID: String? var sessionId: String? var forkedFromId: String? var codexRows: [CostUsageScanner.CodexUsageRow]? diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index cb57ebf5fc..f18183920b 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -42,6 +42,7 @@ enum CostUsageScanner { let parsedBytes: Int64 let lastModel: String? let lastTotals: CostUsageCodexTotals? + let lastCodexTurnID: String? let sessionId: String? let forkedFromId: String? let rows: [CodexUsageRow] @@ -661,6 +662,7 @@ enum CostUsageScanner { startOffset: Int64 = 0, initialModel: String? = nil, initialTotals: CostUsageCodexTotals? = nil, + initialCodexTurnID: String? = nil, inheritedTotalsResolver: ((String, String) -> CostUsageCodexTotals?)? = nil) -> CodexParseResult { var currentModel = initialModel @@ -669,7 +671,7 @@ enum CostUsageScanner { var forkedFromId: String? var inheritedTotals: CostUsageCodexTotals? var remainingInheritedTotals: CostUsageCodexTotals? - var currentTurnID: String? + var currentTurnID = initialCodexTurnID var days: [String: [String: [Int]]] = [:] var rows: [CodexUsageRow] = [] @@ -902,6 +904,7 @@ enum CostUsageScanner { parsedBytes: parsedBytes, lastModel: currentModel, lastTotals: previousTotals, + lastCodexTurnID: currentTurnID, sessionId: sessionId, forkedFromId: forkedFromId, rows: rows) @@ -975,7 +978,8 @@ enum CostUsageScanner { range: range, startOffset: startOffset, initialModel: cached.lastModel, - initialTotals: cached.lastTotals) + initialTotals: cached.lastTotals, + initialCodexTurnID: cached.lastCodexTurnID) let sessionId = delta.sessionId ?? cached.sessionId if let sessionId, state.seenSessionIds.contains(sessionId) { dropCachedFile(cached) @@ -995,6 +999,7 @@ enum CostUsageScanner { parsedBytes: delta.parsedBytes, lastModel: delta.lastModel, lastTotals: delta.lastTotals, + lastCodexTurnID: delta.lastCodexTurnID, sessionId: sessionId, forkedFromId: delta.forkedFromId ?? cached.forkedFromId, codexRows: (cached.codexRows ?? []) + delta.rows) @@ -1030,6 +1035,7 @@ enum CostUsageScanner { parsedBytes: parsed.parsedBytes, lastModel: parsed.lastModel, lastTotals: parsed.lastTotals, + lastCodexTurnID: parsed.lastCodexTurnID, sessionId: sessionId, forkedFromId: parsed.forkedFromId, codexRows: parsed.rows) @@ -1328,6 +1334,7 @@ enum CostUsageScanner { parsedBytes: Int64?, lastModel: String? = nil, lastTotals: CostUsageCodexTotals? = nil, + lastCodexTurnID: String? = nil, sessionId: String? = nil, forkedFromId: String? = nil, codexRows: [CodexUsageRow]? = nil, @@ -1340,6 +1347,7 @@ enum CostUsageScanner { parsedBytes: parsedBytes, lastModel: lastModel, lastTotals: lastTotals, + lastCodexTurnID: lastCodexTurnID, sessionId: sessionId, forkedFromId: forkedFromId, codexRows: codexRows, diff --git a/Tests/CodexBarTests/CostUsageCacheTests.swift b/Tests/CodexBarTests/CostUsageCacheTests.swift index 533ad05770..961c24ab7f 100644 --- a/Tests/CodexBarTests/CostUsageCacheTests.swift +++ b/Tests/CodexBarTests/CostUsageCacheTests.swift @@ -10,7 +10,7 @@ struct CostUsageCacheTests { let codexURL = CostUsageCacheIO.cacheFileURL(provider: .codex, cacheRoot: root) let claudeURL = CostUsageCacheIO.cacheFileURL(provider: .claude, cacheRoot: root) - #expect(codexURL.lastPathComponent == "codex-v5.json") + #expect(codexURL.lastPathComponent == "codex-v6.json") #expect(claudeURL.lastPathComponent == "claude-v2.json") } } diff --git a/Tests/CodexBarTests/CostUsageScannerTests.swift b/Tests/CodexBarTests/CostUsageScannerTests.swift index c2eba4fe42..2b9dd7abe2 100644 --- a/Tests/CodexBarTests/CostUsageScannerTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerTests.swift @@ -415,6 +415,91 @@ struct CostUsageScannerTests { #expect(packed[2] == 6) } + @Test + func `codex incremental parsing keeps current turn id`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + let iso0 = env.isoString(for: day) + let iso1 = env.isoString(for: day.addingTimeInterval(1)) + let iso2 = env.isoString(for: day.addingTimeInterval(2)) + let iso3 = env.isoString(for: day.addingTimeInterval(3)) + + let model = "openai/gpt-5.5" + let turnID = "22222222-2222-2222-2222-222222222222" + let turnContext: [String: Any] = [ + "type": "turn_context", + "timestamp": iso0, + "payload": [ + "model": model, + ], + ] + let taskStarted: [String: Any] = [ + "type": "event_msg", + "timestamp": iso1, + "payload": [ + "type": "task_started", + "id": turnID, + ], + ] + let firstTokenCount: [String: Any] = [ + "type": "event_msg", + "timestamp": iso2, + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 100, + "cached_input_tokens": 20, + "output_tokens": 10, + ], + ], + ], + ] + + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "priority-session.jsonl", + contents: env.jsonl([turnContext, taskStarted, firstTokenCount])) + let range = CostUsageScanner.CostUsageDayRange(since: day, until: day) + + let first = CostUsageScanner.parseCodexFile(fileURL: fileURL, range: range) + #expect(first.lastCodexTurnID == turnID) + #expect(first.rows.map(\.turnID) == [turnID]) + + let secondTokenCount: [String: Any] = [ + "type": "event_msg", + "timestamp": iso3, + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 160, + "cached_input_tokens": 40, + "output_tokens": 16, + ], + ], + ], + ] + try env.jsonl([turnContext, taskStarted, firstTokenCount, secondTokenCount]) + .write(to: fileURL, atomically: true, encoding: .utf8) + + let delta = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: range, + startOffset: first.parsedBytes, + initialModel: first.lastModel, + initialTotals: first.lastTotals, + initialCodexTurnID: first.lastCodexTurnID) + + #expect(delta.lastCodexTurnID == turnID) + #expect(delta.rows.map(\.turnID) == [turnID]) + #expect(delta.rows.first?.input == 60) + #expect(delta.rows.first?.cached == 20) + #expect(delta.rows.first?.output == 6) + } + @Test func `claude incremental parsing reads appended lines only`() throws { let env = try CostUsageTestEnvironment() From 70c6487f85a33082b77a7e5a8d4738f2b17a665f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 14 May 2026 08:52:54 +0100 Subject: [PATCH 7/7] fix: price Codex long-context sessions --- CHANGELOG.md | 1 + .../Vendored/CostUsage/CostUsagePricing.swift | 46 ++++++------------- .../CodexBarTests/CostUsagePricingTests.swift | 19 ++++---- .../CostUsageScannerCodexPriorityTests.swift | 11 +++-- .../CostUsageScannerPriorityTests.swift | 4 +- 5 files changed, 33 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bc6ad0dcb..592e518962 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.26 — Unreleased ### Added +- Codex: add tiered long-context and Fast/Priority pricing to local cost history using local app-server priority traces (#917). Thanks @iam-brain! - Localization: add Simplified Chinese translations for Claude peak-hour labels (#921). Thanks @whtis! - Display: add a setting to hide quota-warning tick marks on usage bars while keeping quota warning notifications active (#918, fixes #916). Thanks @ThiagoCAltoe! - Menu: add an opt-in setting for provider changelog links, starting with Codex, Claude Code, and Gemini CLI (#929, fixes #660). Thanks @ThiagoCAltoe! diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift index 3c8f52d9ea..f8dbc147fa 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift @@ -434,38 +434,20 @@ enum CostUsagePricing { let nonCached = max(0, inputTokens - cached) let cachedRate = pricing.cacheReadInputCostPerToken ?? pricing.inputCostPerToken - func tiered(_ tokens: Int, base: Double, above: Double?, threshold: Int?) -> Double { - guard let threshold, let above else { return Double(tokens) * base } - let below = min(tokens, threshold) - let over = max(tokens - threshold, 0) - return Double(below) * base + Double(over) * above - } - - func inputCost() -> Double { - guard let threshold = pricing.thresholdTokens else { - return (Double(nonCached) * pricing.inputCostPerToken) + (Double(cached) * cachedRate) - } - - let cachedBaseTokens = min(cached, threshold) - let cachedAboveTokens = max(cached - threshold, 0) - let nonCachedBaseAllowance = max(threshold - cached, 0) - let nonCachedBaseTokens = min(nonCached, nonCachedBaseAllowance) - let nonCachedAboveTokens = max(nonCached - nonCachedBaseTokens, 0) - let cachedAboveRate = pricing.cacheReadInputCostPerTokenAboveThreshold ?? cachedRate - let nonCachedAboveRate = pricing.inputCostPerTokenAboveThreshold ?? pricing.inputCostPerToken - - return (Double(cachedBaseTokens) * cachedRate) - + (Double(cachedAboveTokens) * cachedAboveRate) - + (Double(nonCachedBaseTokens) * pricing.inputCostPerToken) - + (Double(nonCachedAboveTokens) * nonCachedAboveRate) - } - - return inputCost() - + tiered( - max(0, outputTokens), - base: pricing.outputCostPerToken, - above: pricing.outputCostPerTokenAboveThreshold, - threshold: pricing.thresholdTokens) + let usesLongContextRates = pricing.thresholdTokens.map { max(0, inputTokens) > $0 } ?? false + let inputRate = usesLongContextRates + ? pricing.inputCostPerTokenAboveThreshold ?? pricing.inputCostPerToken + : pricing.inputCostPerToken + let cachedInputRate = usesLongContextRates + ? pricing.cacheReadInputCostPerTokenAboveThreshold ?? cachedRate + : cachedRate + let outputRate = usesLongContextRates + ? pricing.outputCostPerTokenAboveThreshold ?? pricing.outputCostPerToken + : pricing.outputCostPerToken + + return (Double(nonCached) * inputRate) + + (Double(cached) * cachedInputRate) + + (Double(max(0, outputTokens)) * outputRate) } private static func codexCostUSD( diff --git a/Tests/CodexBarTests/CostUsagePricingTests.swift b/Tests/CodexBarTests/CostUsagePricingTests.swift index ce25b84c3a..4d4ea170b1 100644 --- a/Tests/CodexBarTests/CostUsagePricingTests.swift +++ b/Tests/CodexBarTests/CostUsagePricingTests.swift @@ -69,7 +69,7 @@ struct CostUsagePricingTests { } @Test - func `codex cost applies gpt54 and gpt55 long context tiers`() throws { + func `codex cost applies gpt54 and gpt55 long context rates to full session`() throws { let root = try Self.cacheRoot() let gpt54 = CostUsagePricing.codexCostUSD( model: "gpt-5.4", @@ -84,8 +84,8 @@ struct CostUsagePricingTests { outputTokens: 10, modelsDevCacheRoot: root) - #expect(gpt54 == (272_000.0 * 2.5e-6) + (1.0 * 5e-6) + (10.0 * 1.5e-5)) - #expect(gpt55 == (272_000.0 * 5e-6) + (1.0 * 1e-5) + (10.0 * 3e-5)) + #expect(gpt54 == (272_001.0 * 5e-6) + (10.0 * 2.25e-5)) + #expect(gpt55 == (272_001.0 * 1e-5) + (10.0 * 4.5e-5)) } @Test @@ -102,7 +102,7 @@ struct CostUsagePricingTests { } @Test - func `codex cost applies long context threshold across cached and non cached input`() throws { + func `codex cost applies long context rates to all cached and non cached input`() throws { let root = try Self.cacheRoot() let gpt55 = CostUsagePricing.codexCostUSD( model: "gpt-5.5", @@ -111,12 +111,11 @@ struct CostUsagePricingTests { outputTokens: 10, modelsDevCacheRoot: root) - let cachedBase = 200_000.0 * 5e-7 - let nonCachedBase = 72000.0 * 5e-6 - let nonCachedAbove = 28000.0 * 1e-5 - let output = 10.0 * 3e-5 + let cached = 200_000.0 * 1e-6 + let nonCached = 100_000.0 * 1e-5 + let output = 10.0 * 4.5e-5 - #expect(gpt55 == cachedBase + nonCachedBase + nonCachedAbove + output) + #expect(gpt55 == cached + nonCached + output) } @Test @@ -209,7 +208,7 @@ struct CostUsagePricingTests { modelsDevCacheRoot: root) #expect(atBoundary == (272_000.0 * 5e-6) + (10.0 * 3e-5)) - #expect(aboveBoundary == (272_000.0 * 5e-6) + (1.0 * 1e-5) + (10.0 * 3e-5)) + #expect(aboveBoundary == (272_001.0 * 1e-5) + (10.0 * 4.5e-5)) } @Test diff --git a/Tests/CodexBarTests/CostUsageScannerCodexPriorityTests.swift b/Tests/CodexBarTests/CostUsageScannerCodexPriorityTests.swift index 1ad2970f08..ba4fd6a333 100644 --- a/Tests/CodexBarTests/CostUsageScannerCodexPriorityTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerCodexPriorityTests.swift @@ -68,21 +68,24 @@ struct CostUsageScannerCodexPriorityTests { defer { env.cleanup() } let dbURL = env.root.appendingPathComponent("logs_2.sqlite") try Self.createTestLogsDatabase(at: dbURL) + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + let previousDay = try #require(Calendar.current.date(byAdding: .day, value: -1, to: day)) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) try Self.insertTestLog( dbURL: dbURL, - timestamp: "2026-05-09T23:59:59Z", + timestamp: env.isoString(for: previousDay), body: "thread_id=thread-old turn.id=turn-old websocket request: " + #"{"type":"response.create","model":"gpt-5.5","service_tier":"priority"}"#) try Self.insertTestLog( dbURL: dbURL, - timestamp: "2026-05-10T12:00:00Z", + timestamp: env.isoString(for: day), body: "thread_id=thread-new turn.id=turn-new websocket request: " + #"{"type":"response.create","model":"gpt-5.5","service_tier":"priority"}"#) let turns = CostUsageScanner.codexPriorityTurns( databaseURL: dbURL, - sinceDayKey: "2026-05-10", - untilDayKey: "2026-05-10") + sinceDayKey: dayKey, + untilDayKey: dayKey) #expect(turns.keys.sorted() == ["turn-new"]) } diff --git a/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift b/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift index c05203ce62..e52a3dd478 100644 --- a/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift @@ -156,8 +156,8 @@ struct CostUsageScannerPriorityTests { until: day, now: day, options: options) - let standardTurnBase = (272_000.0 * 5e-6) + (1.0 * 1e-5) + (10.0 * 3e-5) - let standardFirstRow = (272_000.0 * 5e-6) + (28000.0 * 1e-5) + (5.0 * 3e-5) + let standardTurnBase = (272_001.0 * 1e-5) + (10.0 * 4.5e-5) + let standardFirstRow = (300_000.0 * 1e-5) + (5.0 * 4.5e-5) let prioritySecondRow = (100_001.0 * 1.25e-5) + (5.0 * 7.5e-5) let expected = standardTurnBase + standardFirstRow + prioritySecondRow