diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f8eda96e4..72b4b7eaf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - Overview: keep highlighted provider cards readable on macOS 15 by removing forced vibrancy from card wrappers, while retaining fast GPU selection and native submenu interactions (#3173). - Antigravity CLI: render each quota bucket once in usage and cards, retain unavailable usage and reset context, and keep idle-family filtering out of raw JSON (#3489). Thanks @urda! - Copilot: resolve Enterprise sign-in identities on the configured host, keep equal user IDs on different hosts distinct, and skip public GitHub budget enrichment for Enterprise accounts (#3341). Thanks @Fletcher-Alderton! +- Local costs: avoid overflow traps in OpenCodex and combined cost reports, keeping unrepresentable token sums unavailable while retaining valid neighboring token classes. - Kiro: route existing overage enrichment to the CLI profile’s supported region instead of always using US East; reject invalid profile ARNs before sending credentials and retain CLI fallback (#3359). Thanks @zucram! - Settings: observe rapid external config replacements and edits that restore earlier app-written contents, while keeping successful app writes out of the external-change sync path. - Local usage: honor the app’s Low Power Mode interval for automatic Codex catch-up passes in both usage and Spend Dashboard, while retaining manual acceleration and system thermal pauses. diff --git a/Sources/CodexBarCore/CostUsageModels.swift b/Sources/CodexBarCore/CostUsageModels.swift index 1b67d9c7f7..a88ea1fd9c 100644 --- a/Sources/CodexBarCore/CostUsageModels.swift +++ b/Sources/CodexBarCore/CostUsageModels.swift @@ -784,10 +784,23 @@ extension CostUsageDailyReport { } } - private struct OptionalCountAccumulator { + struct OptionalCountAccumulator { private(set) var value: Int? private var overflowed = false + init(_ value: Int? = nil) { + self.add(value) + } + + mutating func merge(_ other: Self) { + if other.overflowed { + self.value = nil + self.overflowed = true + } else { + self.add(other.value) + } + } + mutating func add(_ incoming: Int?) { guard let incoming, incoming >= 0, !self.overflowed else { return } let (sum, overflow) = (self.value ?? 0).addingReportingOverflow(incoming) @@ -799,18 +812,15 @@ extension CostUsageDailyReport { private struct BreakdownAccumulator { var tokenMix = CostUsageTokenMix() var requestCount = OptionalCountAccumulator() - var totalTokens: Int = 0 - var sawTotalTokens = false + var totalTokens = OptionalCountAccumulator() 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 + var standardTokens = OptionalCountAccumulator() + var priorityTokens = OptionalCountAccumulator() mutating func add(_ breakdown: ModelBreakdown) { self.tokenMix.merge(CostUsageTokenMix( @@ -820,10 +830,7 @@ extension CostUsageDailyReport { cacheCreationTokens: breakdown.cacheCreationTokens, reasoningTokens: breakdown.reasoningTokens)) self.requestCount.add(breakdown.requestCount) - if let totalTokens = breakdown.totalTokens { - self.totalTokens += totalTokens - self.sawTotalTokens = true - } + self.totalTokens.add(breakdown.totalTokens) if let costUSD = breakdown.costUSD { self.costUSD += costUSD self.sawCost = true @@ -836,21 +843,15 @@ extension CostUsageDailyReport { 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 - } + self.standardTokens.add(breakdown.standardTokens) + self.priorityTokens.add(breakdown.priorityTokens) } func build(modelName: String, includeActivity: Bool = true) -> ModelBreakdown { ModelBreakdown( modelName: modelName, costUSD: self.sawCost ? self.costUSD : nil, - totalTokens: self.sawTotalTokens ? self.totalTokens : nil, + totalTokens: self.totalTokens.value, requestCount: includeActivity ? self.requestCount.value : nil, inputTokens: includeActivity ? self.tokenMix.inputTokens : nil, outputTokens: includeActivity ? self.tokenMix.outputTokens : nil, @@ -859,35 +860,26 @@ extension CostUsageDailyReport { reasoningTokens: includeActivity ? self.tokenMix.reasoningTokens : 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) + standardTokens: self.standardTokens.value, + priorityTokens: self.priorityTokens.value) } } private struct EntryAccumulator { - var reasoningTokens = OptionalCountAccumulator() + var tokenMix = CostUsageTokenMix() var requestCount = OptionalCountAccumulator() var coverage = CostUsageCoverageAccumulator() var entryCount = 0 var hasExplicitCoverage = false - var inputTokens: Int = 0 - var sawInputTokens = false - var cacheReadTokens: Int = 0 - var sawCacheReadTokens = false - var cacheCreationTokens: Int = 0 - var sawCacheCreationTokens = false - var outputTokens: Int = 0 - var sawOutputTokens = false - var totalTokens: Int = 0 - var sawTotalTokens = false - var derivedTotalTokensWithoutExplicitTotal: Int = 0 + var totalTokens = OptionalCountAccumulator() + var sawExplicitTotalTokens = false var costUSD: Double = 0 var sawCost = false var modelsUsed: Set = [] var breakdowns: [String: BreakdownAccumulator] = [:] mutating func add(_ entry: Entry) { - self.reasoningTokens.add(entry.reasoningTokens) + self.tokenMix.merge(.from(entry: entry)) self.requestCount.add(entry.requestCount) // Classify each source before combining costs: a priced source cannot price another source's missing rows. self.coverage.add(entry) @@ -895,31 +887,13 @@ extension CostUsageDailyReport { self.hasExplicitCoverage = self.hasExplicitCoverage || entry.pricedRequestCount != nil || entry.unpricedRequestCount != nil || entry.unmeteredRequestCount != nil || entry.estimatedRequestCount != nil - let entryDerivedTotalTokens = (entry.inputTokens ?? 0) - + (entry.cacheReadTokens ?? 0) - + (entry.cacheCreationTokens ?? 0) - + (entry.outputTokens ?? 0) - if let inputTokens = entry.inputTokens { - self.inputTokens += inputTokens - self.sawInputTokens = true - } - if let cacheReadTokens = entry.cacheReadTokens { - self.cacheReadTokens += cacheReadTokens - self.sawCacheReadTokens = true - } - if let cacheCreationTokens = entry.cacheCreationTokens { - self.cacheCreationTokens += cacheCreationTokens - self.sawCacheCreationTokens = true - } - if let outputTokens = entry.outputTokens { - self.outputTokens += outputTokens - self.sawOutputTokens = true - } if let totalTokens = entry.totalTokens { - self.totalTokens += totalTokens - self.sawTotalTokens = true - } else if entryDerivedTotalTokens > 0 { - self.derivedTotalTokensWithoutExplicitTotal += entryDerivedTotalTokens + self.totalTokens.add(totalTokens) + self.sawExplicitTotalTokens = true + } else { + for value in [entry.inputTokens, entry.cacheReadTokens, entry.cacheCreationTokens, entry.outputTokens] { + self.totalTokens.add(value) + } } if let costUSD = entry.costUSD { self.costUSD += costUSD @@ -938,18 +912,11 @@ extension CostUsageDailyReport { } } + var resolvedTotalTokens: OptionalCountAccumulator { + self.sawExplicitTotalTokens || self.totalTokens.value != 0 ? self.totalTokens : .init() + } + func build(date: String) -> Entry { - let derivedTotalTokens = self.inputTokens - + self.cacheReadTokens - + self.cacheCreationTokens - + self.outputTokens - let totalTokens: Int? = if self.sawTotalTokens { - self.totalTokens + self.derivedTotalTokensWithoutExplicitTotal - } else if derivedTotalTokens > 0 { - derivedTotalTokens - } else { - nil - } let modelBreakdowns: [ModelBreakdown]? = { guard !self.breakdowns.isEmpty else { return nil } return CostUsageDailyReport.sortedModelBreakdowns( @@ -962,12 +929,12 @@ extension CostUsageDailyReport { let includeCoverage = self.entryCount > 1 || self.hasExplicitCoverage return Entry( date: date, - inputTokens: self.sawInputTokens ? self.inputTokens : nil, - outputTokens: self.sawOutputTokens ? self.outputTokens : nil, - cacheReadTokens: self.sawCacheReadTokens ? self.cacheReadTokens : nil, - cacheCreationTokens: self.sawCacheCreationTokens ? self.cacheCreationTokens : nil, - reasoningTokens: self.reasoningTokens.value, - totalTokens: totalTokens, + inputTokens: self.tokenMix.inputTokens, + outputTokens: self.tokenMix.outputTokens, + cacheReadTokens: self.tokenMix.cacheReadTokens, + cacheCreationTokens: self.tokenMix.cacheCreationTokens, + reasoningTokens: self.tokenMix.reasoningTokens, + totalTokens: self.resolvedTotalTokens.value, requestCount: self.requestCount.value, costUSD: self.sawCost ? self.costUSD : nil, modelsUsed: modelsUsed, @@ -984,80 +951,36 @@ extension CostUsageDailyReport { } public static func merged(_ reports: [CostUsageDailyReport]) -> CostUsageDailyReport { - let entries = self.mergedEntries(from: reports) - guard !entries.isEmpty else { return CostUsageDailyReport(data: [], summary: nil) } - return CostUsageDailyReport(data: entries, summary: self.mergedSummary(from: entries)) - } - - private static func mergedEntries(from reports: [CostUsageDailyReport]) -> [Entry] { - var dayAccumulators: [String: EntryAccumulator] = [:] + var days: [String: EntryAccumulator] = [:] for report in reports { for entry in report.data { - var accumulator = dayAccumulators[entry.date] ?? EntryAccumulator() - accumulator.add(entry) - dayAccumulators[entry.date] = accumulator + days[entry.date, default: EntryAccumulator()].add(entry) } } - - return dayAccumulators - .keys - .sorted() - .map { date in - dayAccumulators[date, default: EntryAccumulator()].build(date: date) - } - } - - private static func mergedSummary(from entries: [Entry]) -> Summary { - var reasoningTokens = OptionalCountAccumulator() - var totalInputTokens = 0 - var sawTotalInputTokens = false - var totalOutputTokens = 0 - var sawTotalOutputTokens = false - var totalCacheReadTokens = 0 - var sawTotalCacheReadTokens = false - var totalCacheCreationTokens = 0 - var sawTotalCacheCreationTokens = false - var totalTokens = 0 - var sawTotalTokens = false + guard !days.isEmpty else { return CostUsageDailyReport(data: [], summary: nil) } + let dates = days.keys.sorted() + let entries = dates.map { days[$0, default: EntryAccumulator()].build(date: $0) } + var mix = CostUsageTokenMix() + var tokens = OptionalCountAccumulator() var totalCostUSD = 0.0 - var sawTotalCostUSD = false - - for entry in entries { - reasoningTokens.add(entry.reasoningTokens) - if let inputTokens = entry.inputTokens { - totalInputTokens += inputTokens - sawTotalInputTokens = true - } - if let outputTokens = entry.outputTokens { - totalOutputTokens += outputTokens - sawTotalOutputTokens = true - } - if let cacheReadTokens = entry.cacheReadTokens { - totalCacheReadTokens += cacheReadTokens - sawTotalCacheReadTokens = true - } - if let cacheCreationTokens = entry.cacheCreationTokens { - totalCacheCreationTokens += cacheCreationTokens - sawTotalCacheCreationTokens = true - } - if let entryTotalTokens = entry.totalTokens { - totalTokens += entryTotalTokens - sawTotalTokens = true - } - if let costUSD = entry.costUSD { - totalCostUSD += costUSD - sawTotalCostUSD = true + var sawCost = false + for date in dates { + guard let day = days[date] else { continue } + mix.merge(day.tokenMix) + tokens.merge(day.resolvedTotalTokens) + if day.sawCost { + totalCostUSD += day.costUSD + sawCost = true } } - - return Summary( - totalInputTokens: sawTotalInputTokens ? totalInputTokens : nil, - totalOutputTokens: sawTotalOutputTokens ? totalOutputTokens : nil, - cacheReadTokens: sawTotalCacheReadTokens ? totalCacheReadTokens : nil, - cacheCreationTokens: sawTotalCacheCreationTokens ? totalCacheCreationTokens : nil, - reasoningTokens: reasoningTokens.value, - totalTokens: sawTotalTokens ? totalTokens : nil, - totalCostUSD: sawTotalCostUSD ? totalCostUSD : nil) + return CostUsageDailyReport(data: entries, summary: Summary( + totalInputTokens: mix.inputTokens, + totalOutputTokens: mix.outputTokens, + cacheReadTokens: mix.cacheReadTokens, + cacheCreationTokens: mix.cacheCreationTokens, + reasoningTokens: mix.reasoningTokens, + totalTokens: tokens.value, + totalCostUSD: sawCost ? totalCostUSD : nil)) } private static func sortedModelBreakdowns(_ breakdowns: [ModelBreakdown]) -> [ModelBreakdown] { diff --git a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageAggregator.swift b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageAggregator.swift index 1e7d6eb60b..76229fb0fe 100644 --- a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageAggregator.swift +++ b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageAggregator.swift @@ -2,19 +2,9 @@ import Foundation enum OpenCodexUsageAggregator { struct DayAccumulator { - var input = 0 - var output = 0 - var cacheRead = 0 - var cacheCreation = 0 - var reasoning = 0 - var tokens = 0 + var mix = CostUsageTokenMix() + var tokens = CostUsageDailyReport.OptionalCountAccumulator() var cost: Double = 0 - var sawInput = false - var sawOutput = false - var sawCacheRead = false - var sawCacheCreation = false - var sawReasoning = false - var sawTokens = false var sawCost = false var priced = 0 var unpriced = 0 @@ -24,33 +14,24 @@ enum OpenCodexUsageAggregator { } struct ModelAccumulator { - var tokens = 0 + var mix = CostUsageTokenMix() + var tokens = CostUsageDailyReport.OptionalCountAccumulator() var cost: Double = 0 - var sawTokens = false var sawCost = false - var input: Int? - var output: Int? - var cacheRead: Int? - var cacheCreation: Int? - var reasoning: Int? } struct SessionAccumulator { var lastActivity = Date.distantPast - var input: Int? - var output: Int? - var cacheRead: Int? - var reasoning: Int? - var tokens: Int? + var mix = CostUsageTokenMix() + var tokens = CostUsageDailyReport.OptionalCountAccumulator() var requests = 0 var cost: Double? var models: [String: ModelAccumulator] = [:] } struct HourAccumulator { - var tokens = 0 + var tokens = CostUsageDailyReport.OptionalCountAccumulator() var cost: Double = 0 - var sawTokens = false var sawCost = false } @@ -106,9 +87,12 @@ enum OpenCodexUsageAggregator { var hoursByStart: [Date: HourAccumulator] = [:] // `windowed` is sorted by timestamp, so the day/hour memos hit on almost every entry; a miss only costs one // Calendar interval lookup. Price once per entry and reuse it for the day, session and hour merges. + var windowTokens = CostUsageDailyReport.OptionalCountAccumulator() + let summaryStart = calendar.date(byAdding: .day, value: -(min(30, days) - 1), to: today) ?? today var dayMemo = LocalDayKeyMemo() var hourMemo = HourStartMemo() for entry in windowed { + if entry.timestamp >= summaryStart { windowTokens.merge(entry.resolvedTotalCount) } let cost = Self.listPriceUSD( entry: entry, customPricing: customPricing, @@ -141,11 +125,11 @@ enum OpenCodexUsageAggregator { return CostUsageSessionBreakdown( sessionID: key, lastActivity: session.lastActivity, - inputTokens: session.input, - cachedInputTokens: session.cacheRead, - outputTokens: session.output, - reasoningTokens: session.reasoning, - totalTokens: session.tokens, + inputTokens: session.mix.inputTokens, + cachedInputTokens: session.mix.cacheReadTokens, + outputTokens: session.mix.outputTokens, + reasoningTokens: session.mix.reasoningTokens, + totalTokens: session.tokens.value, requestCount: session.requests, costUSD: session.cost, modelBreakdowns: Self.modelBreakdowns(session.models)) @@ -161,7 +145,7 @@ enum OpenCodexUsageAggregator { let bucket = hoursByStart[hour] ?? HourAccumulator() return CostUsageHourlyEntry( hour: hour, - totalTokens: bucket.sawTokens ? bucket.tokens : nil, + totalTokens: bucket.tokens.value, costUSD: bucket.sawCost ? bucket.cost : nil) } @@ -181,10 +165,10 @@ enum OpenCodexUsageAggregator { .summary(forLastDays: min(30, days), calendar: calendar) return CostUsageTokenSnapshot( - sessionTokens: todayEntry?.totalTokens ?? (daily.isEmpty ? nil : 0), + sessionTokens: todayEntry == nil && !daily.isEmpty ? 0 : todayEntry?.totalTokens, sessionCostUSD: todayEntry?.costUSD ?? (daily.isEmpty ? nil : 0), sessionRequests: todayEntry?.requestCount ?? (daily.isEmpty ? nil : 0), - last30DaysTokens: windowSummary.totalTokens, + last30DaysTokens: windowTokens.value, last30DaysCostUSD: windowSummary.totalCostUSD, last30DaysRequests: windowSummary.totalRequests, historyDays: days, @@ -201,31 +185,8 @@ enum OpenCodexUsageAggregator { cost: Double?, into day: inout DayAccumulator) { - let usage = entry.usage - if let input = usage?.inputTokens { - day.input += input - day.sawInput = true - } - if let output = usage?.outputTokens { - day.output += output - day.sawOutput = true - } - if let cacheRead = usage?.cacheReadTokens { - day.cacheRead += cacheRead - day.sawCacheRead = true - } - if let cacheCreation = usage?.cacheCreationInputTokens { - day.cacheCreation += cacheCreation - day.sawCacheCreation = true - } - if let reasoning = usage?.reasoningOutputTokens { - day.reasoning += reasoning - day.sawReasoning = true - } - if let tokens = entry.resolvedTotalTokens { - day.tokens += tokens - day.sawTokens = true - } + day.mix.merge(entry.usage?.tokenMix ?? .init()) + day.tokens.merge(entry.resolvedTotalCount) day.priced += entry.usageStatus == .reported ? 1 : 0 day.estimated += entry.usageStatus == .estimated ? 1 : 0 day.unmetered += entry.usageStatus == .unsupported ? 1 : 0 @@ -256,11 +217,8 @@ enum OpenCodexUsageAggregator { cost: Double?, into session: inout SessionAccumulator) { - session.input = self.add(session.input, entry.usage?.inputTokens) - session.output = self.add(session.output, entry.usage?.outputTokens) - session.cacheRead = self.add(session.cacheRead, entry.usage?.cacheReadTokens) - session.reasoning = self.add(session.reasoning, entry.usage?.reasoningOutputTokens) - session.tokens = self.add(session.tokens, entry.resolvedTotalTokens) + session.mix.merge(entry.usage?.tokenMix ?? .init()) + session.tokens.merge(entry.resolvedTotalCount) session.cost = self.add(session.cost, cost) var model = session.models[entry.model] ?? ModelAccumulator() self.merge(entry, cost: cost, into: &model) @@ -272,10 +230,7 @@ enum OpenCodexUsageAggregator { cost: Double?, into hour: inout HourAccumulator) { - if let tokens = entry.resolvedTotalTokens { - hour.tokens += tokens - hour.sawTokens = true - } + hour.tokens.merge(entry.resolvedTotalCount) if let cost { hour.cost += cost hour.sawCost = true @@ -287,15 +242,8 @@ enum OpenCodexUsageAggregator { cost: Double?, into model: inout ModelAccumulator) { - model.input = self.add(model.input, entry.usage?.inputTokens) - model.output = self.add(model.output, entry.usage?.outputTokens) - model.cacheRead = self.add(model.cacheRead, entry.usage?.cacheReadTokens) - model.cacheCreation = self.add(model.cacheCreation, entry.usage?.cacheCreationInputTokens) - model.reasoning = self.add(model.reasoning, entry.usage?.reasoningOutputTokens) - if let tokens = entry.resolvedTotalTokens { - model.tokens += tokens - model.sawTokens = true - } + model.mix.merge(entry.usage?.tokenMix ?? .init()) + model.tokens.merge(entry.resolvedTotalCount) if let cost { model.cost += cost model.sawCost = true @@ -305,12 +253,12 @@ enum OpenCodexUsageAggregator { private static func entry(dayKey: String, day: DayAccumulator) -> CostUsageDailyReport.Entry { CostUsageDailyReport.Entry( date: dayKey, - inputTokens: day.sawInput ? day.input : nil, - outputTokens: day.sawOutput ? day.output : nil, - cacheReadTokens: day.sawCacheRead ? day.cacheRead : nil, - cacheCreationTokens: day.sawCacheCreation ? day.cacheCreation : nil, - reasoningTokens: day.sawReasoning ? day.reasoning : nil, - totalTokens: day.sawTokens ? day.tokens : nil, + inputTokens: day.mix.inputTokens, + outputTokens: day.mix.outputTokens, + cacheReadTokens: day.mix.cacheReadTokens, + cacheCreationTokens: day.mix.cacheCreationTokens, + reasoningTokens: day.mix.reasoningTokens, + totalTokens: day.tokens.value, requestCount: day.priced + day.unpriced + day.unmetered + day.estimated, costUSD: day.sawCost ? day.cost : nil, modelsUsed: day.models.keys.sorted(), @@ -326,12 +274,12 @@ enum OpenCodexUsageAggregator { return CostUsageDailyReport.ModelBreakdown( modelName: name, costUSD: model.sawCost ? model.cost : nil, - totalTokens: model.sawTokens ? model.tokens : nil, - inputTokens: model.input, - outputTokens: model.output, - cacheReadTokens: model.cacheRead, - cacheCreationTokens: model.cacheCreation, - reasoningTokens: model.reasoning) + totalTokens: model.tokens.value, + inputTokens: model.mix.inputTokens, + outputTokens: model.mix.outputTokens, + cacheReadTokens: model.mix.cacheReadTokens, + cacheCreationTokens: model.mix.cacheCreationTokens, + reasoningTokens: model.mix.reasoningTokens) } } @@ -379,15 +327,6 @@ enum OpenCodexUsageAggregator { customPricing: customPricingOverlay) } - private static func add(_ lhs: Int?, _ rhs: Int?) -> Int? { - switch (lhs, rhs) { - case let (left?, right?): left + right - case let (left?, nil): left - case let (nil, right?): right - case (nil, nil): nil - } - } - private static func add(_ lhs: Double?, _ rhs: Double?) -> Double? { switch (lhs, rhs) { case let (left?, right?): left + right diff --git a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageModels.swift b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageModels.swift index b139b9394c..72b18b74e6 100644 --- a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageModels.swift +++ b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageModels.swift @@ -39,17 +39,25 @@ public struct OpenCodexTokenUsage: Sendable, Equatable { } public var resolvedTotalTokens: Int? { - if let totalTokens { - return totalTokens + self.resolvedTotalCount.value + } + + var tokenMix: CostUsageTokenMix { + CostUsageTokenMix( + inputTokens: self.inputTokens, + outputTokens: self.outputTokens, + cacheReadTokens: self.cacheReadTokens, + cacheCreationTokens: self.cacheCreationInputTokens, + reasoningTokens: self.reasoningOutputTokens) + } + + var resolvedTotalCount: CostUsageDailyReport.OptionalCountAccumulator { + if let totalTokens { return .init(totalTokens) } + var count = CostUsageDailyReport.OptionalCountAccumulator() + for value in [self.inputTokens, self.outputTokens, self.cacheReadTokens, self.cacheCreationInputTokens] { + count.add(value) } - let parts = [ - self.inputTokens, - self.outputTokens, - self.cacheReadTokens, - self.cacheCreationInputTokens, - ].compactMap(\.self) - guard !parts.isEmpty else { return nil } - return parts.reduce(0, +) + return count } private static func nonnegative(_ value: Int?) -> Int? { @@ -95,7 +103,12 @@ public struct OpenCodexUsageEntry: Sendable, Equatable { } public var resolvedTotalTokens: Int? { - self.totalTokens ?? self.usage?.resolvedTotalTokens + self.resolvedTotalCount.value + } + + var resolvedTotalCount: CostUsageDailyReport.OptionalCountAccumulator { + if let totalTokens { return .init(totalTokens) } + return self.usage?.resolvedTotalCount ?? .init() } public var displayAccountLabel: String { diff --git a/Tests/CodexBarTests/OpenCodexUsageFanOutTests.swift b/Tests/CodexBarTests/OpenCodexUsageFanOutTests.swift index 1d6f476610..1be7b61459 100644 --- a/Tests/CodexBarTests/OpenCodexUsageFanOutTests.swift +++ b/Tests/CodexBarTests/OpenCodexUsageFanOutTests.swift @@ -538,6 +538,60 @@ struct OpenCodexUsageFanOutTests { } private enum OpenCodexUsageSnapshotReference { + /// Freeze the reference representation independently of production accumulator refactors. + struct DayAccumulator { + var input = 0 + var output = 0 + var cacheRead = 0 + var cacheCreation = 0 + var reasoning = 0 + var tokens = 0 + var cost: Double = 0 + var sawInput = false + var sawOutput = false + var sawCacheRead = false + var sawCacheCreation = false + var sawReasoning = false + var sawTokens = false + var sawCost = false + var priced = 0 + var unpriced = 0 + var unmetered = 0 + var estimated = 0 + var models: [String: ModelAccumulator] = [:] + } + + struct ModelAccumulator { + var tokens = 0 + var cost: Double = 0 + var sawTokens = false + var sawCost = false + var input: Int? + var output: Int? + var cacheRead: Int? + var cacheCreation: Int? + var reasoning: Int? + } + + struct SessionAccumulator { + var lastActivity = Date.distantPast + var input: Int? + var output: Int? + var cacheRead: Int? + var reasoning: Int? + var tokens: Int? + var requests = 0 + var cost: Double? + var models: [String: ModelAccumulator] = [:] + } + + struct HourAccumulator { + var tokens = 0 + var cost: Double = 0 + var sawTokens = false + var sawCost = false + } + static func snapshot( entries: [OpenCodexUsageEntry], now: Date, @@ -561,28 +615,28 @@ private enum OpenCodexUsageSnapshotReference { return lhs.requestID < rhs.requestID } - var daysByKey: [String: OpenCodexUsageAggregator.DayAccumulator] = [:] - var sessions: [String: OpenCodexUsageAggregator.SessionAccumulator] = [:] - var hoursByStart: [Date: OpenCodexUsageAggregator.HourAccumulator] = [:] + var daysByKey: [String: DayAccumulator] = [:] + var sessions: [String: SessionAccumulator] = [:] + var hoursByStart: [Date: HourAccumulator] = [:] for entry in windowed { let cost = Self.listPriceUSD( entry: entry, customPricing: customPricing, modelsDevCacheRoot: modelsDevCacheRoot) let dayKey = CostUsageLocalDay.key(from: entry.timestamp, calendar: calendar) - var day = daysByKey[dayKey] ?? OpenCodexUsageAggregator.DayAccumulator() + var day = daysByKey[dayKey] ?? DayAccumulator() Self.merge(entry, cost: cost, into: &day) daysByKey[dayKey] = day let sessionID = entry.conversationID ?? entry.requestID - var session = sessions[sessionID] ?? OpenCodexUsageAggregator.SessionAccumulator() + var session = sessions[sessionID] ?? SessionAccumulator() session.lastActivity = max(session.lastActivity, entry.timestamp) session.requests += 1 Self.merge(entry, cost: cost, into: &session) sessions[sessionID] = session let hour = calendar.dateInterval(of: .hour, for: entry.timestamp)?.start ?? entry.timestamp - var hourBucket = hoursByStart[hour] ?? OpenCodexUsageAggregator.HourAccumulator() + var hourBucket = hoursByStart[hour] ?? HourAccumulator() Self.merge(entry, cost: cost, into: &hourBucket) hoursByStart[hour] = hourBucket } @@ -612,7 +666,7 @@ private enum OpenCodexUsageSnapshotReference { return lhs.sessionID < rhs.sessionID } let hourly = hoursByStart.keys.sorted().map { hour in - let bucket = hoursByStart[hour] ?? OpenCodexUsageAggregator.HourAccumulator() + let bucket = hoursByStart[hour] ?? HourAccumulator() return CostUsageHourlyEntry( hour: hour, totalTokens: bucket.sawTokens ? bucket.tokens : nil, @@ -651,7 +705,7 @@ private enum OpenCodexUsageSnapshotReference { private static func merge( _ entry: OpenCodexUsageEntry, cost: Double?, - into day: inout OpenCodexUsageAggregator.DayAccumulator) + into day: inout DayAccumulator) { let usage = entry.usage if let input = usage?.inputTokens { @@ -696,7 +750,7 @@ private enum OpenCodexUsageSnapshotReference { day.estimated -= 1 } } - var model = day.models[entry.model] ?? OpenCodexUsageAggregator.ModelAccumulator() + var model = day.models[entry.model] ?? ModelAccumulator() Self.merge(entry, cost: cost, into: &model) day.models[entry.model] = model } @@ -704,7 +758,7 @@ private enum OpenCodexUsageSnapshotReference { private static func merge( _ entry: OpenCodexUsageEntry, cost: Double?, - into session: inout OpenCodexUsageAggregator.SessionAccumulator) + into session: inout SessionAccumulator) { session.input = self.add(session.input, entry.usage?.inputTokens) session.output = self.add(session.output, entry.usage?.outputTokens) @@ -712,7 +766,7 @@ private enum OpenCodexUsageSnapshotReference { session.reasoning = self.add(session.reasoning, entry.usage?.reasoningOutputTokens) session.tokens = self.add(session.tokens, entry.resolvedTotalTokens) session.cost = self.add(session.cost, cost) - var model = session.models[entry.model] ?? OpenCodexUsageAggregator.ModelAccumulator() + var model = session.models[entry.model] ?? ModelAccumulator() Self.merge(entry, cost: cost, into: &model) session.models[entry.model] = model } @@ -720,7 +774,7 @@ private enum OpenCodexUsageSnapshotReference { private static func merge( _ entry: OpenCodexUsageEntry, cost: Double?, - into hour: inout OpenCodexUsageAggregator.HourAccumulator) + into hour: inout HourAccumulator) { if let tokens = entry.resolvedTotalTokens { hour.tokens += tokens @@ -735,7 +789,7 @@ private enum OpenCodexUsageSnapshotReference { private static func merge( _ entry: OpenCodexUsageEntry, cost: Double?, - into model: inout OpenCodexUsageAggregator.ModelAccumulator) + into model: inout ModelAccumulator) { model.input = self.add(model.input, entry.usage?.inputTokens) model.output = self.add(model.output, entry.usage?.outputTokens) @@ -754,7 +808,7 @@ private enum OpenCodexUsageSnapshotReference { private static func entry( dayKey: String, - day: OpenCodexUsageAggregator.DayAccumulator) -> CostUsageDailyReport.Entry + day: DayAccumulator) -> CostUsageDailyReport.Entry { CostUsageDailyReport.Entry( date: dayKey, @@ -774,10 +828,10 @@ private enum OpenCodexUsageSnapshotReference { } private static func modelBreakdowns( - _ models: [String: OpenCodexUsageAggregator.ModelAccumulator]) -> [CostUsageDailyReport.ModelBreakdown] + _ models: [String: ModelAccumulator]) -> [CostUsageDailyReport.ModelBreakdown] { models.keys.sorted().map { name in - let model = models[name] ?? OpenCodexUsageAggregator.ModelAccumulator() + let model = models[name] ?? ModelAccumulator() return CostUsageDailyReport.ModelBreakdown( modelName: name, costUSD: model.sawCost ? model.cost : nil, diff --git a/Tests/CodexBarTests/OpenCodexUsageOverflowTests.swift b/Tests/CodexBarTests/OpenCodexUsageOverflowTests.swift new file mode 100644 index 0000000000..fb70c69e10 --- /dev/null +++ b/Tests/CodexBarTests/OpenCodexUsageOverflowTests.swift @@ -0,0 +1,170 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct OpenCodexUsageOverflowTests { + @Test + func `derived token totals reject an unrepresentable sum`() { + let usage = OpenCodexTokenUsage(inputTokens: Int.max, outputTokens: 1) + #expect(usage.resolvedTotalTokens == nil) + #expect(usage.inputTokens == Int.max) + #expect(usage.outputTokens == 1) + #expect(OpenCodexTokenUsage().resolvedTotalTokens == nil) + #expect(OpenCodexTokenUsage(inputTokens: 0).resolvedTotalTokens == 0) + #expect(OpenCodexTokenUsage(inputTokens: Int.max, outputTokens: 1, totalTokens: 0).resolvedTotalTokens == 0) + } + + @Test(arguments: [[0, 0, 0], [-2, -1, 0], [-1, -1, 0]]) + func `OpenCodex bucket sums keep overflow unavailable after later contributions`(_ dayOffsets: [Int]) throws { + let now = Date(timeIntervalSince1970: 1_784_222_400) + let entries = [Int.max, 1, 5].enumerated().map { index, value in + OpenCodexUsageEntry( + requestID: "row-\(index)", + timestamp: now.addingTimeInterval(Double(dayOffsets[index]) * 86400 + Double(index - 2)), + provider: "openai", + model: "fixture-model", + usageStatus: .unreported, + conversationID: "shared-session", + usage: OpenCodexTokenUsage( + inputTokens: value, + outputTokens: 2, + cacheReadInputTokens: value, + cacheCreationInputTokens: value, + reasoningOutputTokens: value, + totalTokens: value)) + } + let result = Self.snapshot(entries: entries, now: now) + #expect(result.last30DaysTokens == nil) + #expect(result.sessionTokens == (dayOffsets[0] == 0 ? nil : 5)) + let session = try #require(result.sessions.first) + #expect(session.totalTokens == nil) + #expect(session.inputTokens == nil) + #expect(session.cachedInputTokens == nil) + #expect(session.reasoningTokens == nil) + #expect(session.outputTokens == 6) + #expect(session.requestCount == 3) + #expect(session.modelBreakdowns.first?.totalTokens == nil) + if dayOffsets[0] == 0 { + let day = try #require(result.daily.first) + #expect(day.totalTokens == nil) + #expect(day.inputTokens == nil) + #expect(day.cacheReadTokens == nil) + #expect(day.cacheCreationTokens == nil) + #expect(day.reasoningTokens == nil) + #expect(day.outputTokens == 6) + #expect(day.modelBreakdowns?.first?.totalTokens == nil) + #expect(result.hourly.allSatisfy { $0.totalTokens == nil }) + } + } + + @Test + func `an overflowing derived entry is not reported as zero today`() { + let now = Date(timeIntervalSince1970: 1_784_222_400) + let entry = OpenCodexUsageEntry( + requestID: "derived", + timestamp: now, + provider: "openai", + model: "fixture-model", + usageStatus: .unreported, + usage: OpenCodexTokenUsage(inputTokens: Int.max, outputTokens: 1)) + let result = Self.snapshot(entries: [entry], now: now) + #expect(result.sessionTokens == nil) + #expect(result.last30DaysTokens == nil) + #expect(result.daily.first?.inputTokens == Int.max) + #expect(result.daily.first?.outputTokens == 1) + #expect(result.sessions.first?.totalTokens == nil) + #expect(result.hourly.first?.totalTokens == nil) + } + + @Test(arguments: [[0, 0, 0], [-2, -1, 0], [-1, -1, 0]]) + func `daily report token sums use the established sticky overflow contract`(_ dayOffsets: [Int]) throws { + let reports = [Int.max, 1, 5].enumerated().map { index, value in + CostUsageDailyReport(data: [Self.entry( + date: "2026-07-\(16 + dayOffsets[index])", value: value)], summary: nil) + } + let merged = CostUsageDailyReport.merged(reports) + #expect(merged.summary?.totalTokens == nil) + #expect(merged.summary?.totalInputTokens == nil) + #expect(merged.summary?.cacheReadTokens == nil) + #expect(merged.summary?.cacheCreationTokens == nil) + #expect(merged.summary?.reasoningTokens == nil) + #expect(merged.summary?.totalOutputTokens == 6) + let modelTotals = try #require(CostUsageDailyReport.modelCostSummaries(from: merged.data).first) + // A new pass sums known materialized rows; nil has no serialized overflow provenance. + let materializedTotal: Int? = dayOffsets == [-1, -1, 0] ? 5 : nil + #expect(modelTotals.totalTokens == materializedTotal) + #expect(modelTotals.standardTokens == materializedTotal) + #expect(modelTotals.priorityTokens == materializedTotal) + if dayOffsets[0] == 0 { + let day = try #require(merged.data.first) + #expect(day.totalTokens == nil) + #expect(day.inputTokens == nil) + #expect(day.outputTokens == 6) + } + } + + @Test + func `derived daily totals and native OpenCodex merges reject overflow`() { + let derived = CostUsageDailyReport.Entry( + date: "2026-07-16", + inputTokens: Int.max, + outputTokens: 1, + totalTokens: nil, + costUSD: nil, + modelsUsed: nil, + modelBreakdowns: nil) + let report = CostUsageDailyReport.merged([.init(data: [derived], summary: nil)]) + #expect(report.data.first?.totalTokens == nil) + #expect(report.summary?.totalTokens == nil) + let now = Date(timeIntervalSince1970: 1_784_222_400) + let base = CostUsageFetcher.tokenSnapshot( + from: .init(data: [Self.entry(date: "2026-07-16", value: Int.max)], summary: nil), + now: now, + calendar: Self.calendar) + let supplement = CostUsageFetcher.tokenSnapshot( + from: .init(data: [Self.entry(date: "2026-07-16", value: 1)], summary: nil), + now: now, + calendar: Self.calendar) + let merged = OpenCodexUsageFanOut.mergeSnapshots( + base, supplement, now: now, historyDays: 7, calendar: Self.calendar) + #expect(merged.sessionTokens == nil) + #expect(merged.last30DaysTokens == nil) + #expect(merged.daily.first?.inputTokens == nil) + #expect(merged.daily.first?.outputTokens == 4) + } + + private static var calendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + } + + private static func snapshot(entries: [OpenCodexUsageEntry], now: Date) -> CostUsageTokenSnapshot { + OpenCodexUsageAggregator.snapshot( + entries: entries, + now: now, + historyDays: 7, + calendar: self.calendar, + modelsDevCatalog: ModelsDevCatalog(providers: [:]), + customPricingOverlay: .empty) + } + + private static func entry(date: String, value: Int) -> CostUsageDailyReport.Entry { + CostUsageDailyReport.Entry( + date: date, + inputTokens: value, + outputTokens: 2, + cacheReadTokens: value, + cacheCreationTokens: value, + reasoningTokens: value, + totalTokens: value, + costUSD: nil, + modelsUsed: ["fixture-model"], + modelBreakdowns: [.init( + modelName: "fixture-model", + costUSD: nil, + totalTokens: value, + standardTokens: value, + priorityTokens: value)]) + } +} diff --git a/docs/providers.md b/docs/providers.md index 478a9c3da9..f3614f40cc 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -42,6 +42,9 @@ and is not clickable. Custom list-price overlays are documented in `docs/model-p Cached and combined reports retain token-class details and known request counts. Coverage is combined from each source's existing classification, so a priced source cannot hide another source's unpriced or unmetered rows. If coverage totals cannot fit, aggregation falls back to existing request or daily-row inference without changing costs or stored data. +Token sums that exceed the supported integer range remain unavailable for that aggregation pass; later rows do not +restore a partial count. Other token classes, pricing, and explicit totals retain their existing meaning. Materialized +missing values continue to follow the existing partial-data rules; no overflow metadata is added to stored reports. OpenCodex `~/.opencodex/usage.jsonl` is an opt-in, read-only spend source (off by default). It is not a quota Provider. When both OpenCodex logs and native Codex sessions are present they stay on separate rows; merging would