From c0550030858e845a8c80299dd3170ecd280d02d6 Mon Sep 17 00:00:00 2001 From: Bryan Font Date: Wed, 20 May 2026 16:55:19 -0400 Subject: [PATCH 1/6] Show standard and fast cost splits --- .../CodexBar/CostHistoryChartMenuView.swift | 136 ++++-- .../InlineUsageDashboardContent.swift | 74 ---- Sources/CodexBarCore/CostUsageModels.swift | 56 ++- .../Generated/CodexParserHash.generated.swift | 2 +- .../Vendored/CostUsage/CostUsageCache.swift | 4 + .../CostUsageScanner+CacheHelpers.swift | 388 ++++++++++++++++-- .../CostUsageScanner+CodexPriority.swift | 42 +- .../CostUsageDailyReportMergeTests.swift | 27 +- .../CostUsageScannerBreakdownTests.swift | 77 ++++ .../CostUsageScannerCodexPriorityTests.swift | 55 +++ .../CostUsageScannerPriorityTests.swift | 101 +++++ Tests/CodexBarTests/MenuCardModelTests.swift | 8 +- 12 files changed, 820 insertions(+), 150 deletions(-) diff --git a/Sources/CodexBar/CostHistoryChartMenuView.swift b/Sources/CodexBar/CostHistoryChartMenuView.swift index f5d1718a0..406e85ece 100644 --- a/Sources/CodexBar/CostHistoryChartMenuView.swift +++ b/Sources/CodexBar/CostHistoryChartMenuView.swift @@ -24,6 +24,7 @@ struct CostHistoryChartMenuView: View { let id: String let title: String let subtitle: String? + let modeSubtitle: String? let accentColor: Color } @@ -123,7 +124,9 @@ struct CostHistoryChartMenuView: View { HStack(alignment: .top, spacing: 8) { Rectangle() .fill(row.accentColor) - .frame(width: 2, height: row.subtitle == nil ? 14 : Self.detailRowHeight) + .frame( + width: 2, + height: Self.accentHeight(for: row)) .padding(.top, 1) VStack(alignment: .leading, spacing: 1) { @@ -132,26 +135,38 @@ struct CostHistoryChartMenuView: View { .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.tail) + .frame(height: Self.detailTitleLineHeight, alignment: .leading) if let subtitle = row.subtitle { Text(subtitle) .font(.caption2) .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) .lineLimit(1) .truncationMode(.tail) + .frame(height: Self.detailSubtitleLineHeight, alignment: .leading) + } + if let modeSubtitle = row.modeSubtitle { + Text(modeSubtitle) + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + .lineLimit(1) + .truncationMode(.tail) + .frame(height: Self.detailSubtitleLineHeight, alignment: .leading) } } } - .frame(height: Self.detailRowHeight, alignment: .leading) + .frame(height: Self.detailRowHeight(for: row), alignment: .leading) } ForEach(0.. String { days == 1 ? "today" : "\(days)d" } + private static func detailRowHeight(for row: DetailRow) -> CGFloat { + self.detailRowHeight(hasModeSubtitle: row.modeSubtitle != nil) + } + + private static func detailRowHeight(hasModeSubtitle: Bool) -> CGFloat { + hasModeSubtitle ? self.expandedDetailRowHeight : self.compactDetailRowHeight + } + + private static func accentHeight(for row: DetailRow) -> CGFloat { + row.subtitle == nil && row.modeSubtitle == nil ? 14 : self.detailRowHeight(for: row) + } + private static func capHeight(maxValue: Double) -> Double { maxValue * 0.05 } @@ -211,6 +242,7 @@ struct CostHistoryChartMenuView: View { var peak: (key: String, costUSD: Double)? var maxCostUSD: Double = 0 var maxRenderedBreakdownRows = 0 + var detailRowMetrics: [(count: Int, height: CGFloat)] = [] for entry in sorted { guard let costUSD = entry.costUSD, costUSD >= 0 else { continue } guard let date = self.dateFromDayKey(entry.date) else { continue } @@ -219,7 +251,9 @@ struct CostHistoryChartMenuView: View { pointsByKey[entry.date] = point entriesByKey[entry.date] = entry dateKeys.append((entry.date, date)) - maxRenderedBreakdownRows = max(maxRenderedBreakdownRows, Self.renderedBreakdownRowCount(for: entry)) + let rowMetric = Self.renderedBreakdownRowsMetric(for: entry) + detailRowMetrics.append(rowMetric) + maxRenderedBreakdownRows = max(maxRenderedBreakdownRows, rowMetric.count) if let cur = peak { if costUSD > cur.costUSD { peak = (entry.date, costUSD) } } else { @@ -235,6 +269,11 @@ struct CostHistoryChartMenuView: View { }() let barColor = Self.barColor(for: provider) + let maxDetailRowsHeight = detailRowMetrics.reduce(CGFloat(0)) { currentMax, metric in + let fillerRows = max(maxRenderedBreakdownRows - metric.count, 0) + let filledHeight = metric.height + (CGFloat(fillerRows) * Self.compactDetailRowHeight) + return max(currentMax, filledHeight) + } return Model( points: points, pointsByDateKey: pointsByKey, @@ -244,7 +283,8 @@ struct CostHistoryChartMenuView: View { barColor: barColor, peakKey: maxCostUSD > 0 ? peak?.key : nil, maxCostUSD: maxCostUSD, - maxRenderedBreakdownRows: maxRenderedBreakdownRows) + maxRenderedBreakdownRows: maxRenderedBreakdownRows, + maxDetailRowsHeight: maxDetailRowsHeight) } private static func barColor(for provider: UsageProvider) -> Color { @@ -274,15 +314,25 @@ struct CostHistoryChartMenuView: View { return model.pointsByDateKey[key] } - private static func renderedBreakdownRowCount(for entry: DailyEntry) -> Int { - guard let breakdown = entry.modelBreakdowns, !breakdown.isEmpty else { return 0 } - return min(breakdown.count, self.maxVisibleDetailLines) + private static func renderedBreakdownRowsMetric(for entry: DailyEntry) -> (count: Int, height: CGFloat) { + guard let breakdown = entry.modelBreakdowns, !breakdown.isEmpty else { return (0, 0) } + let renderedRows = Array( + self.sortedBreakdown(breakdown) + .prefix(self.maxVisibleDetailLines)) + let height = renderedRows.reduce(CGFloat(0)) { total, item in + total + self.detailRowHeight(hasModeSubtitle: Self.hasModeSubtitle(item)) + } + return (renderedRows.count, height) + } + + private static func hasModeSubtitle(_ item: CostUsageDailyReport.ModelBreakdown) -> Bool { + item.standardCostUSD != nil || item.priorityCostUSD != nil } - private static func detailBlockHeight(maxBreakdownRows: Int) -> CGFloat { + private static func detailBlockHeight(maxBreakdownRows: Int, maxRowsHeight: CGFloat) -> CGFloat { guard maxBreakdownRows > 0 else { return self.detailPrimaryLineHeight } return self.detailPrimaryLineHeight + - (CGFloat(maxBreakdownRows) * self.detailRowHeight) + + maxRowsHeight + (CGFloat(maxBreakdownRows) * self.detailSpacing) } @@ -383,32 +433,62 @@ struct CostHistoryChartMenuView: View { guard let entry = model.entriesByDateKey[key] else { return [] } guard let breakdown = entry.modelBreakdowns, !breakdown.isEmpty else { return [] } - return breakdown - .sorted { lhs, rhs in - let lCost = lhs.costUSD ?? -1 - let rCost = rhs.costUSD ?? -1 - if lCost != rCost { return lCost > rCost } - - let lTokens = lhs.totalTokens ?? -1 - let rTokens = rhs.totalTokens ?? -1 - if lTokens != rTokens { return lTokens > rTokens } - - return lhs.modelName > rhs.modelName - } + return Self.sortedBreakdown(breakdown) .prefix(Self.maxVisibleDetailLines) .enumerated() .map { index, item in DetailRow( id: "\(item.modelName)-\(index)", title: UsageFormatter.modelDisplayName(item.modelName), - subtitle: UsageFormatter.modelCostDetail( - item.modelName, - costUSD: item.costUSD, - totalTokens: item.totalTokens), + subtitle: self.modelBreakdownTotalSubtitle(item), + modeSubtitle: self.modelBreakdownModeSubtitle(item), accentColor: model.barColor.opacity(Self.breakdownAccentOpacity(for: index))) } } + private static func sortedBreakdown( + _ breakdown: [CostUsageDailyReport.ModelBreakdown]) -> [CostUsageDailyReport.ModelBreakdown] + { + breakdown.sorted { lhs, rhs in + let lCost = lhs.costUSD ?? -1 + let rCost = rhs.costUSD ?? -1 + if lCost != rCost { return lCost > rCost } + + let lTokens = lhs.totalTokens ?? -1 + let rTokens = rhs.totalTokens ?? -1 + if lTokens != rTokens { return lTokens > rTokens } + + return lhs.modelName > rhs.modelName + } + } + + private func modelBreakdownTotalSubtitle(_ item: CostUsageDailyReport.ModelBreakdown) -> String? { + UsageFormatter.modelCostDetail( + item.modelName, + costUSD: item.costUSD, + totalTokens: item.totalTokens) + } + + private func modelBreakdownModeSubtitle(_ item: CostUsageDailyReport.ModelBreakdown) -> String? { + var parts: [String] = [] + if let standardCost = item.standardCostUSD { + var standardPart = "Std \(UsageFormatter.usdString(standardCost))" + if let standardTokens = item.standardTokens { + standardPart += " · \(UsageFormatter.tokenCountString(standardTokens))" + } + parts.append(standardPart) + } + if let priorityCost = item.priorityCostUSD { + var priorityPart = "Fast \(UsageFormatter.usdString(priorityCost))" + if let priorityTokens = item.priorityTokens { + priorityPart += " · \(UsageFormatter.tokenCountString(priorityTokens))" + } + parts.append(priorityPart) + } + guard !parts.isEmpty else { return nil } + return parts.joined(separator: " / ") + } + private static func breakdownAccentOpacity(for index: Int) -> Double { let opacity = 0.75 - (Double(index) * 0.12) return max(0.3, opacity) diff --git a/Sources/CodexBar/InlineUsageDashboardContent.swift b/Sources/CodexBar/InlineUsageDashboardContent.swift index 26b00910c..f9daa89e4 100644 --- a/Sources/CodexBar/InlineUsageDashboardContent.swift +++ b/Sources/CodexBar/InlineUsageDashboardContent.swift @@ -109,13 +109,6 @@ extension UsageMenuCardView.Model { { return Self.minimaxInlineDashboard(billing) } - if [.codex, .claude, .vertexai, .bedrock].contains(input.provider), - input.tokenCostUsageEnabled, - let tokenSnapshot = input.tokenSnapshot, - !tokenSnapshot.daily.isEmpty - { - return Self.costHistoryInlineDashboard(provider: input.provider, snapshot: tokenSnapshot) - } return nil } @@ -193,57 +186,6 @@ extension UsageMenuCardView.Model { detailLines: details) } - private static func costHistoryInlineDashboard( - provider: UsageProvider, - snapshot: CostUsageTokenSnapshot) -> InlineUsageDashboardModel - { - let historyDays = max(1, min(365, snapshot.historyDays)) - let historyLabel = historyDays == 1 ? "Today" : "\(historyDays)d" - let periodLabel = historyDays == 1 ? "today" : "\(historyDays) day" - let points = snapshot.daily.suffix(historyDays).compactMap { entry -> InlineUsageDashboardModel.Point? in - guard let cost = entry.costUSD else { return nil } - return InlineUsageDashboardModel.Point( - id: entry.date, - label: Self.shortDayLabel(entry.date), - value: cost, - accessibilityValue: "\(entry.date): \(UsageFormatter.usdString(cost))") - } - let latest = snapshot.daily.max { lhs, rhs in lhs.date < rhs.date } - var details: [String] = [] - if let topModel = Self.topCostModel(from: snapshot.daily) { - details.append("Top model: \(Self.shortModelName(topModel))") - } - if provider == .bedrock { - details.append("AWS Cost Explorer billing can lag.") - } else { - details.append(UsageFormatter.costEstimateHint(provider: provider)) - } - let providerName = ProviderDefaults.metadata[provider]?.displayName ?? provider.rawValue - return InlineUsageDashboardModel( - accessibilityLabel: "\(providerName) \(periodLabel) cost trend", - valueStyle: .currencyUSD, - kpis: [ - .init( - title: provider == .bedrock ? "Latest" : "Today", - value: latest?.costUSD.map(UsageFormatter.usdString) ?? "—", - emphasis: true), - .init( - title: "\(historyLabel) cost", - value: snapshot.last30DaysCostUSD.map(UsageFormatter.usdString) ?? "—", - emphasis: false), - .init( - title: "\(historyLabel) tokens", - value: snapshot.last30DaysTokens.map(UsageFormatter.tokenCountString) ?? "—", - emphasis: false), - .init( - title: "Latest tokens", - value: latest?.totalTokens.map(UsageFormatter.tokenCountString) ?? "—", - emphasis: false), - ], - points: points, - detailLines: details) - } - private static func openRouterInlineDashboard(_ usage: OpenRouterUsageSnapshot) -> InlineUsageDashboardModel? { let periodValues: [(String, String, Double?)] = [ ("day", "Today", usage.keyUsageDaily), @@ -407,22 +349,6 @@ extension UsageMenuCardView.Model { detailLines: details) } - private static func topCostModel(from entries: [CostUsageDailyReport.Entry]) -> String? { - var scores: [String: (cost: Double, tokens: Int)] = [:] - for entry in entries { - for model in entry.modelBreakdowns ?? [] { - var score = scores[model.modelName] ?? (0, 0) - score.cost += model.costUSD ?? 0 - score.tokens += model.totalTokens ?? 0 - scores[model.modelName] = score - } - } - return scores.max { - if $0.value.cost == $1.value.cost { return $0.value.tokens < $1.value.tokens } - return $0.value.cost < $1.value.cost - }?.key - } - private static func topMistralModel(from entries: [MistralDailyUsageBucket]) -> String? { var tokens: [String: Int] = [:] for entry in entries { diff --git a/Sources/CodexBarCore/CostUsageModels.swift b/Sources/CodexBarCore/CostUsageModels.swift index 986bc0e07..072149362 100644 --- a/Sources/CodexBarCore/CostUsageModels.swift +++ b/Sources/CodexBarCore/CostUsageModels.swift @@ -33,12 +33,20 @@ public struct CostUsageDailyReport: Sendable, Decodable { public let modelName: String public let costUSD: Double? public let totalTokens: Int? + public let standardCostUSD: Double? + public let priorityCostUSD: Double? + public let standardTokens: Int? + public let priorityTokens: Int? private enum CodingKeys: String, CodingKey { case modelName case costUSD case cost case totalTokens + case standardCostUSD + case priorityCostUSD + case standardTokens + case priorityTokens } public init(from decoder: Decoder) throws { @@ -48,12 +56,28 @@ public struct CostUsageDailyReport: Sendable, Decodable { try container.decodeIfPresent(Double.self, forKey: .costUSD) ?? container.decodeIfPresent(Double.self, forKey: .cost) self.totalTokens = try container.decodeIfPresent(Int.self, forKey: .totalTokens) + self.standardCostUSD = try container.decodeIfPresent(Double.self, forKey: .standardCostUSD) + self.priorityCostUSD = try container.decodeIfPresent(Double.self, forKey: .priorityCostUSD) + self.standardTokens = try container.decodeIfPresent(Int.self, forKey: .standardTokens) + self.priorityTokens = try container.decodeIfPresent(Int.self, forKey: .priorityTokens) } - public init(modelName: String, costUSD: Double?, totalTokens: Int? = nil) { + public init( + modelName: String, + costUSD: Double?, + totalTokens: Int? = nil, + standardCostUSD: Double? = nil, + priorityCostUSD: Double? = nil, + standardTokens: Int? = nil, + priorityTokens: Int? = nil) + { self.modelName = modelName self.costUSD = costUSD self.totalTokens = totalTokens + self.standardCostUSD = standardCostUSD + self.priorityCostUSD = priorityCostUSD + self.standardTokens = standardTokens + self.priorityTokens = priorityTokens } } @@ -244,6 +268,14 @@ extension CostUsageDailyReport { var sawTotalTokens = false var costUSD: Double = 0 var sawCost = false + var standardCostUSD: Double = 0 + var sawStandardCost = false + var priorityCostUSD: Double = 0 + var sawPriorityCost = false + var standardTokens: Int = 0 + var sawStandardTokens = false + var priorityTokens: Int = 0 + var sawPriorityTokens = false mutating func add(_ breakdown: ModelBreakdown) { if let totalTokens = breakdown.totalTokens { @@ -254,13 +286,33 @@ extension CostUsageDailyReport { self.costUSD += costUSD self.sawCost = true } + if let standardCostUSD = breakdown.standardCostUSD { + self.standardCostUSD += standardCostUSD + self.sawStandardCost = true + } + if let priorityCostUSD = breakdown.priorityCostUSD { + self.priorityCostUSD += priorityCostUSD + self.sawPriorityCost = true + } + if let standardTokens = breakdown.standardTokens { + self.standardTokens += standardTokens + self.sawStandardTokens = true + } + if let priorityTokens = breakdown.priorityTokens { + self.priorityTokens += priorityTokens + self.sawPriorityTokens = true + } } func build(modelName: String) -> ModelBreakdown { ModelBreakdown( modelName: modelName, costUSD: self.sawCost ? self.costUSD : nil, - totalTokens: self.sawTotalTokens ? self.totalTokens : nil) + totalTokens: self.sawTotalTokens ? self.totalTokens : nil, + standardCostUSD: self.sawStandardCost ? self.standardCostUSD : nil, + priorityCostUSD: self.sawPriorityCost ? self.priorityCostUSD : nil, + standardTokens: self.sawStandardTokens ? self.standardTokens : nil, + priorityTokens: self.sawPriorityTokens ? self.priorityTokens : nil) } } diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index adeea0344..644df1424 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "6fecf2b30a8d4617" + static let value = "6466d704a7fbc9b9" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index 1d39db8aa..effe1478d 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -119,6 +119,10 @@ struct CostUsageFileUsage: Codable { var forkedFromId: String? var codexCostNanos: [String: [String: Int64]]? var codexPrioritySurchargeNanos: [String: [String: Int64]]? + var codexStandardCostNanos: [String: [String: Int64]]? + var codexPriorityCostNanos: [String: [String: Int64]]? + var codexStandardTokens: [String: [String: Int]]? + var codexPriorityTokens: [String: [String: Int]]? var codexTurnIDs: [String]? var codexRows: [CostUsageScanner.CodexUsageRow]? var claudeRows: [CostUsageScanner.ClaudeUsageRow]? diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift index e8a661b6b..f8c933b2b 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift @@ -43,6 +43,34 @@ extension CostUsageScanner { self.codexNanosByDayModel(cache: cache, range: range) { $0.codexPrioritySurchargeNanos } } + static func codexStandardCostNanosByDayModel( + cache: CostUsageCache, + range: CostUsageDayRange) -> [String: [String: Int64]] + { + self.codexNanosByDayModel(cache: cache, range: range) { $0.codexStandardCostNanos } + } + + static func codexPriorityCostNanosByDayModel( + cache: CostUsageCache, + range: CostUsageDayRange) -> [String: [String: Int64]] + { + self.codexNanosByDayModel(cache: cache, range: range) { $0.codexPriorityCostNanos } + } + + static func codexStandardTokensByDayModel( + cache: CostUsageCache, + range: CostUsageDayRange) -> [String: [String: Int]] + { + self.codexIntByDayModel(cache: cache, range: range) { $0.codexStandardTokens } + } + + static func codexPriorityTokensByDayModel( + cache: CostUsageCache, + range: CostUsageDayRange) -> [String: [String: Int]] + { + self.codexIntByDayModel(cache: cache, range: range) { $0.codexPriorityTokens } + } + static func codexNanosByDayModel( cache: CostUsageCache, range: CostUsageDayRange, @@ -61,6 +89,24 @@ extension CostUsageScanner { return out } + static func codexIntByDayModel( + cache: CostUsageCache, + range: CostUsageDayRange, + keyPath: (CostUsageFileUsage) -> [String: [String: Int]]?) -> [String: [String: Int]] + { + var out: [String: [String: Int]] = [:] + for usage in cache.files.values { + for (day, models) in keyPath(usage) ?? [:] { + guard CostUsageDayRange.isInRange(dayKey: day, since: range.sinceKey, until: range.untilKey) + else { continue } + for (model, value) in models { + out[day, default: [:]][model, default: 0] += value + } + } + } + return out + } + static func codexRowsCostUSD( rows: [CodexUsageRow], modelsDevCatalog: ModelsDevCatalog?, @@ -92,16 +138,17 @@ extension CostUsageScanner { var total: Double = 0 var seen = false for row in rows { - guard let turnID = row.turnID, priorityTurns[turnID] != nil else { continue } + guard let turnID = row.turnID, let priorityMetadata = priorityTurns[turnID] else { continue } + let pricedModel = priorityMetadata.model ?? row.model guard let baseCost = CostUsagePricing.codexCostUSD( - model: row.model, + model: pricedModel, inputTokens: row.input, cachedInputTokens: row.cached, outputTokens: row.output, modelsDevCatalog: modelsDevCatalog, modelsDevCacheRoot: modelsDevCacheRoot), let priorityCost = CostUsagePricing.codexPriorityCostUSD( - model: row.model, + model: pricedModel, inputTokens: row.input, cachedInputTokens: row.cached, outputTokens: row.output) @@ -112,6 +159,84 @@ extension CostUsageScanner { return seen ? total : nil } + struct CodexRowCostBreakdown { + var standardCostUSD: Double = 0 + var priorityCostUSD: Double = 0 + var standardTokens: Int = 0 + var priorityTokens: Int = 0 + var sawStandardCost = false + var sawPriorityCost = false + + var optionalStandardCostUSD: Double? { + self.sawStandardCost ? self.standardCostUSD : nil + } + + var optionalPriorityCostUSD: Double? { + self.sawPriorityCost ? self.priorityCostUSD : nil + } + + var optionalStandardTokens: Int? { + self.standardTokens > 0 ? self.standardTokens : nil + } + + var optionalPriorityTokens: Int? { + self.priorityTokens > 0 ? self.priorityTokens : nil + } + + var totalCostUSD: Double? { + guard self.sawStandardCost || self.sawPriorityCost else { return nil } + return self.standardCostUSD + self.priorityCostUSD + } + + var hasModeSplit: Bool { + self.sawPriorityCost || self.priorityTokens > 0 + } + } + + static func codexRowCostBreakdown( + rows: [CodexUsageRow], + priorityTurns: [String: CodexPriorityTurnMetadata], + modelsDevCatalog: ModelsDevCatalog?, + modelsDevCacheRoot: URL?) -> CodexRowCostBreakdown + { + var breakdown = CodexRowCostBreakdown() + for row in rows { + let tokenCount = row.input + row.output + let priorityMetadata = row.turnID.flatMap { priorityTurns[$0] } + let isPriority = priorityMetadata != nil + if isPriority { + breakdown.priorityTokens += tokenCount + } else { + breakdown.standardTokens += tokenCount + } + let pricedModel = priorityMetadata?.model ?? row.model + + let baseCost = CostUsagePricing.codexCostUSD( + model: pricedModel, + inputTokens: row.input, + cachedInputTokens: row.cached, + outputTokens: row.output, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + if isPriority, let priorityCost = CostUsagePricing.codexPriorityCostUSD( + model: pricedModel, + inputTokens: row.input, + cachedInputTokens: row.cached, + outputTokens: row.output) + { + breakdown.priorityCostUSD += max(priorityCost, baseCost ?? priorityCost) + breakdown.sawPriorityCost = true + } else if isPriority, let baseCost { + breakdown.priorityCostUSD += baseCost + breakdown.sawPriorityCost = true + } else if let baseCost { + breakdown.standardCostUSD += baseCost + breakdown.sawStandardCost = true + } + } + return breakdown + } + // MARK: - File cache construction static func makeFileUsage( @@ -129,6 +254,10 @@ extension CostUsageScanner { forkedFromId: String? = nil, codexCostNanos: [String: [String: Int64]]? = nil, codexPrioritySurchargeNanos: [String: [String: Int64]]? = nil, + codexStandardCostNanos: [String: [String: Int64]]? = nil, + codexPriorityCostNanos: [String: [String: Int64]]? = nil, + codexStandardTokens: [String: [String: Int]]? = nil, + codexPriorityTokens: [String: [String: Int]]? = nil, codexTurnIDs: [String]? = nil, codexRows: [CodexUsageRow]? = nil, claudeRows: [ClaudeUsageRow]? = nil) -> CostUsageFileUsage @@ -148,20 +277,32 @@ extension CostUsageScanner { forkedFromId: forkedFromId, codexCostNanos: codexCostNanos, codexPrioritySurchargeNanos: codexPrioritySurchargeNanos, + codexStandardCostNanos: codexStandardCostNanos, + codexPriorityCostNanos: codexPriorityCostNanos, + codexStandardTokens: codexStandardTokens, + codexPriorityTokens: codexPriorityTokens, codexTurnIDs: codexTurnIDs, codexRows: codexRows, claudeRows: claudeRows) } static func needsCodexCostCache(_ usage: CostUsageFileUsage) -> Bool { - usage.codexCostNanos == nil && !(usage.codexRows?.isEmpty ?? true) + !(usage.codexRows?.isEmpty ?? true) + && (usage.codexCostNanos == nil || self.needsCodexModeSplitCache(usage)) } static func needsCodexCostCache(_ usage: CostUsageFileUsage, range: CostUsageDayRange) -> Bool { guard let rows = usage.codexRows, !rows.isEmpty else { return false } return rows.contains { CostUsageDayRange.isInRange(dayKey: $0.day, since: range.sinceKey, until: range.untilKey) - } + } && (usage.codexCostNanos == nil || Self.needsCodexModeSplitCache(usage)) + } + + static func needsCodexModeSplitCache(_ usage: CostUsageFileUsage) -> Bool { + usage.codexStandardCostNanos == nil + || usage.codexPriorityCostNanos == nil + || usage.codexStandardTokens == nil + || usage.codexPriorityTokens == nil } static func codexFileUsageWithCostCache( @@ -184,22 +325,36 @@ extension CostUsageScanner { } guard !migratedRows.isEmpty else { return usage } + let splitMaps = Self.codexModeSplitMaps( + rows: migratedRows, + range: context.range, + priorityTurns: context.resources.priorityTurns, + modelsDevCatalog: context.resources.modelsDevCatalog, + modelsDevCacheRoot: context.resources.modelsDevCacheRoot) var updated = usage - updated.codexCostNanos = Self.mergeCostMaps( - usage.codexCostNanos, - Self.codexCostNanos( - rows: migratedRows, - range: context.range, - modelsDevCatalog: context.resources.modelsDevCatalog, - modelsDevCacheRoot: context.resources.modelsDevCacheRoot)) - updated.codexPrioritySurchargeNanos = Self.mergeCostMaps( - usage.codexPrioritySurchargeNanos, - Self.codexPrioritySurchargeNanos( - rows: migratedRows, - range: context.range, - priorityTurns: context.resources.priorityTurns, - modelsDevCatalog: context.resources.modelsDevCatalog, - modelsDevCacheRoot: context.resources.modelsDevCacheRoot)) + updated.codexCostNanos = usage.codexCostNanos ?? Self.codexCostNanos( + rows: migratedRows, + range: context.range, + modelsDevCatalog: context.resources.modelsDevCatalog, + modelsDevCacheRoot: context.resources.modelsDevCacheRoot) + updated.codexPrioritySurchargeNanos = usage.codexPrioritySurchargeNanos ?? Self.codexPrioritySurchargeNanos( + rows: migratedRows, + range: context.range, + priorityTurns: context.resources.priorityTurns, + modelsDevCatalog: context.resources.modelsDevCatalog, + modelsDevCacheRoot: context.resources.modelsDevCacheRoot) + updated.codexStandardCostNanos = Self.mergeCostMaps( + usage.codexStandardCostNanos, + splitMaps.standardCostNanos) + updated.codexPriorityCostNanos = Self.mergeCostMaps( + usage.codexPriorityCostNanos, + splitMaps.priorityCostNanos) + updated.codexStandardTokens = Self.mergeIntMaps( + usage.codexStandardTokens, + splitMaps.standardTokens) + updated.codexPriorityTokens = Self.mergeIntMaps( + usage.codexPriorityTokens, + splitMaps.priorityTokens) updated.codexTurnIDs = Self.mergeCodexTurnIDs(usage.codexTurnIDs, rows: migratedRows) updated.codexRows = retainedRows.isEmpty ? nil : retainedRows return updated @@ -279,6 +434,69 @@ extension CostUsageScanner { return out.isEmpty ? nil : out } + static func codexModeSplitMaps( + rows: [CodexUsageRow], + range: CostUsageDayRange, + priorityTurns: [String: CodexPriorityTurnMetadata], + modelsDevCatalog: ModelsDevCatalog?, + modelsDevCacheRoot: URL?) -> ( + standardCostNanos: [String: [String: Int64]]?, + priorityCostNanos: [String: [String: Int64]]?, + standardTokens: [String: [String: Int]]?, + priorityTokens: [String: [String: Int]]?) + { + var standardCostNanos: [String: [String: Int64]] = [:] + var priorityCostNanos: [String: [String: Int64]] = [:] + var standardTokens: [String: [String: Int]] = [:] + var priorityTokens: [String: [String: Int]] = [:] + + for row in rows { + guard CostUsageDayRange.isInRange(dayKey: row.day, since: range.sinceKey, until: range.untilKey) + else { continue } + + let tokenCount = row.input + row.output + let priorityMetadata = row.turnID.flatMap { priorityTurns[$0] } + let pricedModel = priorityMetadata?.model ?? row.model + let isPriority = priorityMetadata != nil + + if isPriority { + priorityTokens[row.day, default: [:]][row.model, default: 0] += tokenCount + } else { + standardTokens[row.day, default: [:]][row.model, default: 0] += tokenCount + } + + let baseCost = CostUsagePricing.codexCostUSD( + model: pricedModel, + inputTokens: row.input, + cachedInputTokens: row.cached, + outputTokens: row.output, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + + if isPriority, let priorityCost = CostUsagePricing.codexPriorityCostUSD( + model: pricedModel, + inputTokens: row.input, + cachedInputTokens: row.cached, + outputTokens: row.output) + { + priorityCostNanos[row.day, default: [:]][row.model, default: 0] += Int64( + (max(priorityCost, baseCost ?? priorityCost) * Self.costScale).rounded()) + } else if isPriority, let baseCost { + priorityCostNanos[row.day, default: [:]][row.model, default: 0] += Int64( + (baseCost * Self.costScale).rounded()) + } else if let baseCost { + standardCostNanos[row.day, default: [:]][row.model, default: 0] += Int64( + (baseCost * Self.costScale).rounded()) + } + } + + return ( + standardCostNanos.isEmpty ? nil : standardCostNanos, + priorityCostNanos.isEmpty ? nil : priorityCostNanos, + standardTokens.isEmpty ? nil : standardTokens, + priorityTokens.isEmpty ? nil : priorityTokens) + } + static func codexTurnIDs(rows: [CodexUsageRow]) -> [String]? { let ids = Set(rows.compactMap(\.turnID)) return ids.sorted() @@ -303,6 +521,19 @@ extension CostUsageScanner { return out.isEmpty ? nil : out } + static func mergeIntMaps( + _ existing: [String: [String: Int]]?, + _ delta: [String: [String: Int]]?) -> [String: [String: Int]]? + { + var out = existing ?? [:] + for (day, models) in delta ?? [:] { + for (model, value) in models { + out[day, default: [:]][model, default: 0] += value + } + } + return out.isEmpty ? nil : out + } + static func costMapOutsideScanWindow( _ map: [String: [String: Int64]]?, range: CostUsageDayRange) -> [String: [String: Int64]]? @@ -313,6 +544,16 @@ extension CostUsageScanner { return filtered.isEmpty ? nil : filtered } + static func intMapOutsideScanWindow( + _ map: [String: [String: Int]]?, + range: CostUsageDayRange) -> [String: [String: Int]]? + { + let filtered = (map ?? [:]).filter { + !CostUsageDayRange.isInRange(dayKey: $0.key, since: range.scanSinceKey, until: range.scanUntilKey) + } + return filtered.isEmpty ? nil : filtered + } + // MARK: - File scan orchestration struct CodexFileMetadata { @@ -445,6 +686,12 @@ extension CostUsageScanner { var mergedDays = migratedCached.days Self.mergeFileDays(existing: &mergedDays, delta: delta.days) + let splitMaps = Self.codexModeSplitMaps( + rows: delta.rows, + range: context.range, + priorityTurns: context.resources.priorityTurns, + modelsDevCatalog: context.resources.modelsDevCatalog, + modelsDevCacheRoot: context.resources.modelsDevCacheRoot) cache.files[input.metadata.path] = Self.makeFileUsage( mtimeUnixMs: input.metadata.mtimeUnixMs, size: input.metadata.size, @@ -466,6 +713,18 @@ extension CostUsageScanner { migratedCached.codexPrioritySurchargeNanos, deltaRows: delta.rows, context: context), + codexStandardCostNanos: Self.mergeCostMaps( + migratedCached.codexStandardCostNanos, + splitMaps.standardCostNanos), + codexPriorityCostNanos: Self.mergeCostMaps( + migratedCached.codexPriorityCostNanos, + splitMaps.priorityCostNanos), + codexStandardTokens: Self.mergeIntMaps( + migratedCached.codexStandardTokens, + splitMaps.standardTokens), + codexPriorityTokens: Self.mergeIntMaps( + migratedCached.codexPriorityTokens, + splitMaps.priorityTokens), codexTurnIDs: Self.mergeCodexTurnIDs(migratedCached.codexTurnIDs, rows: delta.rows), codexRows: migratedCached.codexRows) Self.rememberScannedCodexFile( @@ -501,6 +760,12 @@ extension CostUsageScanner { return } Self.mergeFileDays(existing: &usageDays, delta: parsed.days) + let splitMaps = Self.codexModeSplitMaps( + rows: parsed.rows, + range: context.range, + priorityTurns: context.resources.priorityTurns, + modelsDevCatalog: context.resources.modelsDevCatalog, + modelsDevCacheRoot: context.resources.modelsDevCacheRoot) cache.files[input.metadata.path] = Self.makeFileUsage( mtimeUnixMs: input.metadata.mtimeUnixMs, @@ -534,6 +799,26 @@ extension CostUsageScanner { priorityTurns: context.resources.priorityTurns, modelsDevCatalog: context.resources.modelsDevCatalog, modelsDevCacheRoot: context.resources.modelsDevCacheRoot)), + codexStandardCostNanos: Self.mergeCostMaps( + context.dropDeferredCodexRows + ? nil + : Self.costMapOutsideScanWindow(migratedCached?.codexStandardCostNanos, range: context.range), + splitMaps.standardCostNanos), + codexPriorityCostNanos: Self.mergeCostMaps( + context.dropDeferredCodexRows + ? nil + : Self.costMapOutsideScanWindow(migratedCached?.codexPriorityCostNanos, range: context.range), + splitMaps.priorityCostNanos), + codexStandardTokens: Self.mergeIntMaps( + context.dropDeferredCodexRows + ? nil + : Self.intMapOutsideScanWindow(migratedCached?.codexStandardTokens, range: context.range), + splitMaps.standardTokens), + codexPriorityTokens: Self.mergeIntMaps( + context.dropDeferredCodexRows + ? nil + : Self.intMapOutsideScanWindow(migratedCached?.codexPriorityTokens, range: context.range), + splitMaps.priorityTokens), codexTurnIDs: context.dropDeferredCodexRows ? Self.codexTurnIDs(rows: parsed.rows) : Self.mergeCodexTurnIDs(migratedCached?.codexTurnIDs, rows: parsed.rows), @@ -660,10 +945,15 @@ extension CostUsageScanner { } let costNanosByDayModel = self.codexCostNanosByDayModel(cache: cache, range: range) let prioritySurchargeNanosByDayModel = self.codexPrioritySurchargeNanosByDayModel(cache: cache, range: range) - let needsRowFallback = cache.files.values.contains { - $0.codexCostNanos == nil && !($0.codexRows?.isEmpty ?? true) + let standardCostNanosByDayModel = self.codexStandardCostNanosByDayModel(cache: cache, range: range) + let priorityCostNanosByDayModel = self.codexPriorityCostNanosByDayModel(cache: cache, range: range) + let standardTokensByDayModel = self.codexStandardTokensByDayModel(cache: cache, range: range) + let priorityTokensByDayModel = self.codexPriorityTokensByDayModel(cache: cache, range: range) + + let hasCodexRows = cache.files.values.contains { + !($0.codexRows?.isEmpty ?? true) } - let rowsByDayModel = needsRowFallback ? self.codexRowsByDayModel(cache: cache, range: range) : [:] + let rowsByDayModel = hasCodexRows ? self.codexRowsByDayModel(cache: cache, range: range) : [:] for day in dayKeys { guard let models = cache.days[day] else { continue } @@ -686,21 +976,40 @@ extension CostUsageScanner { dayOutput += output let rows = rowsByDayModel[day]?[model] - var cost = costNanosByDayModel[day]?[model].map { Double($0) / Self.costScale } ?? rows.flatMap { - self.codexRowsCostUSD( + let rowCostBreakdown = rows.map { + self.codexRowCostBreakdown( rows: $0, + priorityTurns: priorityTurns, modelsDevCatalog: modelsDevCatalog, modelsDevCacheRoot: modelsDevCacheRoot) - } ?? CostUsagePricing.codexCostUSD( - model: model, - inputTokens: input, - cachedInputTokens: cached, - outputTokens: output, - modelsDevCatalog: modelsDevCatalog, - modelsDevCacheRoot: modelsDevCacheRoot) - if let surchargeNanos = prioritySurchargeNanosByDayModel[day]?[model] { + } + let cachedBaseCost = costNanosByDayModel[day]?[model].map { Double($0) / Self.costScale } + let rowTotalCost = cachedBaseCost == nil ? rowCostBreakdown?.totalCostUSD : nil + let standardCost = standardCostNanosByDayModel[day]?[model].map { Double($0) / Self.costScale } + ?? (rowCostBreakdown?.hasModeSplit == true ? rowCostBreakdown?.optionalStandardCostUSD : nil) + let priorityCost = priorityCostNanosByDayModel[day]?[model].map { Double($0) / Self.costScale } + ?? (rowCostBreakdown?.hasModeSplit == true ? rowCostBreakdown?.optionalPriorityCostUSD : nil) + let splitTotalCost: Double? = if standardCost != nil || priorityCost != nil { + (standardCost ?? 0) + (priorityCost ?? 0) + } else { + nil + } + var cost = cachedBaseCost + ?? rowTotalCost + ?? splitTotalCost + ?? CostUsagePricing.codexCostUSD( + model: model, + inputTokens: input, + cachedInputTokens: cached, + outputTokens: output, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + if let surchargeNanos = prioritySurchargeNanosByDayModel[day]?[model], + cachedBaseCost != nil + { cost = (cost ?? 0) + (Double(surchargeNanos) / Self.costScale) - } else if !priorityTurns.isEmpty, + } else if rowTotalCost == nil, + !priorityTurns.isEmpty, let rows, let surcharge = self.codexPrioritySurchargeUSD( rows: rows, @@ -710,11 +1019,20 @@ extension CostUsageScanner { { cost = (cost ?? 0) + surcharge } + let standardModeTokens = standardTokensByDayModel[day]?[model] + ?? (rowCostBreakdown?.hasModeSplit == true ? rowCostBreakdown?.optionalStandardTokens : nil) + let priorityModeTokens = priorityTokensByDayModel[day]?[model] + ?? (rowCostBreakdown?.hasModeSplit == true ? rowCostBreakdown?.optionalPriorityTokens : nil) + let hasModeSplit = priorityCost != nil || priorityModeTokens != nil breakdown.append( CostUsageDailyReport.ModelBreakdown( modelName: model, costUSD: cost, - totalTokens: totalTokens)) + totalTokens: totalTokens, + standardCostUSD: hasModeSplit ? standardCost : nil, + priorityCostUSD: hasModeSplit ? priorityCost : nil, + standardTokens: hasModeSplit ? standardModeTokens : nil, + priorityTokens: hasModeSplit ? priorityModeTokens : nil)) if let cost { dayCost += cost dayCostSeen = true diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexPriority.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexPriority.swift index 4050ca8e4..9583a0c1c 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexPriority.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexPriority.swift @@ -40,13 +40,16 @@ extension CostUsageScanner { """ select ts, feedback_log_body from logs - where ts >= ? and ts < ? and feedback_log_body like '%websocket request:%' + where ts >= ? and ts < ? + and (feedback_log_body like '%websocket request:%' + or feedback_log_body like '%response.completed%') """ } else { """ select ts, feedback_log_body from logs where feedback_log_body like '%websocket request:%' + or feedback_log_body like '%response.completed%' """ } var stmt: OpaquePointer? @@ -62,12 +65,25 @@ extension CostUsageScanner { } var turns: [String: CodexPriorityTurnMetadata] = [:] + var completedModelsByTurnID: [String: String] = [:] 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.parseCodexPriorityTraceRow(timestamp: timestamp, body: body) + let body = self.text(stmt: stmt, index: 1) else { continue } + if let completed = self.parseCodexCompletedTraceRow(body: body) { + completedModelsByTurnID[completed.turnID] = completed.model + if var existing = turns[completed.turnID] { + existing.model = completed.model + turns[completed.turnID] = existing + } + continue + } + guard var parsed = self.parseCodexPriorityTraceRow(timestamp: timestamp, body: body) + else { continue } + if let completedModel = completedModelsByTurnID[parsed.turnID] { + parsed.model = completedModel + } turns[parsed.turnID] = parsed } return turns @@ -98,6 +114,26 @@ extension CostUsageScanner { timestamp: timestamp) } + static func parseCodexCompletedTraceRow(body: String) -> (turnID: String, model: String)? { + let marker = "websocket event:" + guard let markerRange = body.range(of: marker) else { return nil } + let prefix = String(body[.. String? { guard let range = text.range(of: "\(name)=") else { return nil } let tail = text[range.upperBound...] diff --git a/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift b/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift index 398035393..e7a42293c 100644 --- a/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift +++ b/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift @@ -17,7 +17,14 @@ struct CostUsageDailyReportMergeTests { costUSD: 1.25, modelsUsed: ["gpt-5.4"], modelBreakdowns: [ - CostUsageDailyReport.ModelBreakdown(modelName: "gpt-5.4", costUSD: 1.25, totalTokens: 130), + CostUsageDailyReport.ModelBreakdown( + modelName: "gpt-5.4", + costUSD: 1.25, + totalTokens: 130, + standardCostUSD: 0.75, + priorityCostUSD: 0.50, + standardTokens: 80, + priorityTokens: 50), ]), ], summary: CostUsageDailyReport.Summary( @@ -39,7 +46,14 @@ struct CostUsageDailyReportMergeTests { costUSD: 0.75, modelsUsed: ["gpt-5.4"], modelBreakdowns: [ - CostUsageDailyReport.ModelBreakdown(modelName: "gpt-5.4", costUSD: 0.75, totalTokens: 67), + CostUsageDailyReport.ModelBreakdown( + modelName: "gpt-5.4", + costUSD: 0.75, + totalTokens: 67, + standardCostUSD: 0.25, + priorityCostUSD: 0.50, + standardTokens: 20, + priorityTokens: 47), ]), ], summary: CostUsageDailyReport.Summary( @@ -59,7 +73,14 @@ struct CostUsageDailyReportMergeTests { #expect(merged.data.first?.totalTokens == 197) #expect(abs((merged.data.first?.costUSD ?? 0) - 2.0) < 0.000001) #expect(merged.data.first?.modelBreakdowns == [ - CostUsageDailyReport.ModelBreakdown(modelName: "gpt-5.4", costUSD: 2.0, totalTokens: 197), + CostUsageDailyReport.ModelBreakdown( + modelName: "gpt-5.4", + costUSD: 2.0, + totalTokens: 197, + standardCostUSD: 1.0, + priorityCostUSD: 1.0, + standardTokens: 100, + priorityTokens: 97), ]) #expect(merged.summary?.totalTokens == 197) #expect(abs((merged.summary?.totalCostUSD ?? 0) - 2.0) < 0.000001) diff --git a/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift b/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift index 0d6d9e111..dd4acb958 100644 --- a/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift @@ -464,6 +464,83 @@ struct CostUsageScannerBreakdownTests { #expect(migratedCache.files[path]?.parsedBytes == parsedBytes) } + @Test + func `codex split cache migration does not double count existing cost maps`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 18) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let iso0 = env.isoString(for: day) + let iso1 = env.isoString(for: day.addingTimeInterval(1)) + let model = "gpt-5.4" + let normalizedModel = CostUsagePricing.normalizeCodexModel(model) + _ = try env.writeCodexSessionFile( + day: day, + filename: "session.jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "timestamp": iso0, + "payload": ["session_id": "split-cache-session"], + ], + self.codexTurnContext(timestamp: iso0, model: model), + self.codexTokenCount( + timestamp: iso1, + model: model, + total: (input: 10, cached: 0, output: 0)), + ])) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite")) + options.refreshMinIntervalSeconds = 0 + + _ = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + + var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let path = try #require(cache.files.keys.first) + var cachedUsage = try #require(cache.files[path]) + let originalCostNanos = try #require(cachedUsage.codexCostNanos?[dayKey]?[normalizedModel]) + cachedUsage.codexRows = [ + CostUsageScanner.CodexUsageRow( + day: dayKey, + model: normalizedModel, + turnID: nil, + input: 10, + cached: 0, + output: 0), + ] + cachedUsage.codexStandardCostNanos = nil + cachedUsage.codexPriorityCostNanos = nil + cachedUsage.codexStandardTokens = nil + cachedUsage.codexPriorityTokens = nil + cache.files[path] = cachedUsage + CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + + options.refreshMinIntervalSeconds = 60 + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(1), + options: options) + + let expectedCost = 10.0 * 2.5e-6 + #expect(abs((report.summary?.totalCostUSD ?? 0) - expectedCost) < 0.000_000_001) + let migratedUsage = try #require(CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot).files[path]) + #expect(migratedUsage.codexRows == nil) + #expect(migratedUsage.codexCostNanos?[dayKey]?[normalizedModel] == originalCostNanos) + #expect(migratedUsage.codexStandardTokens?[dayKey]?[normalizedModel] == 10) + } + @Test func `codex narrow full rescan preserves cached days outside scan window`() throws { let env = try CostUsageTestEnvironment() diff --git a/Tests/CodexBarTests/CostUsageScannerCodexPriorityTests.swift b/Tests/CodexBarTests/CostUsageScannerCodexPriorityTests.swift index ba4fd6a33..de842296f 100644 --- a/Tests/CodexBarTests/CostUsageScannerCodexPriorityTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerCodexPriorityTests.swift @@ -37,6 +37,17 @@ struct CostUsageScannerCodexPriorityTests { body: prefix + #"{"#) == nil) } + @Test + func `parses completed response model without exposing response body`() { + let body = "INFO thread_id=thread turn.id=turn websocket event: " + + #"{"type":"response.completed","response":{"model":"gpt-5.4","output":[{"content":"private"}]}}"# + + let parsed = CostUsageScanner.parseCodexCompletedTraceRow(body: body) + + #expect(parsed?.turnID == "turn") + #expect(parsed?.model == "gpt-5.4") + } + @Test func `reads priority turns from sqlite logs table`() throws { let env = try CostUsageTestEnvironment() @@ -62,6 +73,50 @@ struct CostUsageScannerCodexPriorityTests { #expect(turns["turn-a"]?.model == "gpt-5.5") } + @Test + func `sqlite scan upgrades priority request alias with completed response model`() 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 turn.id=turn websocket request: " + + #"{"type":"response.create","model":"codex-auto-review","service_tier":"priority"}"#) + try Self.insertTestLog( + dbURL: dbURL, + timestamp: "2026-05-10T12:00:01Z", + body: "thread_id=thread turn.id=turn websocket event: " + + #"{"type":"response.completed","response":{"model":"gpt-5.4","input":"private"}}"#) + + let turns = CostUsageScanner.codexPriorityTurns(databaseURL: dbURL) + + #expect(turns["turn"]?.model == "gpt-5.4") + } + + @Test + func `sqlite scan matches spaced completed response json`() 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 turn.id=turn websocket request: " + + #"{"type":"response.create","model":"codex-auto-review","service_tier":"priority"}"#) + try Self.insertTestLog( + dbURL: dbURL, + timestamp: "2026-05-10T12:00:01Z", + body: "thread_id=thread turn.id=turn websocket event: " + + #"{"type": "response.completed", "response": {"model": "gpt-5.4"}}"#) + + let turns = CostUsageScanner.codexPriorityTurns(databaseURL: dbURL) + + #expect(turns["turn"]?.model == "gpt-5.4") + } + @Test func `sqlite scan only returns priority turns in requested day range`() throws { let env = try CostUsageTestEnvironment() diff --git a/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift b/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift index 7175e6d12..2093ccb5f 100644 --- a/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift @@ -44,6 +44,12 @@ struct CostUsageScannerPriorityTests { let priorityCost = (80.0 * 1.25e-5) + (20.0 * 1.25e-6) + (10.0 * 7.5e-5) #expect(report.summary?.totalCostUSD == standardCost + priorityCost) + let breakdown = try #require(report.data.first?.modelBreakdowns?.first) + #expect(breakdown.costUSD == standardCost + priorityCost) + #expect(breakdown.standardCostUSD == standardCost) + #expect(breakdown.priorityCostUSD == priorityCost) + #expect(breakdown.standardTokens == 110) + #expect(breakdown.priorityTokens == 110) } @Test @@ -93,6 +99,9 @@ struct CostUsageScannerPriorityTests { let priorityCost = (80.0 * 1.25e-5) + (20.0 * 1.25e-6) + (10.0 * 7.5e-5) #expect(cached.summary?.totalCostUSD == priorityCost) + let breakdown = try #require(cached.data.first?.modelBreakdowns?.first) + #expect(breakdown.priorityCostUSD == priorityCost) + #expect(breakdown.priorityTokens == 110) } @Test @@ -278,6 +287,50 @@ struct CostUsageScannerPriorityTests { #expect(report.summary?.totalCostUSD == standardCost + priorityCost) } + @Test + func `codex daily report prices priority alias with completed response model`() 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": "codex-auto-review"]], + ["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)) + + let dbURL = env.root.appendingPathComponent("logs_2.sqlite") + try CostUsageScannerCodexPriorityTests.createTestLogsDatabase(at: dbURL) + try self.insertPriorityTrace(dbURL: dbURL, timestamp: iso1, model: "codex-auto-review") + try CostUsageScannerCodexPriorityTests.insertTestLog( + dbURL: dbURL, + timestamp: iso1, + body: "thread_id=thread turn.id=priority-turn websocket event: " + + #"{"type":"response.completed","response":{"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 priorityCost = (80.0 * 5e-6) + (20.0 * 5e-7) + (10.0 * 3e-5) + + #expect(report.summary?.totalCostUSD == priorityCost) + let breakdown = try #require(report.data.first?.modelBreakdowns?.first) + #expect(breakdown.priorityCostUSD == priorityCost) + #expect(breakdown.priorityTokens == 110) + } + @Test func `codex daily report keeps base cost when sqlite metadata is missing`() throws { let env = try CostUsageTestEnvironment() @@ -308,6 +361,54 @@ struct CostUsageScannerPriorityTests { let expected = (80.0 * 5e-6) + (20.0 * 5e-7) + (10.0 * 3e-5) #expect(report.summary?.totalCostUSD == expected) + let breakdown = try #require(report.data.first?.modelBreakdowns?.first) + #expect(breakdown.costUSD == expected) + #expect(breakdown.standardCostUSD == nil) + #expect(breakdown.priorityCostUSD == nil) + #expect(breakdown.standardTokens == nil) + #expect(breakdown.priorityTokens == nil) + } + + @Test + func `codex daily report attributes base priced priority rows to fast bucket`() 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.4-nano"]], + ["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)) + + let dbURL = env.root.appendingPathComponent("logs_2.sqlite") + try CostUsageScannerCodexPriorityTests.createTestLogsDatabase(at: dbURL) + try self.insertPriorityTrace(dbURL: dbURL, timestamp: iso1, model: "gpt-5.4-nano") + + 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 expected = (80.0 * 2e-7) + (20.0 * 2e-8) + (10.0 * 1.25e-6) + + let breakdown = try #require(report.data.first?.modelBreakdowns?.first) + #expect(abs((report.summary?.totalCostUSD ?? 0) - expected) < 0.000_000_001) + #expect(abs((breakdown.costUSD ?? 0) - expected) < 0.000_000_001) + #expect(breakdown.standardCostUSD == nil) + #expect(abs((breakdown.priorityCostUSD ?? 0) - expected) < 0.000_000_001) + #expect(breakdown.standardTokens == nil) + #expect(breakdown.priorityTokens == 110) } @Test diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift index d8e451cd0..06a12b27b 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -228,7 +228,7 @@ struct ProviderInlineDashboardModelTests { } @Test - func `local cost history gets inline dashboard`() throws { + func `local cost history stays out of inline dashboard`() throws { let now = Date(timeIntervalSince1970: 1_700_179_200) let metadata = try #require(ProviderDefaults.metadata[.claude]) let daily = [ @@ -290,9 +290,9 @@ struct ProviderInlineDashboardModelTests { hidePersonalInfo: false, now: now)) - #expect(model.inlineUsageDashboard?.kpis.first?.value == "$0.25") - #expect(model.inlineUsageDashboard?.points.count == 2) - #expect(model.inlineUsageDashboard?.detailLines.contains { $0.contains("claude-opus-4") } == true) + #expect(model.inlineUsageDashboard == nil) + #expect(model.tokenUsage?.sessionLine.contains("$0.25") == true) + #expect(model.tokenUsage?.monthLine.contains("$0.37") == true) } @Test From f3a1d4ed540ef00b4359703d6d5c0591a27ca39b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 22 May 2026 13:50:09 +0100 Subject: [PATCH 2/6] docs: update changelog for cost split history --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bcaef41ca..d43b62c13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.28.1 — Unreleased ### Added +- Cost history: show Codex standard and fast spend/token splits in model breakdowns (#1070). Thanks @iam-brain! - Alibaba Token Plan: add Bailian token-plan quota tracking via browser or manual cookies (#1098). Thanks @YanxinXue! - OpenCode: show workspace renewal dates for OpenCode and OpenCode Go usage windows (#1099). Thanks @Yuxin-Qiao! From eba5f22a75285bdcb7f0ef3cd25b5f2f44952572 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 22 May 2026 14:04:01 +0100 Subject: [PATCH 3/6] fix: preserve priority pricing for unpriced aliases --- .../Generated/CodexParserHash.generated.swift | 2 +- .../CostUsageScanner+CacheHelpers.swift | 22 +++++++++-- .../CostUsageScannerPriorityTests.swift | 39 +++++++++++++++++++ 3 files changed, 59 insertions(+), 4 deletions(-) diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 267ab4476..182ba0bc6 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "9b0bcddb77e3a107" + static let value = "d62730b7c801e362" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift index f8c933b2b..9b0d3b442 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift @@ -139,7 +139,7 @@ extension CostUsageScanner { var seen = false for row in rows { guard let turnID = row.turnID, let priorityMetadata = priorityTurns[turnID] else { continue } - let pricedModel = priorityMetadata.model ?? row.model + let pricedModel = Self.codexPriorityPricingModel(for: row, priorityMetadata: priorityMetadata) guard let baseCost = CostUsagePricing.codexCostUSD( model: pricedModel, inputTokens: row.input, @@ -159,6 +159,20 @@ extension CostUsageScanner { return seen ? total : nil } + private static func codexPriorityPricingModel( + for row: CodexUsageRow, + priorityMetadata: CodexPriorityTurnMetadata) -> String + { + guard let model = priorityMetadata.model, + CostUsagePricing.codexPriorityCostUSD( + model: model, + inputTokens: row.input, + cachedInputTokens: row.cached, + outputTokens: row.output) != nil + else { return row.model } + return model + } + struct CodexRowCostBreakdown { var standardCostUSD: Double = 0 var priorityCostUSD: Double = 0 @@ -209,7 +223,8 @@ extension CostUsageScanner { } else { breakdown.standardTokens += tokenCount } - let pricedModel = priorityMetadata?.model ?? row.model + let pricedModel = priorityMetadata.map { Self.codexPriorityPricingModel(for: row, priorityMetadata: $0) } + ?? row.model let baseCost = CostUsagePricing.codexCostUSD( model: pricedModel, @@ -456,7 +471,8 @@ extension CostUsageScanner { let tokenCount = row.input + row.output let priorityMetadata = row.turnID.flatMap { priorityTurns[$0] } - let pricedModel = priorityMetadata?.model ?? row.model + let pricedModel = priorityMetadata.map { Self.codexPriorityPricingModel(for: row, priorityMetadata: $0) } + ?? row.model let isPriority = priorityMetadata != nil if isPriority { diff --git a/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift b/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift index 2093ccb5f..46121b104 100644 --- a/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift @@ -331,6 +331,45 @@ struct CostUsageScannerPriorityTests { #expect(breakdown.priorityTokens == 110) } + @Test + func `codex daily report falls back to session model for unpriced priority alias`() 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.4"]], + ["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)) + + let dbURL = env.root.appendingPathComponent("logs_2.sqlite") + try CostUsageScannerCodexPriorityTests.createTestLogsDatabase(at: dbURL) + try self.insertPriorityTrace(dbURL: dbURL, timestamp: iso1, model: "codex-auto-review") + + 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 priorityCost = (80.0 * 5e-6) + (20.0 * 5e-7) + (10.0 * 3e-5) + + #expect(report.summary?.totalCostUSD == priorityCost) + let breakdown = try #require(report.data.first?.modelBreakdowns?.first) + #expect(breakdown.priorityCostUSD == priorityCost) + #expect(breakdown.priorityTokens == 110) + } + @Test func `codex daily report keeps base cost when sqlite metadata is missing`() throws { let env = try CostUsageTestEnvironment() From ba2fa11f1dc5a07a80d8ff80997c22f1f779c1be Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 22 May 2026 14:08:49 +0100 Subject: [PATCH 4/6] fix: rescan priority aliases when completed models arrive --- .../Generated/CodexParserHash.generated.swift | 2 +- .../Vendored/CostUsage/CostUsageScanner.swift | 6 ++- .../CostUsageScannerPriorityTests.swift | 54 +++++++++++++++++++ 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 182ba0bc6..57a706d34 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "d62730b7c801e362" + static let value = "e53ac338c8dacaf0" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index b0138440a..a0ccf17e2 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -601,13 +601,15 @@ enum CostUsageScanner { private static func changedPriorityTurnIDs( old: [String: [String]]?, new: [String: [String]], + oldKeys: [String: String]?, + newKeys: [String: String], range: CostUsageDayRange) -> Set { var out = Set() for dayKey in self.dayKeys(sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) { let oldIDs = Set(old?[dayKey] ?? []) let newIDs = Set(new[dayKey] ?? []) - if oldIDs != newIDs { + if oldIDs != newIDs || oldKeys?[dayKey] != newKeys[dayKey] { out.formUnion(oldIDs) out.formUnion(newIDs) } @@ -1460,6 +1462,8 @@ enum CostUsageScanner { ? Self.changedPriorityTurnIDs( old: cache.codexPriorityTurnIDsByDay, new: priorityTurnIDsByDay, + oldKeys: cache.codexPriorityTurnKeys, + newKeys: priorityTurnKeys, range: range) : [] let shouldRefresh = options.forceRescan diff --git a/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift b/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift index 46121b104..75318d0ee 100644 --- a/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift @@ -331,6 +331,60 @@ struct CostUsageScannerPriorityTests { #expect(breakdown.priorityTokens == 110) } + @Test + func `codex daily report reprices cached priority alias when completed model arrives`() 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": "codex-auto-review"]], + ["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)) + + let dbURL = env.root.appendingPathComponent("logs_2.sqlite") + try CostUsageScannerCodexPriorityTests.createTestLogsDatabase(at: dbURL) + try self.insertPriorityTrace(dbURL: dbURL, timestamp: iso1, model: "codex-auto-review") + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: dbURL) + options.refreshMinIntervalSeconds = 0 + + let first = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + #expect(first.summary?.totalCostUSD == nil) + + try CostUsageScannerCodexPriorityTests.insertTestLog( + dbURL: dbURL, + timestamp: iso1, + body: "thread_id=thread turn.id=priority-turn websocket event: " + + #"{"type":"response.completed","response":{"model":"gpt-5.4"}}"#) + + options.refreshMinIntervalSeconds = 60 + let repriced = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(61), + options: options) + let priorityCost = (80.0 * 5e-6) + (20.0 * 5e-7) + (10.0 * 3e-5) + + #expect(repriced.summary?.totalCostUSD == priorityCost) + let breakdown = try #require(repriced.data.first?.modelBreakdowns?.first) + #expect(breakdown.priorityCostUSD == priorityCost) + #expect(breakdown.priorityTokens == 110) + } + @Test func `codex daily report falls back to session model for unpriced priority alias`() throws { let env = try CostUsageTestEnvironment() From c3d68f6a7281406282e852c0b103c21488be8619 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 22 May 2026 14:16:26 +0100 Subject: [PATCH 5/6] fix: merge missing legacy Codex cost cache rows --- .../Generated/CodexParserHash.generated.swift | 2 +- .../CostUsageScanner+CacheHelpers.swift | 60 ++++++++++++++----- .../CostUsageScannerBreakdownTests.swift | 10 ++++ 3 files changed, 56 insertions(+), 16 deletions(-) diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 57a706d34..e49f570de 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "e53ac338c8dacaf0" + static let value = "efead905bba540ae" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift index 9b0d3b442..defbd61ce 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift @@ -347,27 +347,31 @@ extension CostUsageScanner { modelsDevCatalog: context.resources.modelsDevCatalog, modelsDevCacheRoot: context.resources.modelsDevCacheRoot) var updated = usage - updated.codexCostNanos = usage.codexCostNanos ?? Self.codexCostNanos( - rows: migratedRows, - range: context.range, - modelsDevCatalog: context.resources.modelsDevCatalog, - modelsDevCacheRoot: context.resources.modelsDevCacheRoot) - updated.codexPrioritySurchargeNanos = usage.codexPrioritySurchargeNanos ?? Self.codexPrioritySurchargeNanos( - rows: migratedRows, - range: context.range, - priorityTurns: context.resources.priorityTurns, - modelsDevCatalog: context.resources.modelsDevCatalog, - modelsDevCacheRoot: context.resources.modelsDevCacheRoot) - updated.codexStandardCostNanos = Self.mergeCostMaps( + updated.codexCostNanos = Self.mergeMissingCostMaps( + usage.codexCostNanos, + Self.codexCostNanos( + rows: migratedRows, + range: context.range, + modelsDevCatalog: context.resources.modelsDevCatalog, + modelsDevCacheRoot: context.resources.modelsDevCacheRoot)) + updated.codexPrioritySurchargeNanos = Self.mergeMissingCostMaps( + usage.codexPrioritySurchargeNanos, + Self.codexPrioritySurchargeNanos( + rows: migratedRows, + range: context.range, + priorityTurns: context.resources.priorityTurns, + modelsDevCatalog: context.resources.modelsDevCatalog, + modelsDevCacheRoot: context.resources.modelsDevCacheRoot)) + updated.codexStandardCostNanos = Self.mergeMissingCostMaps( usage.codexStandardCostNanos, splitMaps.standardCostNanos) - updated.codexPriorityCostNanos = Self.mergeCostMaps( + updated.codexPriorityCostNanos = Self.mergeMissingCostMaps( usage.codexPriorityCostNanos, splitMaps.priorityCostNanos) - updated.codexStandardTokens = Self.mergeIntMaps( + updated.codexStandardTokens = Self.mergeMissingIntMaps( usage.codexStandardTokens, splitMaps.standardTokens) - updated.codexPriorityTokens = Self.mergeIntMaps( + updated.codexPriorityTokens = Self.mergeMissingIntMaps( usage.codexPriorityTokens, splitMaps.priorityTokens) updated.codexTurnIDs = Self.mergeCodexTurnIDs(usage.codexTurnIDs, rows: migratedRows) @@ -537,6 +541,19 @@ extension CostUsageScanner { return out.isEmpty ? nil : out } + static func mergeMissingCostMaps( + _ existing: [String: [String: Int64]]?, + _ delta: [String: [String: Int64]]?) -> [String: [String: Int64]]? + { + var out = existing ?? [:] + for (day, models) in delta ?? [:] { + for (model, value) in models where out[day]?[model] == nil { + out[day, default: [:]][model] = value + } + } + return out.isEmpty ? nil : out + } + static func mergeIntMaps( _ existing: [String: [String: Int]]?, _ delta: [String: [String: Int]]?) -> [String: [String: Int]]? @@ -550,6 +567,19 @@ extension CostUsageScanner { return out.isEmpty ? nil : out } + static func mergeMissingIntMaps( + _ existing: [String: [String: Int]]?, + _ delta: [String: [String: Int]]?) -> [String: [String: Int]]? + { + var out = existing ?? [:] + for (day, models) in delta ?? [:] { + for (model, value) in models where out[day]?[model] == nil { + out[day, default: [:]][model] = value + } + } + return out.isEmpty ? nil : out + } + static func costMapOutsideScanWindow( _ map: [String: [String: Int64]]?, range: CostUsageDayRange) -> [String: [String: Int64]]? diff --git a/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift b/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift index 6f6c3c410..0eafae91b 100644 --- a/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift @@ -509,6 +509,7 @@ struct CostUsageScannerBreakdownTests { let path = try #require(cache.files.keys.first) var cachedUsage = try #require(cache.files[path]) let originalCostNanos = try #require(cachedUsage.codexCostNanos?[dayKey]?[normalizedModel]) + let addedModel = CostUsagePricing.normalizeCodexModel("gpt-5.5") cachedUsage.codexRows = [ CostUsageScanner.CodexUsageRow( day: dayKey, @@ -517,6 +518,13 @@ struct CostUsageScannerBreakdownTests { input: 10, cached: 0, output: 0), + CostUsageScanner.CodexUsageRow( + day: dayKey, + model: addedModel, + turnID: nil, + input: 10, + cached: 0, + output: 0), ] cachedUsage.codexStandardCostNanos = nil cachedUsage.codexPriorityCostNanos = nil @@ -538,7 +546,9 @@ struct CostUsageScannerBreakdownTests { let migratedUsage = try #require(CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot).files[path]) #expect(migratedUsage.codexRows == nil) #expect(migratedUsage.codexCostNanos?[dayKey]?[normalizedModel] == originalCostNanos) + #expect(migratedUsage.codexCostNanos?[dayKey]?[addedModel] == Int64((10.0 * 5e-6 * 1_000_000_000).rounded())) #expect(migratedUsage.codexStandardTokens?[dayKey]?[normalizedModel] == 10) + #expect(migratedUsage.codexStandardTokens?[dayKey]?[addedModel] == 10) } @Test From 582be208a4d56d68614b358b76f5c8f6ecdcfafa Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 22 May 2026 14:22:42 +0100 Subject: [PATCH 6/6] fix: prefer Codex mode split totals for priority rows --- .../Generated/CodexParserHash.generated.swift | 2 +- .../CostUsageScanner+CacheHelpers.swift | 10 +++-- .../CostUsageScannerPriorityTests.swift | 44 +++++++++++++++++++ 3 files changed, 51 insertions(+), 5 deletions(-) diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index e49f570de..3779f5ad8 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "efead905bba540ae" + static let value = "d2d731a6eb7ad436" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift index defbd61ce..2b434973e 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift @@ -1040,9 +1040,9 @@ extension CostUsageScanner { } else { nil } - var cost = cachedBaseCost + var cost = splitTotalCost + ?? cachedBaseCost ?? rowTotalCost - ?? splitTotalCost ?? CostUsagePricing.codexCostUSD( model: model, inputTokens: input, @@ -1050,11 +1050,13 @@ extension CostUsageScanner { outputTokens: output, modelsDevCatalog: modelsDevCatalog, modelsDevCacheRoot: modelsDevCacheRoot) - if let surchargeNanos = prioritySurchargeNanosByDayModel[day]?[model], + if splitTotalCost == nil, + let surchargeNanos = prioritySurchargeNanosByDayModel[day]?[model], cachedBaseCost != nil { cost = (cost ?? 0) + (Double(surchargeNanos) / Self.costScale) - } else if rowTotalCost == nil, + } else if splitTotalCost == nil, + rowTotalCost == nil, !priorityTurns.isEmpty, let rows, let surcharge = self.codexPrioritySurchargeUSD( diff --git a/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift b/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift index 75318d0ee..8bdaaf5e2 100644 --- a/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift @@ -331,6 +331,50 @@ struct CostUsageScannerPriorityTests { #expect(breakdown.priorityTokens == 110) } + @Test + func `codex daily report totals use completed model priority cost`() 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.4"]], + ["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)) + + let dbURL = env.root.appendingPathComponent("logs_2.sqlite") + try CostUsageScannerCodexPriorityTests.createTestLogsDatabase(at: dbURL) + try self.insertPriorityTrace(dbURL: dbURL, timestamp: iso1, model: "gpt-5.4") + try CostUsageScannerCodexPriorityTests.insertTestLog( + dbURL: dbURL, + timestamp: iso1, + body: "thread_id=thread turn.id=priority-turn websocket event: " + + #"{"type":"response.completed","response":{"model":"gpt-5.5"}}"#) + + 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 priorityCost = (80.0 * 1.25e-5) + (20.0 * 1.25e-6) + (10.0 * 7.5e-5) + + #expect(report.summary?.totalCostUSD == priorityCost) + let breakdown = try #require(report.data.first?.modelBreakdowns?.first) + #expect(breakdown.costUSD == priorityCost) + #expect(breakdown.priorityCostUSD == priorityCost) + } + @Test func `codex daily report reprices cached priority alias when completed model arrives`() throws { let env = try CostUsageTestEnvironment()