diff --git a/Sources/CodexBar/CostHistoryChartMenuView.swift b/Sources/CodexBar/CostHistoryChartMenuView.swift index ef5ed808c..572aa56b5 100644 --- a/Sources/CodexBar/CostHistoryChartMenuView.swift +++ b/Sources/CodexBar/CostHistoryChartMenuView.swift @@ -10,11 +10,13 @@ struct CostHistoryChartMenuView: View { let id: String let date: Date let costUSD: Double + let actualCostUSD: Double? let totalTokens: Int? - init(date: Date, costUSD: Double, totalTokens: Int?) { + init(date: Date, costUSD: Double, actualCostUSD: Double?, totalTokens: Int?) { self.date = date self.costUSD = costUSD + self.actualCostUSD = actualCostUSD self.totalTokens = totalTokens self.id = "\(Int(date.timeIntervalSince1970))-\(costUSD)" } @@ -178,37 +180,66 @@ struct CostHistoryChartMenuView: View { } private static func makeModel(provider: UsageProvider, daily: [DailyEntry]) -> Model { + self.makeModel(provider: provider, daily: daily, now: Date()) + } + + private static func makeModel(provider: UsageProvider, daily: [DailyEntry], now: Date) -> Model { let sorted = daily.sorted { lhs, rhs in lhs.date < rhs.date } + if sorted.isEmpty { + let barColor = Self.barColor(for: provider) + return Model( + points: [], + pointsByDateKey: [:], + entriesByDateKey: [:], + dateKeys: [], + axisDates: [], + barColor: barColor, + peakKey: nil, + maxCostUSD: 0, + maxRenderedBreakdownRows: 0) + } + + let dayRange = Self.rollingDayKeys(endingAt: now) var points: [Point] = [] - points.reserveCapacity(sorted.count) + points.reserveCapacity(dayRange.count) var pointsByKey: [String: Point] = [:] - pointsByKey.reserveCapacity(sorted.count) + pointsByKey.reserveCapacity(dayRange.count) var entriesByKey: [String: DailyEntry] = [:] entriesByKey.reserveCapacity(sorted.count) + for entry in sorted { + entriesByKey[entry.date] = entry + } var dateKeys: [(key: String, date: Date)] = [] - dateKeys.reserveCapacity(sorted.count) + dateKeys.reserveCapacity(dayRange.count) var peak: (key: String, costUSD: Double)? var maxCostUSD: Double = 0 var maxRenderedBreakdownRows = 0 - for entry in sorted { - guard let costUSD = entry.costUSD, costUSD >= 0 else { continue } - guard let date = self.dateFromDayKey(entry.date) else { continue } - let point = Point(date: date, costUSD: costUSD, totalTokens: entry.totalTokens) + for item in dayRange { + let entry = entriesByKey[item.key] + let costUSD = Self.displayCostUSD(for: entry) + let point = Point( + date: item.date, + costUSD: costUSD, + actualCostUSD: entry?.costUSD, + totalTokens: entry?.totalTokens) points.append(point) - pointsByKey[entry.date] = point - entriesByKey[entry.date] = entry - dateKeys.append((entry.date, date)) - maxRenderedBreakdownRows = max(maxRenderedBreakdownRows, Self.renderedBreakdownRowCount(for: entry)) - if let cur = peak { - if costUSD > cur.costUSD { peak = (entry.date, costUSD) } - } else { - peak = (entry.date, costUSD) + pointsByKey[item.key] = point + dateKeys.append((item.key, item.date)) + if let entry { + maxRenderedBreakdownRows = max(maxRenderedBreakdownRows, Self.renderedBreakdownRowCount(for: entry)) + } + if costUSD > 0 { + if let cur = peak { + if costUSD > cur.costUSD { peak = (item.key, costUSD) } + } else { + peak = (item.key, costUSD) + } + maxCostUSD = max(maxCostUSD, costUSD) } - maxCostUSD = max(maxCostUSD, costUSD) } let axisDates: [Date] = { @@ -252,11 +283,49 @@ struct CostHistoryChartMenuView: View { return comps.date } + private static func rollingDayKeys(endingAt now: Date) -> [(key: String, date: Date)] { + var days: [(key: String, date: Date)] = [] + let calendar = Calendar.current + let end = calendar.startOfDay(for: now) + let start = calendar.date(byAdding: .day, value: -29, to: end) ?? end + var current = start + + while current <= end { + let comps = calendar.dateComponents([.year, .month, .day], from: current) + let key = String(format: "%04d-%02d-%02d", comps.year ?? 1970, comps.month ?? 1, comps.day ?? 1) + let date = calendar.date(bySettingHour: 12, minute: 0, second: 0, of: current) ?? current + days.append((key, date)) + guard let next = calendar.date(byAdding: .day, value: 1, to: current) else { break } + current = next + } + + return days + } + private static func peakPoint(model: Model) -> Point? { guard let key = model.peakKey else { return nil } return model.pointsByDateKey[key] } + private static func displayCostUSD(for entry: DailyEntry?) -> Double { + guard let entry else { return 0 } + if let costUSD = entry.costUSD, costUSD > 0 { + return costUSD + } + if let breakdown = entry.modelBreakdowns { + let subtotal = breakdown.reduce(0.0) { partial, item in + partial + max(0, item.costUSD ?? 0) + } + if subtotal > 0 { + return subtotal + } + } + if (entry.totalTokens ?? 0) > 0 { + return 0 + } + return 0 + } + private static func renderedBreakdownRowCount(for entry: DailyEntry) -> Int { guard let breakdown = entry.modelBreakdowns, !breakdown.isEmpty else { return 0 } if breakdown.count > self.maxVisibleDetailLines { @@ -356,11 +425,19 @@ struct CostHistoryChartMenuView: View { } let dayLabel = date.formatted(.dateTime.month(.abbreviated).day()) - let cost = UsageFormatter.usdString(point.costUSD) + let primaryBase = if let actualCostUSD = point.actualCostUSD, actualCostUSD > 0 { + "\(dayLabel): \(UsageFormatter.usdString(actualCostUSD))" + } else if point.costUSD > 0 { + "\(dayLabel): \(UsageFormatter.usdString(point.costUSD)) partial" + } else if (point.totalTokens ?? 0) > 0 { + "\(dayLabel): No priced cost data" + } else { + "\(dayLabel): No cost data" + } let primary = if let tokens = point.totalTokens { - "\(dayLabel): \(cost) · \(UsageFormatter.tokenCountString(tokens)) tokens" + "\(primaryBase) · \(UsageFormatter.tokenCountString(tokens)) tokens" } else { - "\(dayLabel): \(cost)" + primaryBase } return DetailContent(primary: primary, rows: self.breakdownRows(key: key, model: model)) } @@ -421,6 +498,8 @@ struct CostHistoryChartMenuView: View { var parts: [String] = [] if let costUSD, costUSD > 0 { parts.append(UsageFormatter.usdString(costUSD)) + } else if totalTokens != nil { + parts.append("unpriced") } if let totalTokens, totalTokens > 0 { parts.append("\(UsageFormatter.tokenCountString(totalTokens)) tokens") @@ -428,3 +507,65 @@ struct CostHistoryChartMenuView: View { return parts.isEmpty ? nil : parts.joined(separator: " · ") } } + +extension CostHistoryChartMenuView { + enum TestSupport { + struct DayState: Equatable { + let dayKey: String + let costUSD: Double + let hasEntry: Bool + } + + struct DetailSummary: Equatable { + let primary: String + } + + @MainActor + static func makeDayStates( + provider: UsageProvider = .codex, + daily: [DailyEntry], + now: Date) -> [DayState] + { + let model = CostHistoryChartMenuView.makeModel(provider: provider, daily: daily, now: now) + return model.dateKeys.compactMap { item -> DayState? in + guard let point = model.pointsByDateKey[item.key] else { return nil } + return DayState( + dayKey: item.key, + costUSD: point.costUSD, + hasEntry: model.entriesByDateKey[item.key] != nil) + } + } + + @MainActor + static func detailSummary( + selectedDateKey: String, + provider: UsageProvider = .codex, + daily: [DailyEntry]) -> DetailSummary + { + let now = CostHistoryChartMenuView.dateFromDayKey(selectedDateKey) ?? Date() + let model = CostHistoryChartMenuView.makeModel(provider: provider, daily: daily, now: now) + guard let point = model.pointsByDateKey[selectedDateKey], + let date = CostHistoryChartMenuView.dateFromDayKey(selectedDateKey) + else { + return DetailSummary(primary: "Hover a bar for details") + } + + let dayLabel = date.formatted(.dateTime.month(.abbreviated).day()) + let primaryBase = if let actualCostUSD = point.actualCostUSD, actualCostUSD > 0 { + "\(dayLabel): \(UsageFormatter.usdString(actualCostUSD))" + } else if point.costUSD > 0 { + "\(dayLabel): \(UsageFormatter.usdString(point.costUSD)) partial" + } else if (point.totalTokens ?? 0) > 0 { + "\(dayLabel): No priced cost data" + } else { + "\(dayLabel): No cost data" + } + let primary = if let tokens = point.totalTokens { + "\(primaryBase) · \(UsageFormatter.tokenCountString(tokens)) tokens" + } else { + primaryBase + } + return DetailSummary(primary: primary) + } + } +} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift index 36ad430e8..ea379e7e7 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift @@ -27,11 +27,26 @@ enum CostUsagePricing { outputCostPerToken: 1e-5, cacheReadInputCostPerToken: 1.25e-7, displayLabel: nil), + "gpt-5-chat": CodexPricing( + inputCostPerToken: 1.25e-6, + outputCostPerToken: 1e-5, + cacheReadInputCostPerToken: 1.25e-7, + displayLabel: nil), + "gpt-5-chat-latest": CodexPricing( + inputCostPerToken: 1.25e-6, + outputCostPerToken: 1e-5, + cacheReadInputCostPerToken: 1.25e-7, + displayLabel: nil), "gpt-5-codex": CodexPricing( inputCostPerToken: 1.25e-6, outputCostPerToken: 1e-5, cacheReadInputCostPerToken: 1.25e-7, displayLabel: nil), + "gpt-5-codex-mini": CodexPricing( + inputCostPerToken: 2.5e-7, + outputCostPerToken: 2e-6, + cacheReadInputCostPerToken: 2.5e-8, + displayLabel: nil), "gpt-5-mini": CodexPricing( inputCostPerToken: 2.5e-7, outputCostPerToken: 2e-6, @@ -52,6 +67,11 @@ enum CostUsagePricing { outputCostPerToken: 1e-5, cacheReadInputCostPerToken: 1.25e-7, displayLabel: nil), + "gpt-5.1-chat-latest": CodexPricing( + inputCostPerToken: 1.25e-6, + outputCostPerToken: 1e-5, + cacheReadInputCostPerToken: 1.25e-7, + displayLabel: nil), "gpt-5.1-codex": CodexPricing( inputCostPerToken: 1.25e-6, outputCostPerToken: 1e-5, @@ -72,6 +92,16 @@ enum CostUsagePricing { outputCostPerToken: 1.4e-5, cacheReadInputCostPerToken: 1.75e-7, displayLabel: nil), + "gpt-5.2-chat": CodexPricing( + inputCostPerToken: 1.75e-6, + outputCostPerToken: 1.4e-5, + cacheReadInputCostPerToken: 1.75e-7, + displayLabel: nil), + "gpt-5.2-chat-latest": CodexPricing( + inputCostPerToken: 1.75e-6, + outputCostPerToken: 1.4e-5, + cacheReadInputCostPerToken: 1.75e-7, + displayLabel: nil), "gpt-5.2-codex": CodexPricing( inputCostPerToken: 1.75e-6, outputCostPerToken: 1.4e-5, @@ -87,6 +117,11 @@ enum CostUsagePricing { outputCostPerToken: 1.4e-5, cacheReadInputCostPerToken: 1.75e-7, displayLabel: nil), + "gpt-5.3-chat-latest": CodexPricing( + inputCostPerToken: 1.75e-6, + outputCostPerToken: 1.4e-5, + cacheReadInputCostPerToken: 1.75e-7, + displayLabel: nil), "gpt-5.3-codex-spark": CodexPricing( inputCostPerToken: 0, outputCostPerToken: 0, @@ -275,7 +310,10 @@ enum CostUsagePricing { guard let pricing = self.codex[key] else { return nil } let cached = min(max(0, cachedInputTokens), max(0, inputTokens)) let nonCached = max(0, inputTokens - cached) - let cachedRate = pricing.cacheReadInputCostPerToken ?? pricing.inputCostPerToken + if cached > 0, pricing.cacheReadInputCostPerToken == nil { + return nil + } + let cachedRate = pricing.cacheReadInputCostPerToken ?? 0 return Double(nonCached) * pricing.inputCostPerToken + Double(cached) * cachedRate + Double(max(0, outputTokens)) * pricing.outputCostPerToken diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Codex.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Codex.swift index 54ce34761..d12f3f3e7 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Codex.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Codex.swift @@ -23,6 +23,13 @@ private struct CostUsageScannerCodexParseState { } extension CostUsageScanner { + private static let codexSessionMetaModelRegex = try? NSRegularExpression( + pattern: #"GPT-([0-9]+(?:\.[0-9]+)?)(?:\s+(Codex|Mini|Nano|Pro))?(?:\s+(Max|Mini))?"#, + options: [.caseInsensitive]) + private static let codexImplicitContextRegex = try? NSRegularExpression( + pattern: #"Codex(?:\s+CLI)?"#, + options: [.caseInsensitive]) + private static func regexCapture(_ pattern: String, in text: String) -> String? { guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } let range = NSRange(text.startIndex.. String? { + guard let payload else { return nil } + if let model = payload["model"] as? String { + return model + } + if let baseInstructions = (payload["base_instructions"] as? [String: Any])?["text"] as? String { + return Self.codexModelFromInstructions(baseInstructions) + } + return nil + } + + private static func codexModelFromInstructions(_ text: String) -> String? { + guard let regex = self.codexSessionMetaModelRegex else { return nil } + let range = NSRange(text.startIndex.. Bool { if line.bytes.isEmpty { return false } let hasRelevantType = line.bytes.containsAscii(#""type":"event_msg""#) @@ -167,7 +226,7 @@ extension CostUsageScanner { state.currentModel = explicitModel } - let model = usage.explicitModel ?? state.currentModel ?? "gpt-5" + let model = usage.explicitModel ?? state.currentModel ?? "unknown" var deltaInput = 0 var deltaCached = 0 var deltaOutput = 0 @@ -210,6 +269,9 @@ extension CostUsageScanner { if state.sessionId == nil { state.sessionId = recovered.sessionId } + if state.currentModel == nil, let model = recovered.model { + state.currentModel = model + } return } @@ -247,8 +309,8 @@ extension CostUsageScanner { else { return } if type == "session_meta" { + let payload = obj["payload"] as? [String: Any] if state.sessionId == nil { - let payload = obj["payload"] as? [String: Any] state.sessionId = payload?["session_id"] as? String ?? payload?["sessionId"] as? String ?? payload?["id"] as? String @@ -256,6 +318,9 @@ extension CostUsageScanner { ?? obj["sessionId"] as? String ?? obj["id"] as? String } + if state.currentModel == nil { + state.currentModel = Self.codexModelFromSessionMeta(payload: payload) + } return } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index cebdcb425..3bba8775f 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -401,6 +401,7 @@ enum CostUsageScanner { var totalTokens = 0 var totalCost: Double = 0 var costSeen = false + var hasUnknownCost = false let dayKeys = cache.days.keys.sorted().filter { CostUsageDayRange.isInRange(dayKey: $0, since: range.sinceKey, until: range.untilKey) @@ -416,6 +417,7 @@ enum CostUsageScanner { var breakdown: [CostUsageDailyReport.ModelBreakdown] = [] var dayCost: Double = 0 var dayCostSeen = false + var dayHasUnknownCost = false for model in modelNames { let packed = models[model] ?? [0, 0, 0] @@ -439,6 +441,8 @@ enum CostUsageScanner { if let cost { dayCost += cost dayCostSeen = true + } else { + dayHasUnknownCost = true } } @@ -453,7 +457,7 @@ enum CostUsageScanner { } let dayTotal = dayInput + dayOutput - let entryCost = dayCostSeen ? dayCost : nil + let entryCost = dayCostSeen && !dayHasUnknownCost ? dayCost : nil entries.append(CostUsageDailyReport.Entry( date: day, inputTokens: dayInput, @@ -470,6 +474,9 @@ enum CostUsageScanner { totalCost += entryCost costSeen = true } + if dayHasUnknownCost { + hasUnknownCost = true + } } let summary: CostUsageDailyReport.Summary? = entries.isEmpty @@ -478,7 +485,7 @@ enum CostUsageScanner { totalInputTokens: totalInput, totalOutputTokens: totalOutput, totalTokens: totalTokens, - totalCostUSD: costSeen ? totalCost : nil) + totalCostUSD: costSeen && !hasUnknownCost ? totalCost : nil) return CostUsageDailyReport(data: entries, summary: summary) } diff --git a/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift b/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift new file mode 100644 index 000000000..4680aff63 --- /dev/null +++ b/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift @@ -0,0 +1,89 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@Suite +struct CostHistoryChartMenuViewTests { + @Test + @MainActor + func makeDayStatesBuildsRollingThirtyDayWindowEndingToday() throws { + let now = try #require(Self.date(year: 2026, month: 3, day: 12, hour: 9)) + let daily = [ + CostUsageDailyReport.Entry( + date: "2026-03-12", + inputTokens: 120, + outputTokens: 30, + totalTokens: 150, + costUSD: 0.25, + modelsUsed: ["gpt-5.4"], + modelBreakdowns: nil), + ] + + let days = CostHistoryChartMenuView.TestSupport.makeDayStates(daily: daily, now: now) + + #expect(days.count == 30) + #expect(days.first?.dayKey == "2026-02-11") + #expect(days.last?.dayKey == "2026-03-12") + + let today = try #require(days.last) + #expect(today.hasEntry == true) + #expect(today.costUSD == 0.25) + + let earlierDays = days.dropLast() + #expect(earlierDays.allSatisfy { $0.hasEntry == false }) + #expect(earlierDays.allSatisfy { $0.costUSD == 0 }) + } + + @Test + @MainActor + func detailSummaryTreatsPartialAndUnknownDaysDifferently() { + let daily = [ + CostUsageDailyReport.Entry( + date: "2025-12-01", + inputTokens: 100, + outputTokens: 20, + totalTokens: 120, + costUSD: nil, + modelsUsed: ["gpt-5.2-codex", "unknown"], + modelBreakdowns: [ + .init(modelName: "gpt-5.2-codex", costUSD: 0.08, totalTokens: 80), + .init(modelName: "unknown", costUSD: nil, totalTokens: 40), + ]), + CostUsageDailyReport.Entry( + date: "2025-12-02", + inputTokens: 90, + outputTokens: 10, + totalTokens: 100, + costUSD: nil, + modelsUsed: ["unknown"], + modelBreakdowns: [ + .init(modelName: "unknown", costUSD: nil, totalTokens: 100), + ]), + ] + + let partial = CostHistoryChartMenuView.TestSupport.detailSummary( + selectedDateKey: "2025-12-01", + daily: daily) + let unknownOnly = CostHistoryChartMenuView.TestSupport.detailSummary( + selectedDateKey: "2025-12-02", + daily: daily) + + #expect(partial.primary.contains("$0.08")) + #expect(partial.primary.contains("partial")) + #expect(partial.primary.contains("120 tokens")) + #expect(unknownOnly.primary.contains("No priced cost data")) + #expect(unknownOnly.primary.contains("100 tokens")) + } + + private static func date(year: Int, month: Int, day: Int, hour: Int) -> Date? { + var components = DateComponents() + components.calendar = Calendar.current + components.timeZone = TimeZone.current + components.year = year + components.month = month + components.day = day + components.hour = hour + return components.date + } +} diff --git a/Tests/CodexBarTests/CostUsagePricingTests.swift b/Tests/CodexBarTests/CostUsagePricingTests.swift index 662b72c78..692509623 100644 --- a/Tests/CodexBarTests/CostUsagePricingTests.swift +++ b/Tests/CodexBarTests/CostUsagePricingTests.swift @@ -45,6 +45,54 @@ struct CostUsagePricingTests { #expect(CostUsagePricing.codexDisplayLabel(model: "gpt-5.2-codex") == nil) } + @Test + func normalizesAdditionalCodexAliasesAndSnapshots() { + #expect(CostUsagePricing.normalizeCodexModel("gpt-5-chat") == "gpt-5-chat") + #expect(CostUsagePricing.normalizeCodexModel("gpt-5-codex-mini") == "gpt-5-codex-mini") + #expect(CostUsagePricing.normalizeCodexModel("gpt-5-mini-2025-10-06") == "gpt-5-mini") + #expect(CostUsagePricing.normalizeCodexModel("gpt-5-pro-2025-10-06") == "gpt-5-pro") + #expect(CostUsagePricing.normalizeCodexModel("gpt-5.2-chat") == "gpt-5.2-chat") + #expect(CostUsagePricing.normalizeCodexModel("gpt-5.3-chat-latest") == "gpt-5.3-chat-latest") + } + + @Test + func codexCostSupportsAdditionalGpt5Aliases() { + #expect(CostUsagePricing.codexCostUSD( + model: "gpt-5-chat", + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 5) != nil) + #expect(CostUsagePricing.codexCostUSD( + model: "gpt-5-codex-mini", + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 5) != nil) + #expect(CostUsagePricing.codexCostUSD( + model: "gpt-5.2-chat", + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 5) != nil) + #expect(CostUsagePricing.codexCostUSD( + model: "gpt-5-mini-2025-10-06", + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 5) != nil) + } + + @Test + func codexCostReturnsNilForProModelCachedReads() { + #expect(CostUsagePricing.codexCostUSD( + model: "gpt-5.2-pro", + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 5) == nil) + #expect(CostUsagePricing.codexCostUSD( + model: "gpt-5.4-pro", + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 5) == nil) + } + @Test func normalizesClaudeOpus41DatedVariants() { #expect(CostUsagePricing.normalizeClaudeModel("claude-opus-4-1-20250805") == "claude-opus-4-1") diff --git a/Tests/CodexBarTests/CostUsageScannerMergedStackTests.swift b/Tests/CodexBarTests/CostUsageScannerMergedStackTests.swift new file mode 100644 index 000000000..82e11b2a4 --- /dev/null +++ b/Tests/CodexBarTests/CostUsageScannerMergedStackTests.swift @@ -0,0 +1,258 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite +struct CostUsageScannerMergedStackTests { + @Test + func codexDailyReportRecoversModelFromSessionMetaInstructions() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2025, month: 12, day: 24) + let iso0 = env.isoString(for: day) + let iso1 = env.isoString(for: day.addingTimeInterval(1)) + + let sessionMeta: [String: Any] = [ + "type": "session_meta", + "timestamp": iso0, + "payload": [ + "id": "sess-meta-model", + "base_instructions": [ + "text": "You are GPT-5.2 running in the Codex CLI.", + ], + ], + ] + let tokenCountWithoutModel: [String: Any] = [ + "type": "event_msg", + "timestamp": iso1, + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 100, + "cached_input_tokens": 20, + "output_tokens": 10, + ], + ], + ], + ] + + _ = try env.writeCodexSessionFile( + day: day, + filename: "session-meta-model.jsonl", + contents: env.jsonl([sessionMeta, tokenCountWithoutModel])) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let entry = try #require(report.data.first) + #expect(entry.modelsUsed == ["gpt-5.2-codex"]) + #expect(entry.modelBreakdowns?.contains { + $0.modelName == "gpt-5.2-codex" && $0.costUSD != nil && $0.totalTokens == 110 + } == true) + } + + @Test + func codexDailyReportUsesUnknownModelWhenMetadataIsMissing() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2025, month: 12, day: 24) + let iso0 = env.isoString(for: day) + + let tokenCountWithoutModel: [String: Any] = [ + "type": "event_msg", + "timestamp": iso0, + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 100, + "cached_input_tokens": 20, + "output_tokens": 10, + ], + ], + ], + ] + + _ = try env.writeCodexSessionFile( + day: day, + filename: "session-unknown-model.jsonl", + contents: env.jsonl([tokenCountWithoutModel])) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let entry = try #require(report.data.first) + #expect(entry.modelsUsed == ["unknown"]) + #expect(entry.modelBreakdowns?.contains { + $0.modelName == "unknown" && $0.costUSD == nil && $0.totalTokens == 110 + } == true) + #expect(report.summary?.totalCostUSD == nil) + } + + @Test + func codexDailyReportNilCostWhenKnownAndUnknownModelsMix() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2025, month: 12, day: 22) + let iso0 = env.isoString(for: day) + let iso1 = env.isoString(for: day.addingTimeInterval(1)) + let iso2 = env.isoString(for: day.addingTimeInterval(2)) + + let knownModel = "openai/gpt-5.2-codex" + let turnContext: [String: Any] = [ + "type": "turn_context", + "timestamp": iso0, + "payload": [ + "model": knownModel, + ], + ] + let knownTokenCount: [String: Any] = [ + "type": "event_msg", + "timestamp": iso1, + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 100, + "cached_input_tokens": 20, + "output_tokens": 10, + ], + "model": knownModel, + ], + ], + ] + let unknownTokenCount: [String: Any] = [ + "type": "event_msg", + "timestamp": iso2, + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 80, + "cached_input_tokens": 0, + "output_tokens": 8, + ], + ], + ], + ] + + _ = try env.writeCodexSessionFile( + day: day, + filename: "session-known-model.jsonl", + contents: env.jsonl([turnContext, knownTokenCount])) + _ = try env.writeCodexSessionFile( + day: day, + filename: "session-unknown-model.jsonl", + contents: env.jsonl([unknownTokenCount])) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + + let entry = try #require(report.data.first) + #expect(entry.modelsUsed == ["gpt-5.2-codex", "unknown"]) + #expect(entry.costUSD == nil) + #expect(entry.modelBreakdowns?.contains { + $0.modelName == "gpt-5.2-codex" && $0.costUSD != nil && $0.totalTokens == 110 + } == true) + #expect(entry.modelBreakdowns?.contains { + $0.modelName == "unknown" && $0.costUSD == nil && $0.totalTokens == 88 + } == true) + #expect(report.summary?.totalCostUSD == nil) + } + + @Test + func codexDailyReportKeepsAllModelBreakdownsForBusyDays() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2025, month: 12, day: 23) + let models = [ + "openai/gpt-5.1-codex", + "openai/gpt-5.2-codex", + "openai/gpt-5.3-codex", + "openai/gpt-5.4", + ] + + for (index, model) in models.enumerated() { + let timestamp = env.isoString(for: day.addingTimeInterval(TimeInterval(index))) + let tokenCount: [String: Any] = [ + "type": "event_msg", + "timestamp": timestamp, + "payload": [ + "type": "token_count", + "info": [ + "last_token_usage": [ + "input_tokens": 100 + index, + "cached_input_tokens": 10, + "output_tokens": 10, + ], + "model": model, + ], + ], + ] + + _ = try env.writeCodexSessionFile( + day: day, + filename: "session-\(index).jsonl", + contents: env.jsonl([tokenCount])) + } + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let entry = try #require(report.data.first) + #expect(entry.modelsUsed == [ + "gpt-5.1-codex", + "gpt-5.2-codex", + "gpt-5.3-codex", + "gpt-5.4", + ]) + #expect(entry.modelBreakdowns?.map(\.modelName).sorted() == [ + "gpt-5.1-codex", + "gpt-5.2-codex", + "gpt-5.3-codex", + "gpt-5.4", + ]) + } +}