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! 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 cd69aa7a2..ff3dca4cd 100644 --- a/Sources/CodexBar/InlineUsageDashboardContent.swift +++ b/Sources/CodexBar/InlineUsageDashboardContent.swift @@ -160,45 +160,6 @@ extension UsageMenuCardView.Model { detailLines: details) } - fileprivate static func claudeAdminAPIInlineDashboard(_ usage: ClaudeAdminAPIUsageSnapshot) - -> InlineUsageDashboardModel - { - let today = usage.latestDay - let last7 = usage.last7Days - let last30 = usage.last30Days - let points = usage.daily.suffix(30).map { - InlineUsageDashboardModel.Point( - id: $0.day, - label: Self.shortDayLabel($0.day), - value: $0.costUSD, - accessibilityValue: "\($0.day): \(UsageFormatter.usdString($0.costUSD))") - } - var details = [ - "30d: \(UsageFormatter.tokenCountString(last30.totalTokens)) tokens", - "Cache read: \(UsageFormatter.tokenCountString(last30.cacheReadInputTokens)) tokens", - ] - if let topModel = usage.topModels.first { - details.append("Top model: \(Self.shortModelName(topModel.name))") - } - return InlineUsageDashboardModel( - accessibilityLabel: "Claude Admin API 30 day spend trend", - valueStyle: .currencyUSD, - kpis: [ - .init(title: "Today", value: UsageFormatter.usdString(today.costUSD), emphasis: true), - .init(title: "7d spend", value: UsageFormatter.usdString(last7.costUSD), emphasis: false), - .init( - title: "30d spend", - value: UsageFormatter.usdString(last30.costUSD), - emphasis: false), - .init( - title: "Today tokens", - value: UsageFormatter.tokenCountString(today.totalTokens), - emphasis: false), - ], - points: points, - detailLines: details) - } - private static func costHistoryInlineDashboard( provider: UsageProvider, snapshot: CostUsageTokenSnapshot) -> InlineUsageDashboardModel @@ -250,6 +211,45 @@ extension UsageMenuCardView.Model { detailLines: details) } + fileprivate static func claudeAdminAPIInlineDashboard(_ usage: ClaudeAdminAPIUsageSnapshot) + -> InlineUsageDashboardModel + { + let today = usage.latestDay + let last7 = usage.last7Days + let last30 = usage.last30Days + let points = usage.daily.suffix(30).map { + InlineUsageDashboardModel.Point( + id: $0.day, + label: Self.shortDayLabel($0.day), + value: $0.costUSD, + accessibilityValue: "\($0.day): \(UsageFormatter.usdString($0.costUSD))") + } + var details = [ + "30d: \(UsageFormatter.tokenCountString(last30.totalTokens)) tokens", + "Cache read: \(UsageFormatter.tokenCountString(last30.cacheReadInputTokens)) tokens", + ] + if let topModel = usage.topModels.first { + details.append("Top model: \(Self.shortModelName(topModel.name))") + } + return InlineUsageDashboardModel( + accessibilityLabel: "Claude Admin API 30 day spend trend", + valueStyle: .currencyUSD, + kpis: [ + .init(title: "Today", value: UsageFormatter.usdString(today.costUSD), emphasis: true), + .init(title: "7d spend", value: UsageFormatter.usdString(last7.costUSD), emphasis: false), + .init( + title: "30d spend", + value: UsageFormatter.usdString(last30.costUSD), + emphasis: false), + .init( + title: "Today tokens", + value: UsageFormatter.tokenCountString(today.totalTokens), + emphasis: false), + ], + points: points, + detailLines: details) + } + private static func openRouterInlineDashboard(_ usage: OpenRouterUsageSnapshot) -> InlineUsageDashboardModel? { let periodValues: [(String, String, Double?)] = [ ("day", "Today", usage.keyUsageDaily), @@ -413,22 +413,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 { @@ -478,6 +462,22 @@ extension UsageMenuCardView.Model { guard trimmed.count > 26 else { return trimmed } return String(trimmed.prefix(25)) + "…" } + + 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 + } } struct InlineUsageDashboardContent: View { 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 204353b6a..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 = "e563303df766b16f" + static let value = "d2d731a6eb7ad436" } 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..2b434973e 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 = Self.codexPriorityPricingModel(for: row, priorityMetadata: priorityMetadata) 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,99 @@ 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 + 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.map { Self.codexPriorityPricingModel(for: row, priorityMetadata: $0) } + ?? 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 +269,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 +292,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,15 +340,21 @@ 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( + updated.codexCostNanos = Self.mergeMissingCostMaps( usage.codexCostNanos, Self.codexCostNanos( rows: migratedRows, range: context.range, modelsDevCatalog: context.resources.modelsDevCatalog, modelsDevCacheRoot: context.resources.modelsDevCacheRoot)) - updated.codexPrioritySurchargeNanos = Self.mergeCostMaps( + updated.codexPrioritySurchargeNanos = Self.mergeMissingCostMaps( usage.codexPrioritySurchargeNanos, Self.codexPrioritySurchargeNanos( rows: migratedRows, @@ -200,6 +362,18 @@ extension CostUsageScanner { priorityTurns: context.resources.priorityTurns, modelsDevCatalog: context.resources.modelsDevCatalog, modelsDevCacheRoot: context.resources.modelsDevCacheRoot)) + updated.codexStandardCostNanos = Self.mergeMissingCostMaps( + usage.codexStandardCostNanos, + splitMaps.standardCostNanos) + updated.codexPriorityCostNanos = Self.mergeMissingCostMaps( + usage.codexPriorityCostNanos, + splitMaps.priorityCostNanos) + updated.codexStandardTokens = Self.mergeMissingIntMaps( + usage.codexStandardTokens, + splitMaps.standardTokens) + updated.codexPriorityTokens = Self.mergeMissingIntMaps( + usage.codexPriorityTokens, + splitMaps.priorityTokens) updated.codexTurnIDs = Self.mergeCodexTurnIDs(usage.codexTurnIDs, rows: migratedRows) updated.codexRows = retainedRows.isEmpty ? nil : retainedRows return updated @@ -279,6 +453,70 @@ 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.map { Self.codexPriorityPricingModel(for: row, priorityMetadata: $0) } + ?? 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 +541,45 @@ 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]]? + { + 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 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]]? @@ -313,6 +590,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 +732,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 +759,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 +806,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 +845,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 +991,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 +1022,42 @@ 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 = splitTotalCost + ?? cachedBaseCost + ?? rowTotalCost + ?? CostUsagePricing.codexCostUSD( + model: model, + inputTokens: input, + cachedInputTokens: cached, + outputTokens: output, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + if splitTotalCost == nil, + let surchargeNanos = prioritySurchargeNanosByDayModel[day]?[model], + cachedBaseCost != nil + { cost = (cost ?? 0) + (Double(surchargeNanos) / Self.costScale) - } else if !priorityTurns.isEmpty, + } else if splitTotalCost == nil, + rowTotalCost == nil, + !priorityTurns.isEmpty, let rows, let surcharge = self.codexPrioritySurchargeUSD( rows: rows, @@ -710,11 +1067,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/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/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 2c10247d8..0eafae91b 100644 --- a/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift @@ -464,6 +464,93 @@ 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]) + let addedModel = CostUsagePricing.normalizeCodexModel("gpt-5.5") + cachedUsage.codexRows = [ + CostUsageScanner.CodexUsageRow( + day: dayKey, + model: normalizedModel, + turnID: nil, + 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 + 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.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 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..8bdaaf5e2 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,187 @@ 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 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() + 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() + 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() @@ -308,6 +498,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..e1f19b3c1 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -293,6 +293,8 @@ struct ProviderInlineDashboardModelTests { #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.tokenUsage?.sessionLine.contains("$0.25") == true) + #expect(model.tokenUsage?.monthLine.contains("$0.37") == true) } @Test