diff --git a/Sources/CodexBar/SpendDashboardModel+ModelBreakdown.swift b/Sources/CodexBar/SpendDashboardModel+ModelBreakdown.swift index f5736dbb83..c492cdc438 100644 --- a/Sources/CodexBar/SpendDashboardModel+ModelBreakdown.swift +++ b/Sources/CodexBar/SpendDashboardModel+ModelBreakdown.swift @@ -203,6 +203,19 @@ extension SpendDashboardModel { } } + /// Provider-specific by design: Cursor usage events can omit totalCents without voiding priced rows. + static func hasExplicitlyUnpriceableLedgerCost( + _ provider: UsageProvider, + _ entry: CostUsageDailyReport.Entry) -> Bool + { + switch provider { + case .codex, .cursor: + self.hasExplicitlyUnpriceableCodexCost(entry) + default: + false + } + } + private static func hasCompleteModelCostCoverage(_ entry: CostUsageDailyReport.Entry) -> Bool { var totalCost = 0.0 var sawNamedBreakdown = false diff --git a/Sources/CodexBar/SpendDashboardModel.swift b/Sources/CodexBar/SpendDashboardModel.swift index ac6ec1f6d3..076ff1d5b2 100644 --- a/Sources/CodexBar/SpendDashboardModel.swift +++ b/Sources/CodexBar/SpendDashboardModel.swift @@ -539,10 +539,9 @@ struct SpendDashboardModel: Equatable, Sendable { } guard coverage.contains(day) else { continue } guard let cost = validCost(entry.costUSD) else { - // Provider-specific by design: only the Codex ledger carries explicit unpriceable model/day evidence. + // Provider-specific by design: Codex and Cursor can omit prices on some model/day rows. guard input.snapshot.historyCoverageIsEstablished, - input.provider == .codex, - Self.hasExplicitlyUnpriceableCodexCost(entry) + Self.hasExplicitlyUnpriceableLedgerCost(input.provider, entry) else { return false } continue } diff --git a/Sources/CodexBarCore/Providers/Cursor/CursorUsageEventsFetcher.swift b/Sources/CodexBarCore/Providers/Cursor/CursorUsageEventsFetcher.swift index e6d7ee79c0..8124801758 100644 --- a/Sources/CodexBarCore/Providers/Cursor/CursorUsageEventsFetcher.swift +++ b/Sources/CodexBarCore/Providers/Cursor/CursorUsageEventsFetcher.swift @@ -500,7 +500,8 @@ struct CursorUsageEventsFetcher: Sendable { var outputTokens: Int? = 0 var cacheReadTokens: Int? = 0 var cacheCreationTokens: Int? = 0 - var costUSD: Double? = 0 + var costUSD: Double? + var costInvalid = false var requestCount: Int? = 0 mutating func add(_ usage: CursorEventTokenUsage) { @@ -508,7 +509,10 @@ struct CursorUsageEventsFetcher: Sendable { self.outputTokens = Self.checkedSum(self.outputTokens, usage.outputTokens) self.cacheReadTokens = Self.checkedSum(self.cacheReadTokens, usage.cacheReadTokens) self.cacheCreationTokens = Self.checkedSum(self.cacheCreationTokens, usage.cacheWriteTokens) - self.costUSD = Self.checkedCostSum(self.costUSD, usage.totalCents) + self.costUSD = Self.checkedKnownCostSum( + self.costUSD, + usage.totalCents, + alreadyInvalid: &self.costInvalid) self.requestCount = Self.checkedSum(self.requestCount, 1) } @@ -534,10 +538,25 @@ struct CursorUsageEventsFetcher: Sendable { } } - static func checkedCostSum(_ lhsUSD: Double?, _ rhsCents: Double?) -> Double? { - guard let lhsUSD, let rhsCents, rhsCents >= 0 else { return nil } - let sum = lhsUSD + rhsCents / 100.0 - return sum.isFinite ? sum : nil + static func checkedKnownCostSum( + _ lhsUSD: Double?, + _ rhsCents: Double?, + alreadyInvalid: inout Bool) -> Double? + { + if alreadyInvalid { + return nil + } + guard let rhsCents else { return lhsUSD } + guard rhsCents >= 0, rhsCents.isFinite else { + alreadyInvalid = true + return nil + } + let sum = (lhsUSD ?? 0) + rhsCents / 100.0 + guard sum.isFinite else { + alreadyInvalid = true + return nil + } + return sum } } @@ -547,7 +566,7 @@ struct CursorUsageEventsFetcher: Sendable { var cacheReadTokens: Int? = 0 var cacheCreationTokens: Int? = 0 var requestCount: Int? = 0 - var costUSD: Double? = 0 + var costUSD: Double? var breakdowns: [CostUsageDailyReport.ModelBreakdown] = [] for (model, accumulator) in models { @@ -556,7 +575,7 @@ struct CursorUsageEventsFetcher: Sendable { cacheReadTokens = ModelAccumulator.checkedSum([cacheReadTokens, accumulator.cacheReadTokens]) cacheCreationTokens = ModelAccumulator.checkedSum([cacheCreationTokens, accumulator.cacheCreationTokens]) requestCount = ModelAccumulator.checkedSum([requestCount, accumulator.requestCount]) - costUSD = Self.checkedUSDTotal(costUSD, accumulator.costUSD) + costUSD = Self.checkedKnownUSDTotal(costUSD, accumulator.costUSD) breakdowns.append(CostUsageDailyReport.ModelBreakdown( modelName: model, costUSD: accumulator.costUSD, @@ -588,14 +607,14 @@ struct CursorUsageEventsFetcher: Sendable { var totalCacheRead: Int? = 0 var totalCacheCreation: Int? = 0 var totalTokens: Int? = 0 - var totalCost: Double? = 0 + var totalCost: Double? = entries.isEmpty ? 0 : nil for entry in entries { totalInput = ModelAccumulator.checkedSum([totalInput, entry.inputTokens]) totalOutput = ModelAccumulator.checkedSum([totalOutput, entry.outputTokens]) totalCacheRead = ModelAccumulator.checkedSum([totalCacheRead, entry.cacheReadTokens]) totalCacheCreation = ModelAccumulator.checkedSum([totalCacheCreation, entry.cacheCreationTokens]) totalTokens = ModelAccumulator.checkedSum([totalTokens, entry.totalTokens]) - totalCost = Self.checkedUSDTotal(totalCost, entry.costUSD) + totalCost = Self.checkedKnownUSDTotal(totalCost, entry.costUSD) } return CostUsageDailyReport.Summary( totalInputTokens: totalInput, @@ -606,10 +625,18 @@ struct CursorUsageEventsFetcher: Sendable { totalCostUSD: totalCost) } - private static func checkedUSDTotal(_ lhs: Double?, _ rhs: Double?) -> Double? { - guard let lhs, let rhs else { return nil } - let sum = lhs + rhs - return sum.isFinite ? sum : nil + private static func checkedKnownUSDTotal(_ lhs: Double?, _ rhs: Double?) -> Double? { + switch (lhs, rhs) { + case let (left?, right?): + let sum = left + right + return sum.isFinite ? sum : nil + case let (left?, nil): + return left + case let (nil, right?): + return right + case (nil, nil): + return nil + } } private static func sortedBreakdowns( diff --git a/Tests/CodexBarTests/CursorUsageEventsFetcherTests.swift b/Tests/CodexBarTests/CursorUsageEventsFetcherTests.swift index 2573d238eb..5a9a66e88c 100644 --- a/Tests/CodexBarTests/CursorUsageEventsFetcherTests.swift +++ b/Tests/CodexBarTests/CursorUsageEventsFetcherTests.swift @@ -334,6 +334,88 @@ struct CursorUsageEventsFetcherTests { #expect(report.summary?.totalCostUSD == nil) } + @Test + func `reports keep priced cents when a sibling event omits total cents`() { + let events = [ + Self.event( + timestampMS: 1_700_000_000_000, + model: "claude-4.5-sonnet", + input: 5, + totalCents: 100), + Self.event( + timestampMS: 1_700_000_001_000, + model: "gpt-5", + input: 7, + totalCents: nil), + ] + + let report = CursorUsageEventsFetcher.makeDailyReport(from: events, calendar: Self.utcCalendar) + let priced = report.data[0].modelBreakdowns?.first { $0.modelName == "claude-4.5-sonnet" } + let unpriced = report.data[0].modelBreakdowns?.first { $0.modelName == "gpt-5" } + + #expect(report.data.count == 1) + #expect(Self.approxEqual(report.data[0].costUSD, 1.0)) + #expect(Self.approxEqual(priced?.costUSD, 1.0)) + #expect(unpriced?.costUSD == nil) + #expect(unpriced?.totalTokens == 7) + #expect(Self.approxEqual(report.summary?.totalCostUSD, 1.0)) + } + + @Test + func `reports do not revive a model cost after an invalid cents event`() { + let events = [ + Self.event( + timestampMS: 1_700_000_000_000, + model: "gpt-5", + input: 5, + totalCents: 100), + Self.event( + timestampMS: 1_700_000_001_000, + model: "gpt-5", + input: 7, + totalCents: -1), + Self.event( + timestampMS: 1_700_000_002_000, + model: "gpt-5", + input: 3, + totalCents: 50), + ] + + let report = CursorUsageEventsFetcher.makeDailyReport(from: events, calendar: Self.utcCalendar) + + #expect(report.data.count == 1) + #expect(report.data[0].costUSD == nil) + #expect(report.data[0].modelBreakdowns?.first?.costUSD == nil) + #expect(report.data[0].modelBreakdowns?.first?.totalTokens == 15) + #expect(report.summary?.totalCostUSD == nil) + } + + @Test + func `reports keep priced days when another day omits total cents`() { + let events = [ + Self.event( + timestampMS: 1_700_000_000_000, + model: "claude-4.5-sonnet", + input: 5, + totalCents: 100), + Self.event( + timestampMS: 1_700_172_800_000, + model: "gpt-5", + input: 7, + totalCents: nil), + ] + + let report = CursorUsageEventsFetcher.makeDailyReport(from: events, calendar: Self.utcCalendar) + let priced = report.data.first { $0.costUSD != nil } + let unpriced = report.data.first { $0.costUSD == nil } + + #expect(report.data.count == 2) + #expect(Self.approxEqual(priced?.costUSD, 1.0)) + #expect(unpriced?.costUSD == nil) + #expect(unpriced?.totalTokens == 7) + #expect(Self.approxEqual(report.summary?.totalCostUSD, 1.0)) + } + @Test func `reports preserve unknown aggregate tokens on cross event overflow`() { let events = [ diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 5df36f7c51..b41b4091e2 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -2362,7 +2362,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardModel.swift", - line: 810, + line: 809, anchor: "guard provider == .mistral else { return displayCalendar }", expectedProviderIDs: ["mistral"], expectedReferenceCount: 1, diff --git a/Tests/CodexBarTests/SpendDashboardPartialCostTests.swift b/Tests/CodexBarTests/SpendDashboardPartialCostTests.swift index 26d528c538..b86348e8e4 100644 --- a/Tests/CodexBarTests/SpendDashboardPartialCostTests.swift +++ b/Tests/CodexBarTests/SpendDashboardPartialCostTests.swift @@ -53,6 +53,63 @@ struct SpendDashboardPartialCostTests { #expect(group.dailyPoints.map(\.cost) == [3]) } + @Test + func `established Cursor history keeps priced days when another day omits cost`() throws { + let snapshot = Self.snapshot( + entries: [ + Self.entry(day: "2026-07-15", cost: 1, tokens: 5, model: "claude-4.5-sonnet"), + Self.entry(day: "2026-07-16", cost: nil, tokens: 7, model: "gpt-5"), + ], + last30DaysTokens: 12, + last30DaysCostUSD: 1) + let group = try Self.group(inputs: [ + .init(provider: .cursor, displayName: "Cursor", snapshot: snapshot), + ]) + + #expect(snapshot.historyCoverageIsEstablished) + #expect(group.totalCost == 1) + #expect(group.totalTokens == 12) + #expect(group.dailyPoints.map(\.cost) == [1]) + } + + @Test + func `unestablished Cursor history with an unresolved day keeps spend unavailable`() throws { + let snapshot = Self.snapshot( + entries: [ + Self.entry(day: "2026-07-15", cost: 1, tokens: 5, model: "claude-4.5-sonnet"), + Self.entry(day: "2026-07-16", cost: nil, tokens: 7, model: "gpt-5"), + ], + historyCoverageIsEstablished: false, + last30DaysTokens: 12, + last30DaysCostUSD: 1) + let group = try Self.group(inputs: [ + .init(provider: .cursor, displayName: "Cursor", snapshot: snapshot), + ]) + + #expect(!snapshot.historyCoverageIsEstablished) + #expect(group.totalCost == nil) + #expect(group.dailyPoints.isEmpty) + } + + @Test + func `established Cursor history retains priced days when some events omit total cents`() throws { + let snapshot = Self.snapshot( + entries: [ + Self.entry(day: "2026-07-15", cost: 3, tokens: 30, model: "claude-4.5-sonnet"), + Self.entry(day: "2026-07-16", cost: nil, tokens: 40, model: "gpt-5"), + ], + last30DaysTokens: 70, + last30DaysCostUSD: 3) + let group = try Self.group(inputs: [ + .init(provider: .cursor, displayName: "Cursor", snapshot: snapshot), + ]) + + #expect(group.totalCost == 3) + #expect(group.totalTokens == 70) + #expect(group.dailyPoints.map(\.cost) == [3]) + #expect(group.modelHistoryCompleteness == .incomplete) + } + @Test func `incomplete Codex history with an unresolved day keeps spend unavailable`() throws { let snapshot = Self.snapshot(