diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index c1e59085d4..e5fe14eb9b 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -517,6 +517,9 @@ extension SettingsStore { if changed { self.costUsageSettingsRevision &+= 1 } + if newValue { + self.pinCostUsageBucketTimeZoneIfNeeded() + } self.noteBackgroundWorkSettingsChanged() } } @@ -544,6 +547,66 @@ extension SettingsStore { } } + var costUsageBucketTimeZoneIdentifier: String { + get { self.defaultsState.costUsageBucketTimeZoneIdentifier } + set { + let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) + let normalized = CostUsageBucketTimeZone.isValidIdentifier(trimmed) ? trimmed : "" + let changed = self.defaultsState.costUsageBucketTimeZoneIdentifier != normalized + self.defaultsState.costUsageBucketTimeZoneIdentifier = normalized + self.userDefaults.set(normalized, forKey: "tokenCostUsageBucketTimeZone") + if changed { + self.costUsageSettingsRevision &+= 1 + } + } + } + + var costUsageBucketCalendar: Calendar { + CostUsageBucketTimeZone.calendar(identifier: self.costUsageBucketTimeZoneIdentifier) + } + + var openCodexUsageLogsEnabled: Bool { + get { self.defaultsState.openCodexUsageLogsEnabled } + set { + let changed = self.defaultsState.openCodexUsageLogsEnabled != newValue + self.defaultsState.openCodexUsageLogsEnabled = newValue + self.userDefaults.set(newValue, forKey: "openCodexUsageLogsEnabled") + if changed { + self.costUsageSettingsRevision &+= 1 + } + } + } + + var hideNativeCodexCostWhenOpenCodexPresent: Bool { + get { self.defaultsState.hideNativeCodexCostWhenOpenCodexPresent } + set { + let changed = self.defaultsState.hideNativeCodexCostWhenOpenCodexPresent != newValue + self.defaultsState.hideNativeCodexCostWhenOpenCodexPresent = newValue + self.userDefaults.set(newValue, forKey: "hideNativeCodexCostWhenOpenCodexPresent") + if changed { + self.costUsageSettingsRevision &+= 1 + } + } + } + + var spendDashboardHiddenSourceIDs: [String] { + get { self.defaultsState.spendDashboardHiddenSourceIDs } + set { + let normalized = Array(Set(newValue.filter { !$0.isEmpty })).sorted() + let changed = self.defaultsState.spendDashboardHiddenSourceIDs != normalized + self.defaultsState.spendDashboardHiddenSourceIDs = normalized + self.userDefaults.set(normalized, forKey: "spendDashboardHiddenSourceIDs") + if changed { + self.costUsageSettingsRevision &+= 1 + } + } + } + + func pinCostUsageBucketTimeZoneIfNeeded() { + guard self.costUsageBucketTimeZoneIdentifier.isEmpty else { return } + self.costUsageBucketTimeZoneIdentifier = CostUsageBucketTimeZone.pinIdentifier() + } + var costComparisonPeriodsEnabled: Bool { get { self.defaultsState.costComparisonPeriodsEnabled } set { diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index 3b01482606..d144a963a5 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -46,6 +46,10 @@ extension SettingsStore { _ = self.costUsageEnabled _ = self.codexLocalSessionCostLedgerEnabled _ = self.costUsageHistoryDays + _ = self.costUsageBucketTimeZoneIdentifier + _ = self.openCodexUsageLogsEnabled + _ = self.hideNativeCodexCostWhenOpenCodexPresent + _ = self.spendDashboardHiddenSourceIDs _ = self.costComparisonPeriodsEnabled _ = self.costSummaryDisplayStyle _ = self.appLanguage diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 22cea5d532..93a8bc56e8 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -546,6 +546,17 @@ extension SettingsStore { forKey: "codexLocalSessionCostLedgerEnabled") as? Bool ?? false let rawCostUsageHistoryDays = userDefaults.object(forKey: "tokenCostUsageHistoryDays") as? Int ?? 30 let costUsageHistoryDays = max(1, min(365, rawCostUsageHistoryDays)) + let storedBucketTimeZone = userDefaults.string(forKey: "tokenCostUsageBucketTimeZone") ?? "" + let costUsageBucketTimeZoneIdentifier = CostUsageBucketTimeZone.isValidIdentifier(storedBucketTimeZone) + ? storedBucketTimeZone + : (costUsageEnabled ? CostUsageBucketTimeZone.pinIdentifier() : "") + if costUsageEnabled, storedBucketTimeZone.isEmpty, !costUsageBucketTimeZoneIdentifier.isEmpty { + userDefaults.set(costUsageBucketTimeZoneIdentifier, forKey: "tokenCostUsageBucketTimeZone") + } + let openCodexUsageLogsEnabled = userDefaults.object(forKey: "openCodexUsageLogsEnabled") as? Bool ?? false + let hideNativeCodexCostWhenOpenCodexPresent = userDefaults.object( + forKey: "hideNativeCodexCostWhenOpenCodexPresent") as? Bool ?? false + let spendDashboardHiddenSourceIDs = userDefaults.stringArray(forKey: "spendDashboardHiddenSourceIDs") ?? [] let costComparisonPeriodsEnabled = userDefaults.object( forKey: "costComparisonPeriodsEnabled") as? Bool ?? false let costSummaryDisplayStyleRaw = Self.loadCostSummaryDisplayStyleRaw( @@ -673,6 +684,10 @@ extension SettingsStore { costUsageEnabled: costUsageEnabled, codexLocalSessionCostLedgerEnabled: codexLocalSessionCostLedgerEnabled, costUsageHistoryDays: costUsageHistoryDays, + costUsageBucketTimeZoneIdentifier: costUsageBucketTimeZoneIdentifier, + openCodexUsageLogsEnabled: openCodexUsageLogsEnabled, + hideNativeCodexCostWhenOpenCodexPresent: hideNativeCodexCostWhenOpenCodexPresent, + spendDashboardHiddenSourceIDs: spendDashboardHiddenSourceIDs, costComparisonPeriodsEnabled: costComparisonPeriodsEnabled, costSummaryDisplayStyleRaw: costSummaryDisplayStyleRaw, hidePersonalInfo: hidePersonalInfo, diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index d34a34926a..b1547135c2 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -47,6 +47,10 @@ struct SettingsDefaultsState { var costUsageEnabled: Bool var codexLocalSessionCostLedgerEnabled: Bool var costUsageHistoryDays: Int + var costUsageBucketTimeZoneIdentifier: String + var openCodexUsageLogsEnabled: Bool + var hideNativeCodexCostWhenOpenCodexPresent: Bool + var spendDashboardHiddenSourceIDs: [String] var costComparisonPeriodsEnabled: Bool var costSummaryDisplayStyleRaw: String var hidePersonalInfo: Bool diff --git a/Sources/CodexBar/UsageStore+CodexCostCatchUp.swift b/Sources/CodexBar/UsageStore+CodexCostCatchUp.swift index d473029996..2794820073 100644 --- a/Sources/CodexBar/UsageStore+CodexCostCatchUp.swift +++ b/Sources/CodexBar/UsageStore+CodexCostCatchUp.swift @@ -300,7 +300,9 @@ extension UsageStore { if let override = self._test_codexCostCatchUpStatusOverride { return await override(codexHomePath) } - return await self.costUsageFetcher.codexScanCatchUpStatus(codexHomePath: codexHomePath) + return await self.costUsageFetcher.codexScanCatchUpStatus( + codexHomePath: codexHomePath, + calendar: self.settings.costUsageBucketCalendar) } private func advanceCodexCostCatchUp( @@ -314,7 +316,8 @@ extension UsageStore { return try await self.costUsageFetcher.advanceCodexScanCatchUp( now: now, codexHomePath: codexHomePath, - historyDays: historyDays) + historyDays: historyDays, + calendar: self.settings.costUsageBucketCalendar) } private func codexCostCatchUpDecision( diff --git a/Sources/CodexBar/UsageStore+SpendDashboardCodexCostCatchUp.swift b/Sources/CodexBar/UsageStore+SpendDashboardCodexCostCatchUp.swift index 86758961c5..13bc2b7b8e 100644 --- a/Sources/CodexBar/UsageStore+SpendDashboardCodexCostCatchUp.swift +++ b/Sources/CodexBar/UsageStore+SpendDashboardCodexCostCatchUp.swift @@ -276,8 +276,11 @@ extension UsageStore { statuses[account.cacheIdentity] = await override(account) } else { statuses[account.cacheIdentity] = await CostUsageFetcher( - cacheRoot: SpendDashboardSource.codexCacheRoot(for: account)) - .codexScanCatchUpStatus(codexHomePath: account.homePath) + cacheRoot: SpendDashboardSource.codexCacheRoot(for: account), + calendar: self.settings.costUsageBucketCalendar) + .codexScanCatchUpStatus( + codexHomePath: account.homePath, + calendar: self.settings.costUsageBucketCalendar) } } return statuses @@ -291,11 +294,14 @@ extension UsageStore { if let override = self._test_spendDashboardCodexCostCatchUpAdvanceOverride { return try await override(account, now, historyDays) } - return try await CostUsageFetcher(cacheRoot: SpendDashboardSource.codexCacheRoot(for: account)) + return try await CostUsageFetcher( + cacheRoot: SpendDashboardSource.codexCacheRoot(for: account), + calendar: self.settings.costUsageBucketCalendar) .advanceCodexScanCatchUp( now: now, codexHomePath: account.homePath, - historyDays: historyDays) + historyDays: historyDays, + calendar: self.settings.costUsageBucketCalendar) } private func spendDashboardCodexCostCatchUpDecision( diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index 6e5cb85969..ce3c4741c5 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -75,7 +75,8 @@ extension UsageStore { historyDays: historyDays, cursorCookieHeaderOverride: cursorCookieHeaderOverride, allowPricingRefresh: allowPricingRefresh, - bypassScannerDebounce: true) + bypassScannerDebounce: true, + calendar: self.settings.costUsageBucketCalendar) } group.addTask { try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000)) @@ -228,7 +229,8 @@ extension UsageStore { await self.costUsageFetcher.loadCachedCodexTokenSnapshotResult( now: now, codexHomePath: scope.codexHomePath, - historyDays: historyDays) + historyDays: historyDays, + calendar: self.settings.costUsageBucketCalendar) .map { ( snapshot: $0.snapshot, diff --git a/Sources/CodexBarCore/CodexLocalProjectUsageIndexer.swift b/Sources/CodexBarCore/CodexLocalProjectUsageIndexer.swift index d645a17f7e..6f4cfdbe9a 100644 --- a/Sources/CodexBarCore/CodexLocalProjectUsageIndexer.swift +++ b/Sources/CodexBarCore/CodexLocalProjectUsageIndexer.swift @@ -446,7 +446,10 @@ extension CodexLocalProjectUsageIndexer { var skippedFiles = 0 var sessionBuckets: [String: SessionBucket] = [:] let files = cache.files.sorted(by: { $0.key < $1.key }).filter { - $0.value.touchesCodexScanWindow(sinceKey: range.sinceKey, untilKey: range.untilKey) + $0.value.touchesCodexScanWindow( + sinceKey: range.sinceKey, + untilKey: range.untilKey, + calendar: range.calendar) } progress?(CodexLocalProjectUsageIndexProgress( phase: .indexingProjects, diff --git a/Sources/CodexBarCore/CostProvenance.swift b/Sources/CodexBarCore/CostProvenance.swift new file mode 100644 index 0000000000..f41d5f77bf --- /dev/null +++ b/Sources/CodexBarCore/CostProvenance.swift @@ -0,0 +1,185 @@ +import Foundation + +/// How a cost figure was produced. This is display-time accounting, not a billing receipt. +public enum CostProvenance: String, Sendable, Equatable, Codable { + /// Token counts × public API list prices. + case listPriceEstimate + /// Vendor-reported metered spend (for example Cursor plan deductions). + case vendorMetered + /// Window mixes list-price rows with vendor-metered rows. + case mixed + case unknown + + public var isBillingReceipt: Bool { + false + } + + /// Narrow a snapshot-level provenance to the costs actually present in a window. + /// Daily vendor-reported rows stay vendor-metered even when `meteredCostUSD` is absent. + public static func forWindow( + snapshot: CostProvenance, + hasWindowCosts: Bool, + includesMetered: Bool) -> CostProvenance + { + switch snapshot { + case .vendorMetered: + includesMetered || hasWindowCosts ? .vendorMetered : .unknown + case .mixed: + switch (includesMetered, hasWindowCosts) { + case (true, true): + .mixed + case (true, false): + .vendorMetered + case (false, true): + .listPriceEstimate + case (false, false): + .unknown + } + case .listPriceEstimate: + hasWindowCosts ? .listPriceEstimate : .unknown + case .unknown: + .unknown + } + } +} + +/// Request/row coverage for a cost window. Counts stay independent so a missing +/// category is `0` rather than collapsing into another bucket. +public struct CostUsageCoverageCounts: Sendable, Equatable, Codable { + public var priced: Int + public var unpriced: Int + public var unmetered: Int + public var estimated: Int + + public init(priced: Int = 0, unpriced: Int = 0, unmetered: Int = 0, estimated: Int = 0) { + self.priced = max(0, priced) + self.unpriced = max(0, unpriced) + self.unmetered = max(0, unmetered) + self.estimated = max(0, estimated) + } + + public var total: Int { + self.priced + self.unpriced + self.unmetered + self.estimated + } + + public var coverageRatio: Double? { + let measured = self.priced + self.estimated + let denominator = self.total + guard denominator > 0 else { return nil } + return Double(measured) / Double(denominator) + } + + public mutating func merge(_ other: CostUsageCoverageCounts) { + self.priced += other.priced + self.unpriced += other.unpriced + self.unmetered += other.unmetered + self.estimated += other.estimated + } + + public static func + (lhs: Self, rhs: Self) -> Self { + var merged = lhs + merged.merge(rhs) + return merged + } +} + +/// Token-class mix. `nil` means the source did not establish that class — never treat as zero. +public struct CostUsageTokenMix: Sendable, Equatable, Codable { + public var inputTokens: Int? + public var outputTokens: Int? + public var cacheReadTokens: Int? + public var cacheCreationTokens: Int? + public var reasoningTokens: Int? + + public init( + inputTokens: Int? = nil, + outputTokens: Int? = nil, + cacheReadTokens: Int? = nil, + cacheCreationTokens: Int? = nil, + reasoningTokens: Int? = nil) + { + self.inputTokens = Self.nonnegative(inputTokens) + self.outputTokens = Self.nonnegative(outputTokens) + self.cacheReadTokens = Self.nonnegative(cacheReadTokens) + self.cacheCreationTokens = Self.nonnegative(cacheCreationTokens) + self.reasoningTokens = Self.nonnegative(reasoningTokens) + } + + public var hasAnyClass: Bool { + self.inputTokens != nil + || self.outputTokens != nil + || self.cacheReadTokens != nil + || self.cacheCreationTokens != nil + || self.reasoningTokens != nil + } + + public mutating func merge(_ other: CostUsageTokenMix) { + self.inputTokens = Self.add(self.inputTokens, other.inputTokens) + self.outputTokens = Self.add(self.outputTokens, other.outputTokens) + self.cacheReadTokens = Self.add(self.cacheReadTokens, other.cacheReadTokens) + self.cacheCreationTokens = Self.add(self.cacheCreationTokens, other.cacheCreationTokens) + self.reasoningTokens = Self.add(self.reasoningTokens, other.reasoningTokens) + } + + public static func + (lhs: Self, rhs: Self) -> Self { + var merged = lhs + merged.merge(rhs) + return merged + } + + public static func from(entry: CostUsageDailyReport.Entry) -> Self { + Self( + inputTokens: entry.inputTokens, + outputTokens: entry.outputTokens, + cacheReadTokens: entry.cacheReadTokens, + cacheCreationTokens: entry.cacheCreationTokens, + reasoningTokens: entry.reasoningTokens) + } + + private static func nonnegative(_ value: Int?) -> Int? { + guard let value, value >= 0 else { return nil } + return value + } + + private static func add(_ lhs: Int?, _ rhs: Int?) -> Int? { + switch (lhs, rhs) { + case let (left?, right?): + let (result, overflow) = left.addingReportingOverflow(right) + return overflow ? nil : result + case let (left?, nil): + return left + case let (nil, right?): + return right + case (nil, nil): + return nil + } + } +} + +/// Pinned IANA timezone used to bucket cost-usage days. Re-bucketing the same history +/// under a different zone would move midnight-adjacent events and inflate totals. +public enum CostUsageBucketTimeZone: Sendable { + public static func calendar(identifier: String?) -> Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = self.timeZone(identifier: identifier) + return calendar + } + + public static func timeZone(identifier: String?) -> TimeZone { + if let identifier { + let trimmed = identifier.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty, let zone = TimeZone(identifier: trimmed) { + return zone + } + } + return .current + } + + public static func pinIdentifier(from timeZone: TimeZone = .current) -> String { + timeZone.identifier + } + + public static func isValidIdentifier(_ identifier: String) -> Bool { + TimeZone(identifier: identifier) != nil + } +} diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 35d50c751b..737bf60b43 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -66,8 +66,17 @@ public struct CostUsageFetcher: Sendable { private let scannerOptions: CostUsageScanner.Options? - public init(cacheRoot: URL? = nil) { - self.scannerOptions = cacheRoot.map { CostUsageScanner.Options(cacheRoot: $0) } + public init(cacheRoot: URL? = nil, calendar: Calendar? = nil) { + if cacheRoot == nil, calendar == nil { + self.scannerOptions = nil + } else { + var options = CostUsageScanner.Options() + options.cacheRoot = cacheRoot + if let calendar { + options.calendar = calendar + } + self.scannerOptions = options + } } init(scannerOptions: CostUsageScanner.Options) { @@ -77,37 +86,40 @@ public struct CostUsageFetcher: Sendable { public func loadCachedCodexTokenSnapshot( now: Date = Date(), codexHomePath: String? = nil, - historyDays: Int = 30) async -> CostUsageTokenSnapshot? + historyDays: Int = 30, + calendar: Calendar? = nil) async -> CostUsageTokenSnapshot? { await Self.loadCachedCodexTokenSnapshot( now: now, codexHomePath: codexHomePath, historyDays: historyDays, - scannerOptions: self.scannerOptionsOverride()) + scannerOptions: self.scannerOptions(calendar: calendar)) } package func loadCachedCodexTokenActivity( now: Date = Date(), codexHomePath: String? = nil, - maximumDays: Int = 365) async -> CostUsageTokenActivityCache? + maximumDays: Int = 365, + calendar: Calendar? = nil) async -> CostUsageTokenActivityCache? { await Self.loadCachedCodexTokenActivity( now: now, codexHomePath: codexHomePath, maximumDays: maximumDays, - scannerOptions: self.scannerOptionsOverride()) + scannerOptions: self.scannerOptions(calendar: calendar)) } package func loadCachedCodexTokenSnapshotResult( now: Date = Date(), codexHomePath: String? = nil, - historyDays: Int = 30) async -> CachedCodexTokenSnapshotResult? + historyDays: Int = 30, + calendar: Calendar? = nil) async -> CachedCodexTokenSnapshotResult? { await Self.loadCachedCodexTokenSnapshotResult( now: now, codexHomePath: codexHomePath, historyDays: historyDays, - scannerOptions: self.scannerOptionsOverride()) + scannerOptions: self.scannerOptions(calendar: calendar)) } package func loadCachedCodexTokenSnapshotForScopedHome( @@ -115,7 +127,8 @@ public struct CostUsageFetcher: Sendable { codexHomePath: String, historyDays: Int = 30, includePiSessions: Bool = false, - includeProjectAndSessionBreakdowns: Bool = false) async -> CostUsageTokenSnapshot? + includeProjectAndSessionBreakdowns: Bool = false, + calendar: Calendar? = nil) async -> CostUsageTokenSnapshot? { await Self.loadCachedCodexTokenSnapshot( now: now, @@ -124,7 +137,7 @@ public struct CostUsageFetcher: Sendable { allowScopedCodexHome: true, includePiSessions: includePiSessions, includeProjectAndSessionBreakdowns: includeProjectAndSessionBreakdowns, - scannerOptions: self.scannerOptionsOverride()) + scannerOptions: self.scannerOptions(calendar: calendar)) } public func loadCachedCodexLocalProjectUsageSnapshot( @@ -207,9 +220,14 @@ public struct CostUsageFetcher: Sendable { allowPricingRefresh: Bool = true, refreshPricingInBackground: Bool = true, includePiSessions: Bool = true, - bypassScannerDebounce: Bool) async throws -> CostUsageTokenSnapshot + bypassScannerDebounce: Bool, + calendar: Calendar? = nil) async throws -> CostUsageTokenSnapshot { - try await Self.loadTokenSnapshot( + var options = self.scannerOptionsOverride() ?? CostUsageScanner.Options() + if let calendar { + options.calendar = calendar + } + return try await Self.loadTokenSnapshot( provider: provider, environment: environment, now: now, @@ -222,7 +240,7 @@ public struct CostUsageFetcher: Sendable { refreshPricingInBackground: refreshPricingInBackground, includePiSessions: includePiSessions, bypassScannerDebounce: bypassScannerDebounce, - scannerOptions: self.scannerOptionsOverride()) + scannerOptions: options) } @available(*, deprecated, message: "Codex token-cost scans are uncapped; this limit is ignored.") @@ -254,12 +272,22 @@ public struct CostUsageFetcher: Sendable { self.scannerOptions } + private func scannerOptions(calendar: Calendar?) -> CostUsageScanner.Options? { + guard calendar != nil || self.scannerOptions != nil else { return self.scannerOptions } + var options = self.scannerOptions ?? CostUsageScanner.Options() + if let calendar { + options.calendar = calendar + } + return options + } + package func codexScanCatchUpStatus( - codexHomePath: String? = nil) async -> CodexScanCatchUpStatus + codexHomePath: String? = nil, + calendar: Calendar? = nil) async -> CodexScanCatchUpStatus { // Provider-specific by design: Codex exposes bounded background catch-up for its incremental JSONL scanner. let options = Self.resolvedScannerOptions( - self.scannerOptionsOverride(), + self.scannerOptions(calendar: calendar), provider: .codex, codexHomePath: codexHomePath) return await (try? CostUsageScanExecutor.run { checkCancellation in @@ -271,10 +299,11 @@ public struct CostUsageFetcher: Sendable { package func advanceCodexScanCatchUp( now: Date = Date(), codexHomePath: String? = nil, - historyDays: Int = 30) async throws -> CodexScanCatchUpStatus + historyDays: Int = 30, + calendar: Calendar? = nil) async throws -> CodexScanCatchUpStatus { var options = Self.resolvedScannerOptions( - self.scannerOptionsOverride(), + self.scannerOptions(calendar: calendar), provider: .codex, codexHomePath: codexHomePath) options.forceRescan = false @@ -487,6 +516,7 @@ public struct CostUsageFetcher: Sendable { historyDays: clampedHistoryDays, calendar: scanOptions.calendar, historyCoverageIsEstablished: scanResult.historyCoverageIsEstablished, + costProvenance: .listPriceEstimate, projects: scanResult.projects, sessions: scanResult.sessions, updatedAt: scanResult.staleSnapshotUpdatedAt) @@ -943,6 +973,7 @@ public struct CostUsageFetcher: Sendable { historyDays: clampedHistoryDays, calendar: options.calendar, historyCoverageIsEstablished: Self.codexHistoryCoverageIsEstablished(options: options), + costProvenance: .listPriceEstimate, projects: Self.mergedProjectBreakdowns(projects), sessions: sessions, updatedAt: scanTimes.min()), @@ -1076,6 +1107,9 @@ public struct CostUsageFetcher: Sendable { historyDays: historyDays, useCurrentLocalDayForSession: true, meteredCostUSD: report.meteredCostUSD, + costProvenance: Self.cursorCostProvenance( + meteredCostUSD: report.meteredCostUSD, + daily: report.daily.data), credentialScopeFingerprint: report.credentialScopeFingerprint) } #endif @@ -1088,6 +1122,7 @@ public struct CostUsageFetcher: Sendable { calendar: Calendar = .current, historyCoverageIsEstablished: Bool = true, meteredCostUSD: Double? = nil, + costProvenance: CostProvenance = .unknown, credentialScopeFingerprint: String? = nil, historyLabel: String? = nil, projects: [CostUsageProjectBreakdown] = [], @@ -1144,6 +1179,7 @@ public struct CostUsageFetcher: Sendable { historyCoverageIsEstablished: historyCoverageIsEstablished, historyLabel: historyLabel, meteredCostUSD: meteredCostUSD, + costProvenance: costProvenance, credentialScopeFingerprint: credentialScopeFingerprint, daily: daily.data, projects: projects, @@ -1168,6 +1204,17 @@ public struct CostUsageFetcher: Sendable { return self.codexAutomaticScanDurationPerRefresh } + private static func cursorCostProvenance( + meteredCostUSD: Double?, + daily: [CostUsageDailyReport.Entry]) -> CostProvenance + { + let hasDailyCosts = daily.contains { $0.costUSD != nil } + if meteredCostUSD != nil, hasDailyCosts { return .mixed } + if meteredCostUSD != nil { return .vendorMetered } + if hasDailyCosts { return .listPriceEstimate } + return .unknown + } + private static func configureScannerRefresh( _ options: inout CostUsageScanner.Options, provider: UsageProvider, @@ -1526,7 +1573,8 @@ extension CostUsageFetcher { from: daily, now: now, historyDays: historyDays, - useCurrentLocalDayForSession: false) + useCurrentLocalDayForSession: false, + costProvenance: .vendorMetered) } #if os(macOS) diff --git a/Sources/CodexBarCore/CostUsageModels.swift b/Sources/CodexBarCore/CostUsageModels.swift index 322f7d59f5..7c5853f3f0 100644 --- a/Sources/CodexBarCore/CostUsageModels.swift +++ b/Sources/CodexBarCore/CostUsageModels.swift @@ -22,19 +22,31 @@ public struct CostUsageWindowSummary: Sendable, Equatable { public let totalCostUSD: Double? public let totalRequests: Int? public let entryCount: Int + public let tokenMix: CostUsageTokenMix + public let coverage: CostUsageCoverageCounts + public let provenance: CostProvenance + public let meteredCostUSD: Double? public init( days: Int, totalTokens: Int?, totalCostUSD: Double?, totalRequests: Int?, - entryCount: Int) + entryCount: Int, + tokenMix: CostUsageTokenMix = CostUsageTokenMix(), + coverage: CostUsageCoverageCounts = CostUsageCoverageCounts(), + provenance: CostProvenance = .unknown, + meteredCostUSD: Double? = nil) { self.days = days self.totalTokens = totalTokens self.totalCostUSD = totalCostUSD self.totalRequests = totalRequests self.entryCount = entryCount + self.tokenMix = tokenMix + self.coverage = coverage + self.provenance = provenance + self.meteredCostUSD = meteredCostUSD } } @@ -46,6 +58,7 @@ public struct CostUsageSessionBreakdown: Sendable, Equatable, Identifiable { public let inputTokens: Int? public let cachedInputTokens: Int? public let outputTokens: Int? + public let reasoningTokens: Int? public let totalTokens: Int? public let requestCount: Int? public let costUSD: Double? @@ -61,6 +74,7 @@ public struct CostUsageSessionBreakdown: Sendable, Equatable, Identifiable { inputTokens: Int?, cachedInputTokens: Int?, outputTokens: Int?, + reasoningTokens: Int? = nil, totalTokens: Int?, requestCount: Int?, costUSD: Double?, @@ -71,6 +85,7 @@ public struct CostUsageSessionBreakdown: Sendable, Equatable, Identifiable { self.inputTokens = inputTokens self.cachedInputTokens = cachedInputTokens self.outputTokens = outputTokens + self.reasoningTokens = reasoningTokens self.totalTokens = totalTokens self.requestCount = requestCount self.costUSD = costUSD @@ -93,6 +108,9 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { /// actually deducts, as opposed to the API-rate estimate. Only some providers (e.g. Cursor) /// report this; `nil` when unknown. public let meteredCostUSD: Double? + /// How this snapshot's costs were produced. Never infer this solely from whether a + /// cost figure exists — Bedrock and OpenAI Admin costs are vendor-reported. + public let costProvenance: CostProvenance /// Internal credential scope used to prevent cross-account cache publication. This is a /// non-reversible fingerprint, not account identity, and is not emitted by CLI payloads. public let credentialScopeFingerprint: String? @@ -113,6 +131,7 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { historyCoverageIsEstablished: Bool = true, historyLabel: String? = nil, meteredCostUSD: Double? = nil, + costProvenance: CostProvenance = .unknown, credentialScopeFingerprint: String? = nil, daily: [CostUsageDailyReport.Entry], projects: [CostUsageProjectBreakdown] = [], @@ -131,6 +150,7 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { self.historyCoverageIsEstablished = historyCoverageIsEstablished self.historyLabel = historyLabel self.meteredCostUSD = meteredCostUSD + self.costProvenance = costProvenance self.credentialScopeFingerprint = credentialScopeFingerprint self.daily = daily self.projects = projects @@ -155,12 +175,27 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { let costs = entries.compactMap(\.costUSD) let tokens = entries.compactMap(\.totalTokens) let requests = entries.compactMap(\.requestCount) + var mix = CostUsageTokenMix() + var coverage = CostUsageCoverageCounts() + for entry in entries { + mix.merge(.from(entry: entry)) + coverage.merge(entry.coverageCounts) + } + let coversFullHistory = days >= self.historyDays + let windowMetered = coversFullHistory ? self.meteredCostUSD : nil return CostUsageWindowSummary( days: days, totalTokens: tokens.isEmpty ? nil : tokens.reduce(0, +), totalCostUSD: costs.isEmpty ? nil : costs.reduce(0, +), totalRequests: requests.isEmpty ? nil : requests.reduce(0, +), - entryCount: entries.count) + entryCount: entries.count, + tokenMix: mix, + coverage: coverage, + provenance: CostProvenance.forWindow( + snapshot: self.costProvenance, + hasWindowCosts: !costs.isEmpty, + includesMetered: windowMetered != nil), + meteredCostUSD: windowMetered) } public func comparisonSummaries( @@ -294,6 +329,11 @@ public struct CostUsageDailyReport: Sendable, Decodable { public let costUSD: Double? public let totalTokens: Int? public let requestCount: Int? + public let inputTokens: Int? + public let outputTokens: Int? + public let cacheReadTokens: Int? + public let cacheCreationTokens: Int? + public let reasoningTokens: Int? public let standardCostUSD: Double? public let priorityCostUSD: Double? public let standardTokens: Int? @@ -306,6 +346,11 @@ public struct CostUsageDailyReport: Sendable, Decodable { case totalTokens case requestCount case requests + case inputTokens + case outputTokens + case cacheReadTokens + case cacheCreationTokens + case reasoningTokens case standardCostUSD case priorityCostUSD case standardTokens @@ -322,6 +367,11 @@ public struct CostUsageDailyReport: Sendable, Decodable { self.requestCount = try container.decodeIfPresent(Int.self, forKey: .requestCount) ?? container.decodeIfPresent(Int.self, forKey: .requests) + self.inputTokens = try container.decodeIfPresent(Int.self, forKey: .inputTokens) + self.outputTokens = try container.decodeIfPresent(Int.self, forKey: .outputTokens) + self.cacheReadTokens = try container.decodeIfPresent(Int.self, forKey: .cacheReadTokens) + self.cacheCreationTokens = try container.decodeIfPresent(Int.self, forKey: .cacheCreationTokens) + self.reasoningTokens = try container.decodeIfPresent(Int.self, forKey: .reasoningTokens) 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) @@ -333,6 +383,11 @@ public struct CostUsageDailyReport: Sendable, Decodable { costUSD: Double?, totalTokens: Int? = nil, requestCount: Int? = nil, + inputTokens: Int? = nil, + outputTokens: Int? = nil, + cacheReadTokens: Int? = nil, + cacheCreationTokens: Int? = nil, + reasoningTokens: Int? = nil, standardCostUSD: Double? = nil, priorityCostUSD: Double? = nil, standardTokens: Int? = nil, @@ -342,6 +397,11 @@ public struct CostUsageDailyReport: Sendable, Decodable { self.costUSD = costUSD self.totalTokens = totalTokens self.requestCount = requestCount + self.inputTokens = inputTokens + self.outputTokens = outputTokens + self.cacheReadTokens = cacheReadTokens + self.cacheCreationTokens = cacheCreationTokens + self.reasoningTokens = reasoningTokens self.standardCostUSD = standardCostUSD self.priorityCostUSD = priorityCostUSD self.standardTokens = standardTokens @@ -355,11 +415,47 @@ public struct CostUsageDailyReport: Sendable, Decodable { public let cacheReadTokens: Int? public let cacheCreationTokens: Int? public let outputTokens: Int? + public let reasoningTokens: Int? public let totalTokens: Int? public let requestCount: Int? public let costUSD: Double? public let modelsUsed: [String]? public let modelBreakdowns: [ModelBreakdown]? + public let unpricedRequestCount: Int? + public let unmeteredRequestCount: Int? + public let estimatedRequestCount: Int? + + public var coverageCounts: CostUsageCoverageCounts { + let unpriced = max(0, self.unpricedRequestCount ?? 0) + let unmetered = max(0, self.unmeteredRequestCount ?? 0) + let estimated = max(0, self.estimatedRequestCount ?? 0) + if let requests = self.requestCount, requests > 0 { + let priced = if self.costUSD != nil { + max(0, requests - unpriced - unmetered - estimated) + } else { + 0 + } + return CostUsageCoverageCounts( + priced: priced, + unpriced: unpriced, + unmetered: unmetered, + estimated: estimated) + } + if unpriced + unmetered + estimated > 0 { + return CostUsageCoverageCounts( + priced: 0, + unpriced: unpriced, + unmetered: unmetered, + estimated: estimated) + } + if self.costUSD != nil { + return CostUsageCoverageCounts(priced: 1) + } + if (self.totalTokens ?? 0) > 0 { + return CostUsageCoverageCounts(unpriced: 1) + } + return CostUsageCoverageCounts() + } private enum CodingKeys: String, CodingKey { case date @@ -369,6 +465,8 @@ public struct CostUsageDailyReport: Sendable, Decodable { case cacheReadInputTokens case cacheCreationInputTokens case outputTokens + case reasoningTokens + case reasoningOutputTokens case totalTokens case requestCount case requests @@ -377,6 +475,9 @@ public struct CostUsageDailyReport: Sendable, Decodable { case modelsUsed case models case modelBreakdowns + case unpricedRequestCount + case unmeteredRequestCount + case estimatedRequestCount } public init(from decoder: Decoder) throws { @@ -390,6 +491,9 @@ public struct CostUsageDailyReport: Sendable, Decodable { try container.decodeIfPresent(Int.self, forKey: .cacheCreationTokens) ?? container.decodeIfPresent(Int.self, forKey: .cacheCreationInputTokens) self.outputTokens = try container.decodeIfPresent(Int.self, forKey: .outputTokens) + self.reasoningTokens = + try container.decodeIfPresent(Int.self, forKey: .reasoningTokens) + ?? container.decodeIfPresent(Int.self, forKey: .reasoningOutputTokens) self.totalTokens = try container.decodeIfPresent(Int.self, forKey: .totalTokens) self.requestCount = try container.decodeIfPresent(Int.self, forKey: .requestCount) @@ -399,6 +503,9 @@ public struct CostUsageDailyReport: Sendable, Decodable { ?? container.decodeIfPresent(Double.self, forKey: .totalCost) self.modelsUsed = Self.decodeModelsUsed(from: container) self.modelBreakdowns = try container.decodeIfPresent([ModelBreakdown].self, forKey: .modelBreakdowns) + self.unpricedRequestCount = try container.decodeIfPresent(Int.self, forKey: .unpricedRequestCount) + self.unmeteredRequestCount = try container.decodeIfPresent(Int.self, forKey: .unmeteredRequestCount) + self.estimatedRequestCount = try container.decodeIfPresent(Int.self, forKey: .estimatedRequestCount) } public init( @@ -407,22 +514,30 @@ public struct CostUsageDailyReport: Sendable, Decodable { outputTokens: Int?, cacheReadTokens: Int? = nil, cacheCreationTokens: Int? = nil, + reasoningTokens: Int? = nil, totalTokens: Int?, requestCount: Int? = nil, costUSD: Double?, modelsUsed: [String]?, - modelBreakdowns: [ModelBreakdown]?) + modelBreakdowns: [ModelBreakdown]?, + unpricedRequestCount: Int? = nil, + unmeteredRequestCount: Int? = nil, + estimatedRequestCount: Int? = nil) { self.date = date self.inputTokens = inputTokens self.outputTokens = outputTokens self.cacheReadTokens = cacheReadTokens self.cacheCreationTokens = cacheCreationTokens + self.reasoningTokens = reasoningTokens self.totalTokens = totalTokens self.requestCount = requestCount self.costUSD = costUSD self.modelsUsed = modelsUsed self.modelBreakdowns = modelBreakdowns + self.unpricedRequestCount = unpricedRequestCount + self.unmeteredRequestCount = unmeteredRequestCount + self.estimatedRequestCount = estimatedRequestCount } private static func decodeModelsUsed(from container: KeyedDecodingContainer) -> [String]? { @@ -452,6 +567,7 @@ public struct CostUsageDailyReport: Sendable, Decodable { public let totalOutputTokens: Int? public let cacheReadTokens: Int? public let cacheCreationTokens: Int? + public let reasoningTokens: Int? public let totalTokens: Int? public let totalCostUSD: Double? @@ -462,6 +578,7 @@ public struct CostUsageDailyReport: Sendable, Decodable { case cacheCreationTokens case totalCacheReadTokens case totalCacheCreationTokens + case reasoningTokens case totalTokens case totalCostUSD case totalCost @@ -472,6 +589,7 @@ public struct CostUsageDailyReport: Sendable, Decodable { totalOutputTokens: Int?, cacheReadTokens: Int? = nil, cacheCreationTokens: Int? = nil, + reasoningTokens: Int? = nil, totalTokens: Int?, totalCostUSD: Double?) { @@ -479,6 +597,7 @@ public struct CostUsageDailyReport: Sendable, Decodable { self.totalOutputTokens = totalOutputTokens self.cacheReadTokens = cacheReadTokens self.cacheCreationTokens = cacheCreationTokens + self.reasoningTokens = reasoningTokens self.totalTokens = totalTokens self.totalCostUSD = totalCostUSD } @@ -493,6 +612,7 @@ public struct CostUsageDailyReport: Sendable, Decodable { self.cacheCreationTokens = try container.decodeIfPresent(Int.self, forKey: .cacheCreationTokens) ?? container.decodeIfPresent(Int.self, forKey: .totalCacheCreationTokens) + self.reasoningTokens = try container.decodeIfPresent(Int.self, forKey: .reasoningTokens) self.totalTokens = try container.decodeIfPresent(Int.self, forKey: .totalTokens) self.totalCostUSD = try container.decodeIfPresent(Double.self, forKey: .totalCostUSD) diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 34028bb592..6edd791549 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 = "50cb4b9e11791432" + static let value = "b7726c3088c14277" } diff --git a/Sources/CodexBarCore/Providers/Groq/GroqConsoleUsageSnapshot.swift b/Sources/CodexBarCore/Providers/Groq/GroqConsoleUsageSnapshot.swift index 84cebce395..95d74cd191 100644 --- a/Sources/CodexBarCore/Providers/Groq/GroqConsoleUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/Groq/GroqConsoleUsageSnapshot.swift @@ -195,6 +195,7 @@ public struct GroqConsoleUsageSnapshot: Codable, Equatable, Sendable { last30DaysCostUSD: total.costUSD, last30DaysRequests: total.requests, historyDays: self.historyDays, + costProvenance: .vendorMetered, daily: daily, updatedAt: self.updatedAt) } diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift b/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift index db653f1a84..6233420093 100644 --- a/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift +++ b/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift @@ -291,6 +291,7 @@ public struct MistralUsageSnapshot: Codable, Sendable { historyDays: window.coveredDays, historyCoverageIsEstablished: window.coverageIsEstablished, historyLabel: window.isMonthToDate ? "This month" : nil, + costProvenance: .vendorMetered, daily: entries, updatedAt: window.observationEnd) } diff --git a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageSnapshot.swift b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageSnapshot.swift index 5b4030eca8..6401d78684 100644 --- a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageSnapshot.swift @@ -268,6 +268,7 @@ public struct OpenAIAPIUsageSnapshot: Codable, Equatable, Sendable { last30DaysCostUSD: total.costUSD, last30DaysRequests: total.requests, historyDays: self.historyDays, + costProvenance: .vendorMetered, daily: daily, updatedAt: self.updatedAt) } diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageSnapshot.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageSnapshot.swift index 6f0adc4ebe..de46bcc9ab 100644 --- a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageSnapshot.swift @@ -175,6 +175,7 @@ public struct OpenCodeGoUsageSnapshot: Sendable { CostUsageFetcher.tokenSnapshot( from: CostUsageDailyReport(data: self.daily, summary: nil), now: self.updatedAt, - historyDays: historyDays) + historyDays: historyDays, + costProvenance: .listPriceEstimate) } } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCacheModels.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCacheModels.swift index 8484476d6d..4af8c32169 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCacheModels.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCacheModels.swift @@ -83,6 +83,10 @@ struct CostUsageCodexPreviousReport: Codable, Equatable { var costUSD: Double? var totalTokens: Int? var requestCount: Int? + var inputTokens: Int? + var outputTokens: Int? + var cacheReadTokens: Int? + var reasoningTokens: Int? var standardCostUSD: Double? var priorityCostUSD: Double? var standardTokens: Int? @@ -93,6 +97,10 @@ struct CostUsageCodexPreviousReport: Codable, Equatable { self.costUSD = breakdown.costUSD self.totalTokens = breakdown.totalTokens self.requestCount = breakdown.requestCount + self.inputTokens = breakdown.inputTokens + self.outputTokens = breakdown.outputTokens + self.cacheReadTokens = breakdown.cacheReadTokens + self.reasoningTokens = breakdown.reasoningTokens self.standardCostUSD = breakdown.standardCostUSD self.priorityCostUSD = breakdown.priorityCostUSD self.standardTokens = breakdown.standardTokens @@ -105,6 +113,10 @@ struct CostUsageCodexPreviousReport: Codable, Equatable { costUSD: self.costUSD, totalTokens: self.totalTokens, requestCount: self.requestCount, + inputTokens: self.inputTokens, + outputTokens: self.outputTokens, + cacheReadTokens: self.cacheReadTokens, + reasoningTokens: self.reasoningTokens, standardCostUSD: self.standardCostUSD, priorityCostUSD: self.priorityCostUSD, standardTokens: self.standardTokens, @@ -118,11 +130,15 @@ struct CostUsageCodexPreviousReport: Codable, Equatable { var cacheReadTokens: Int? var cacheCreationTokens: Int? var outputTokens: Int? + var reasoningTokens: Int? var totalTokens: Int? var requestCount: Int? var costUSD: Double? var modelsUsed: [String]? var modelBreakdowns: [ModelBreakdown]? + var unpricedRequestCount: Int? + var unmeteredRequestCount: Int? + var estimatedRequestCount: Int? init(_ entry: CostUsageDailyReport.Entry) { self.date = entry.date @@ -130,11 +146,15 @@ struct CostUsageCodexPreviousReport: Codable, Equatable { self.cacheReadTokens = entry.cacheReadTokens self.cacheCreationTokens = entry.cacheCreationTokens self.outputTokens = entry.outputTokens + self.reasoningTokens = entry.reasoningTokens self.totalTokens = entry.totalTokens self.requestCount = entry.requestCount self.costUSD = entry.costUSD self.modelsUsed = entry.modelsUsed self.modelBreakdowns = entry.modelBreakdowns?.map(ModelBreakdown.init) + self.unpricedRequestCount = entry.unpricedRequestCount + self.unmeteredRequestCount = entry.unmeteredRequestCount + self.estimatedRequestCount = entry.estimatedRequestCount } var dailyReportValue: CostUsageDailyReport.Entry { @@ -144,11 +164,15 @@ struct CostUsageCodexPreviousReport: Codable, Equatable { outputTokens: self.outputTokens, cacheReadTokens: self.cacheReadTokens, cacheCreationTokens: self.cacheCreationTokens, + reasoningTokens: self.reasoningTokens, totalTokens: self.totalTokens, requestCount: self.requestCount, costUSD: self.costUSD, modelsUsed: self.modelsUsed, - modelBreakdowns: self.modelBreakdowns?.map(\.dailyReportValue)) + modelBreakdowns: self.modelBreakdowns?.map(\.dailyReportValue), + unpricedRequestCount: self.unpricedRequestCount, + unmeteredRequestCount: self.unmeteredRequestCount, + estimatedRequestCount: self.estimatedRequestCount) } } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCustomPricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCustomPricing.swift new file mode 100644 index 0000000000..7d5618a545 --- /dev/null +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCustomPricing.swift @@ -0,0 +1,209 @@ +#if canImport(CryptoKit) +import CryptoKit +#else +import Crypto +#endif +import Foundation + +/// User overlay for list-price estimates. Values are USD per million tokens. +/// Exact key match only; `0` is free; a missing field stays unknown and is not +/// filled from models.dev or bundled tables. +public struct CostUsageCustomPricing: Sendable, Equatable { + public struct Rates: Sendable, Equatable { + public var input: Double? + public var output: Double? + public var cacheRead: Double? + public var cacheWrite: Double? + + public init(input: Double? = nil, output: Double? = nil, cacheRead: Double? = nil, cacheWrite: Double? = nil) { + self.input = input + self.output = output + self.cacheRead = cacheRead + self.cacheWrite = cacheWrite + } + + public var hasAnyRate: Bool { + self.input != nil || self.output != nil || self.cacheRead != nil || self.cacheWrite != nil + } + } + + public let entries: [String: Rates] + public let fingerprint: String + + public init(entries: [String: Rates], fingerprint: String) { + self.entries = entries + self.fingerprint = fingerprint + } + + public static let empty = CostUsageCustomPricing(entries: [:], fingerprint: "none") + + public static let fileName = "custom-pricing.json" + + public static func defaultFileURL(fileManager: FileManager = .default) -> URL { + AppGroupSupport.localFallbackDirectory(fileManager: fileManager) + .appendingPathComponent(self.fileName, isDirectory: false) + } + + public static func load( + fileURL: URL? = nil, + fileManager: FileManager = .default, + environment: [String: String] = ProcessInfo.processInfo.environment) -> CostUsageCustomPricing + { + if fileURL == nil, self.isRunningTests(environment) { + return .empty + } + let url = fileURL ?? self.defaultFileURL(fileManager: fileManager) + guard fileManager.fileExists(atPath: url.path), + let data = try? Data(contentsOf: url) + else { return .empty } + return self.parse(data) + } + + private static func isRunningTests(_ environment: [String: String]) -> Bool { + let keys = [ + "XCTestConfigurationFilePath", + "XCTestBundlePath", + "XCTestSessionIdentifier", + "SWIFT_TESTING_ENABLED", + "TESTING_LIBRARY_VERSION", + "SWIFT_TESTING", + ] + if keys.contains(where: { environment[$0] != nil }) { + return true + } + if keys.contains(where: { ProcessInfo.processInfo.environment[$0] != nil }) { + return true + } + return Bundle.allBundles.contains { $0.bundlePath.hasSuffix(".xctest") } + } + + public static func parse(_ data: Data) -> CostUsageCustomPricing { + guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return .empty + } + var entries: [String: Rates] = [:] + for (rawKey, rawValue) in object { + let key = self.normalizeKey(rawKey) + guard !key.isEmpty, let rates = self.rates(from: rawValue) else { continue } + entries[key] = rates + } + let fingerprint = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + return CostUsageCustomPricing(entries: entries, fingerprint: fingerprint) + } + + public func rates(providerID: String? = nil, model: String) -> Rates? { + let modelKey = Self.normalizeKey(model) + guard !modelKey.isEmpty else { return nil } + if let exact = self.entries[modelKey] { + return exact + } + if let providerID { + let combined = Self.normalizeKey("\(providerID)/\(model)") + if let match = self.entries[combined] { + return match + } + } + return nil + } + + public func costUSD( + providerID: String? = nil, + model: String, + inputTokens: Int, + outputTokens: Int, + cacheReadTokens: Int = 0, + cacheWriteTokens: Int = 0) -> Double? + { + guard let rates = self.rates(providerID: providerID, model: model) else { return nil } + return Self.costUSD( + rates: rates, + inputTokens: inputTokens, + outputTokens: outputTokens, + cacheReadTokens: cacheReadTokens, + cacheWriteTokens: cacheWriteTokens) + } + + func estimatedCodexCostUSD( + model: String, + inputTokens: Int, + cachedInputTokens: Int, + outputTokens: Int, + cacheWriteInputTokens: Int) -> Double? + { + let cached = max(0, cachedInputTokens) + let written = max(0, cacheWriteInputTokens) + let uncachedInput = max(0, inputTokens - cached - written) + return self.costUSD( + providerID: CostUsagePricing.codexModelsDevProviderID, + model: model, + inputTokens: uncachedInput, + outputTokens: outputTokens, + cacheReadTokens: cached, + cacheWriteTokens: written) + } + + static func costUSD( + rates: Rates, + inputTokens: Int, + outputTokens: Int, + cacheReadTokens: Int, + cacheWriteTokens: Int) -> Double? + { + var total = 0.0 + if inputTokens > 0 { + guard let rate = rates.input else { return nil } + total += Double(inputTokens) * Self.perToken(rate) + } + if outputTokens > 0 { + guard let rate = rates.output else { return nil } + total += Double(outputTokens) * Self.perToken(rate) + } + if cacheReadTokens > 0 { + guard let rate = rates.cacheRead else { return nil } + total += Double(cacheReadTokens) * Self.perToken(rate) + } + if cacheWriteTokens > 0 { + guard let rate = rates.cacheWrite else { return nil } + total += Double(cacheWriteTokens) * Self.perToken(rate) + } + return total + } + + public static func perToken(_ perMillion: Double) -> Double { + perMillion / 1_000_000 + } + + static func normalizeKey(_ raw: String) -> String { + raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } + + private static func rates(from value: Any) -> Rates? { + guard let object = value as? [String: Any] else { return nil } + let rates = Rates( + input: self.rate(object["input"]), + output: self.rate(object["output"]), + cacheRead: self.rate(object["cacheRead"]) ?? self.rate(object["cache_read"]), + cacheWrite: self.rate(object["cacheWrite"]) + ?? self.rate(object["cache_write"]) + ?? self.rate(object["cacheCreation"]) + ?? self.rate(object["cache_creation"])) + return rates.hasAnyRate ? rates : nil + } + + /// `0` is a valid free rate. Non-finite and negative values are unknown. + private static func rate(_ value: Any?) -> Double? { + guard let value else { return nil } + let number: Double + if let parsed = value as? Double { + number = parsed + } else if let parsed = value as? Int { + number = Double(parsed) + } else if let parsed = value as? NSNumber { + number = parsed.doubleValue + } else { + return nil + } + guard number.isFinite, number >= 0 else { return nil } + return number + } +} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+Overlay.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+Overlay.swift new file mode 100644 index 0000000000..f9b3d97481 --- /dev/null +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+Overlay.swift @@ -0,0 +1,72 @@ +import Foundation + +extension CostUsagePricing { + static func codexCostUSD( + model: String, + inputTokens: Int, + cachedInputTokens: Int, + outputTokens: Int, + cacheWriteInputTokens: Int = 0, + modelsDevCatalog: ModelsDevCatalog? = nil, + modelsDevCacheRoot: URL? = nil, + customPricing: CostUsageCustomPricing? = nil) -> Double? + { + if let cost = (customPricing ?? self.customPricingOverlay()).estimatedCodexCostUSD( + model: model, + inputTokens: inputTokens, + cachedInputTokens: cachedInputTokens, + outputTokens: outputTokens, + cacheWriteInputTokens: cacheWriteInputTokens) + { + return cost + } + guard let pricing = self.resolvedCodexPricing( + model: model, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + else { return nil } + return self.codexCostUSD( + pricing: pricing, + inputTokens: inputTokens, + cachedInputTokens: cachedInputTokens, + cacheWriteInputTokens: cacheWriteInputTokens, + outputTokens: outputTokens) + } + + static func codexAggregateCostUSD( + model: String, + inputTokens: Int, + cachedInputTokens: Int, + outputTokens: Int, + cacheWriteInputTokens: Int = 0, + modelsDevCatalog: ModelsDevCatalog? = nil, + modelsDevCacheRoot: URL? = nil, + customPricing: CostUsageCustomPricing? = nil) -> Double? + { + if let cost = (customPricing ?? self.customPricingOverlay()).estimatedCodexCostUSD( + model: model, + inputTokens: inputTokens, + cachedInputTokens: cachedInputTokens, + outputTokens: outputTokens, + cacheWriteInputTokens: cacheWriteInputTokens) + { + return cost + } + guard let pricing = self.resolvedCodexPricing( + model: model, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + else { return nil } + if let thresholdTokens = pricing.thresholdTokens, + max(0, inputTokens) > thresholdTokens + { + return nil + } + return self.codexCostUSD( + pricing: pricing, + inputTokens: inputTokens, + cachedInputTokens: cachedInputTokens, + cacheWriteInputTokens: cacheWriteInputTokens, + outputTokens: outputTokens) + } +} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift index 4fce93e6ec..30d366cb5e 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift @@ -420,7 +420,7 @@ enum CostUsagePricing { cacheReadInputCostPerTokenAboveThreshold: 6e-7), ] - private static let codexModelsDevProviderID = "openai" + static let codexModelsDevProviderID = "openai" /// Provider IDs emitted by Codex-compatible clients that have matching entries in models.dev. /// /// The route prefix is part of the model identity for local usage estimates. Keep both the @@ -539,56 +539,11 @@ enum CostUsagePricing { return trimmed } - static func codexCostUSD( - model: String, - inputTokens: Int, - cachedInputTokens: Int, - outputTokens: Int, - cacheWriteInputTokens: Int = 0, - modelsDevCatalog: ModelsDevCatalog? = nil, - modelsDevCacheRoot: URL? = nil) -> Double? - { - guard let pricing = self.resolvedCodexPricing( - model: model, - modelsDevCatalog: modelsDevCatalog, - modelsDevCacheRoot: modelsDevCacheRoot) - else { return nil } - return self.codexCostUSD( - pricing: pricing, - inputTokens: inputTokens, - cachedInputTokens: cachedInputTokens, - cacheWriteInputTokens: cacheWriteInputTokens, - outputTokens: outputTokens) - } - - static func codexAggregateCostUSD( - model: String, - inputTokens: Int, - cachedInputTokens: Int, - outputTokens: Int, - cacheWriteInputTokens: Int = 0, - modelsDevCatalog: ModelsDevCatalog? = nil, - modelsDevCacheRoot: URL? = nil) -> Double? - { - guard let pricing = self.resolvedCodexPricing( - model: model, - modelsDevCatalog: modelsDevCatalog, - modelsDevCacheRoot: modelsDevCacheRoot) - else { return nil } - if let thresholdTokens = pricing.thresholdTokens, - max(0, inputTokens) > thresholdTokens - { - return nil - } - return self.codexCostUSD( - pricing: pricing, - inputTokens: inputTokens, - cachedInputTokens: cachedInputTokens, - cacheWriteInputTokens: cacheWriteInputTokens, - outputTokens: outputTokens) + static func customPricingOverlay(fileURL: URL? = nil) -> CostUsageCustomPricing { + CostUsageCustomPricing.load(fileURL: fileURL) } - private static func resolvedCodexPricing( + static func resolvedCodexPricing( model: String, modelsDevCatalog: ModelsDevCatalog?, modelsDevCacheRoot: URL?) -> CodexPricing? @@ -666,7 +621,8 @@ enum CostUsagePricing { cacheWriteInputTokens: Int = 0, outputTokens: Int, modelsDevCatalog: ModelsDevCatalog? = nil, - modelsDevCacheRoot: URL? = nil) -> Double? + modelsDevCacheRoot: URL? = nil, + customPricing: CostUsageCustomPricing? = nil) -> Double? { guard let multiplier = self.codexAPIFastMultiplier(model: model) else { return nil } // OpenAI does not support API Fast processing for long-context requests. Do not combine @@ -682,7 +638,8 @@ enum CostUsagePricing { outputTokens: outputTokens, cacheWriteInputTokens: cacheWriteInputTokens, modelsDevCatalog: modelsDevCatalog, - modelsDevCacheRoot: modelsDevCacheRoot) + modelsDevCacheRoot: modelsDevCacheRoot, + customPricing: customPricing) .map { $0 * multiplier } } @@ -696,7 +653,7 @@ enum CostUsagePricing { } } - private static func codexCostUSD( + static func codexCostUSD( pricing: CodexPricing, inputTokens: Int, cachedInputTokens: Int, diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricingKey.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricingKey.swift index 7148b861c8..27c57944a6 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricingKey.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricingKey.swift @@ -10,11 +10,13 @@ enum CostUsagePricingKey { modelsDevArtifact: ModelsDevCacheArtifact?, formulaVersion: Int, parserHash: String? = nil, - modelsDevProviderIDs: Set = CostUsagePricing.codexModelsDevProviderIDs) -> String + modelsDevProviderIDs: Set = CostUsagePricing.codexModelsDevProviderIDs, + customPricingFingerprint: String = CostUsageCustomPricing.load().fingerprint) -> String { var parts = [ "costFormulaVersion=\(formulaVersion)", "builtInPricing:\n\(CostUsagePricing.codexBuiltInPricingFingerprint())", + "customPricing=\(customPricingFingerprint)", ] if let parserHash { parts.append("parserHash=\(parserHash)") diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift index 31f435d89f..8a2543e9e6 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift @@ -153,7 +153,8 @@ extension CostUsageScanner { rows: [CodexUsageRow], priorityTurns: [String: CodexPriorityTurnMetadata], modelsDevCatalog: ModelsDevCatalog?, - modelsDevCacheRoot: URL?) -> CodexRowCostBreakdown + modelsDevCacheRoot: URL?, + customPricing: CostUsageCustomPricing? = nil) -> CodexRowCostBreakdown { var breakdown = CodexRowCostBreakdown() for row in rows { @@ -183,7 +184,8 @@ extension CostUsageScanner { for: row, priorityTurns: priorityTurns, modelsDevCatalog: modelsDevCatalog, - modelsDevCacheRoot: modelsDevCacheRoot) + modelsDevCacheRoot: modelsDevCacheRoot, + customPricing: customPricing) else { breakdown.hasIncompletePricing = breakdown.hasIncompletePricing || hasTokens continue @@ -1186,7 +1188,8 @@ extension CostUsageScanner { state.contributingSessionIds.contains(sessionId), uniqueRows.isEmpty, usageDays.isEmpty, - parsed.bufferedSubagentLines == nil + parsed.bufferedSubagentLines == nil, + parsed.bufferedUnresolvedForkLines == nil { cache.files.removeValue(forKey: input.metadata.path) return @@ -1350,7 +1353,10 @@ extension CostUsageScanner { guard isForceRescan else { return } for key in cache.files.keys { guard let old = cache.files[key] else { continue } - guard !old.touchesCodexScanWindow(sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) + guard !old.touchesCodexScanWindow( + sinceKey: range.scanSinceKey, + untilKey: range.scanUntilKey, + calendar: range.calendar) else { continue } Self.applyFileDays(cache: &cache, fileDays: old.days, sign: -1) cache.files.removeValue(forKey: key) @@ -1397,124 +1403,76 @@ extension CostUsageScanner { priorityTurns: priorityTurns) } var entries: [CostUsageDailyReport.Entry] = [] - var (totalInput, totalCacheRead, totalOutput, totalTokens) = (0, 0, 0, 0) + var (totalInput, totalCacheRead, totalOutput, totalReasoning, totalTokens) = (0, 0, 0, 0, 0) var (totalCost, costSeen) = (0.0, false) - let dayKeys = self.codexReportDayKeys(cache: reportCache, range: range) - var rowsByDayModel: [String: [String: [CodexUsageRow]]] = [:] - var unresolvedRowGroups = Set() - var modeOwnershipMismatchGroups = Set() - var priorityEvidenceGroups = Set() - var incompletePricingEvidenceGroups = Set() - var authoritativeCostEvidenceGroups = Set() + let unmeteredByDay = Self.unresolvedForkUnmeteredCounts(cache: reportCache, range: range) + let dayKeys = Array(Set(self.codexReportDayKeys(cache: reportCache, range: range) + unmeteredByDay.keys)) + .sorted() + .filter { + CostUsageDayRange.isInRange(dayKey: $0, since: range.sinceKey, until: range.untilKey) + } + let catalog = catalogResolver.load(modelsDevCatalogLoader) + var pricing = CodexReportDayPricingContext( + rowsByDayModel: [:], + unresolvedRowGroups: [], + modeOwnershipMismatchGroups: [], + priorityEvidenceGroups: [], + incompletePricingEvidenceGroups: [], + authoritativeCostEvidenceGroups: [], + priorityTurns: priorityTurns, + modelsDevCatalog: catalog, + modelsDevCacheRoot: modelsDevCacheRoot, + customPricing: CostUsagePricing.customPricingOverlay()) for usage in reportCache.files.values { let reconciled = self.codexCanonicalPricingRows(usage) - unresolvedRowGroups.formUnion(reconciled.unresolvedGroups) + pricing.unresolvedRowGroups.formUnion(reconciled.unresolvedGroups) let modeEvidence = self.codexPricingModeEvidence( usage: usage, reconciledRows: reconciled.rows, range: range, priorityTurns: priorityTurns) - modeOwnershipMismatchGroups.formUnion(modeEvidence.mismatchGroups) - priorityEvidenceGroups.formUnion(modeEvidence.priorityGroups) - incompletePricingEvidenceGroups.formUnion(self.codexIncompletePricingEvidenceGroups( + pricing.modeOwnershipMismatchGroups.formUnion(modeEvidence.mismatchGroups) + pricing.priorityEvidenceGroups.formUnion(modeEvidence.priorityGroups) + pricing.incompletePricingEvidenceGroups.formUnion(self.codexIncompletePricingEvidenceGroups( usage: usage, range: range, priorityTurns: priorityTurns, - modelsDevCatalog: catalogResolver.load(modelsDevCatalogLoader), - modelsDevCacheRoot: modelsDevCacheRoot)) + modelsDevCatalog: catalog, + modelsDevCacheRoot: modelsDevCacheRoot, + customPricing: pricing.customPricing)) for row in usage.codexRows ?? [] where (row.knownCostNanos ?? 0) != 0 { - authoritativeCostEvidenceGroups.insert(CodexDayModelKey(day: row.day, model: row.model)) + pricing.authoritativeCostEvidenceGroups.insert(CodexDayModelKey(day: row.day, model: row.model)) } for row in reconciled.rows where CostUsageDayRange.isInRange( dayKey: row.day, since: range.sinceKey, until: range.untilKey) { - rowsByDayModel[row.day, default: [:]][row.model, default: []].append(row) + pricing.rowsByDayModel[row.day, default: [:]][row.model, default: []].append(row) } } for day in dayKeys { - guard let models = reportCache.days[day] else { continue } - let modelNames = models.keys.sorted() - - var dayInput = 0 - var dayCacheRead = 0 - var dayOutput = 0 - var breakdown: [CostUsageDailyReport.ModelBreakdown] = [] - var dayCost: Double = 0 - var dayCostSeen = false - - for model in modelNames { - let packed = models[model] ?? [0, 0, 0] - let input = packed[safe: 0] ?? 0 - let cached = packed[safe: 1] ?? 0 - let output = packed[safe: 2] ?? 0 - let totalTokens = input + output - - dayInput += input - dayCacheRead += cached - dayOutput += output - - let rows = rowsByDayModel[day]?[model] ?? [] - let rowCost = rows.isEmpty ? nil : Self.codexRowCostBreakdown( - rows: rows, - priorityTurns: priorityTurns, - modelsDevCatalog: catalogResolver.load(modelsDevCatalogLoader), - modelsDevCacheRoot: modelsDevCacheRoot) - let group = CodexDayModelKey(day: day, model: model) - let rowCostIsTrusted = !unresolvedRowGroups.contains(group) - && !modeOwnershipMismatchGroups.contains(group) - && rowCost?.isTrusted(canonicalTotalTokens: totalTokens) == true - let aggregateCost = priorityEvidenceGroups.contains(group) - || incompletePricingEvidenceGroups.contains(group) - || (unresolvedRowGroups.contains(group) && authoritativeCostEvidenceGroups.contains(group)) - || rowCost?.hasIncompletePricing == true - ? nil - : CostUsagePricing.codexAggregateCostUSD( - model: model, - inputTokens: input, - cachedInputTokens: cached, - outputTokens: output, - modelsDevCatalog: catalogResolver.load(modelsDevCatalogLoader), - modelsDevCacheRoot: modelsDevCacheRoot) - let cost = rowCostIsTrusted - ? rowCost?.totalCostUSD ?? aggregateCost - : aggregateCost - let hasModeSplit = rowCostIsTrusted && rowCost?.hasModeSplit == true - breakdown.append( - CostUsageDailyReport.ModelBreakdown( - modelName: model, - costUSD: cost, - totalTokens: totalTokens, - standardCostUSD: hasModeSplit ? rowCost?.optionalStandardCostUSD : nil, - priorityCostUSD: hasModeSplit ? rowCost?.optionalPriorityCostUSD : nil, - standardTokens: hasModeSplit ? rowCost?.optionalStandardTokens : nil, - priorityTokens: hasModeSplit ? rowCost?.optionalPriorityTokens : nil)) - if let cost { - dayCost += cost - dayCostSeen = true + let unmetered = unmeteredByDay[day] ?? 0 + guard let models = reportCache.days[day] else { + if let entry = Self.unmeteredForkReportEntry(day: day, unmetered: unmetered) { + entries.append(entry) } + continue } - - let dayTotal = dayInput + dayOutput - let entryCost = dayCostSeen ? dayCost : nil - entries.append(CostUsageDailyReport.Entry( - date: day, - inputTokens: dayInput, - outputTokens: dayOutput, - cacheReadTokens: dayCacheRead > 0 ? dayCacheRead : nil, - totalTokens: dayTotal, - costUSD: entryCost, - modelsUsed: modelNames, - modelBreakdowns: Self.sortedModelBreakdowns(breakdown))) - - totalInput += dayInput - totalCacheRead += dayCacheRead - totalOutput += dayOutput - totalTokens += dayTotal - if let entryCost { + let entry = Self.makeCodexBilledDayEntry( + day: day, + models: models, + unmetered: unmetered, + pricing: pricing) + entries.append(entry) + totalInput += entry.inputTokens ?? 0 + totalCacheRead += entry.cacheReadTokens ?? 0 + totalOutput += entry.outputTokens ?? 0 + totalReasoning += entry.reasoningTokens ?? 0 + totalTokens += entry.totalTokens ?? 0 + if let entryCost = entry.costUSD { totalCost += entryCost costSeen = true } @@ -1526,6 +1484,7 @@ extension CostUsageScanner { totalInputTokens: totalInput, totalOutputTokens: totalOutput, cacheReadTokens: totalCacheRead > 0 ? totalCacheRead : nil, + reasoningTokens: totalReasoning > 0 ? totalReasoning : nil, totalTokens: totalTokens, totalCostUSD: costSeen ? totalCost : nil) @@ -1603,11 +1562,3 @@ extension [UInt8] { return self[index] } } - -extension CostUsageFileUsage { - func touchesCodexScanWindow(sinceKey: String, untilKey: String) -> Bool { - self.days.keys.contains { - CostUsageScanner.CostUsageDayRange.isInRange(dayKey: $0, since: sinceKey, until: untilKey) - } - } -} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+ForkCoverage.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+ForkCoverage.swift new file mode 100644 index 0000000000..40b7588df3 --- /dev/null +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+ForkCoverage.swift @@ -0,0 +1,196 @@ +import Foundation + +extension CostUsageScanner { + /// Missing-parent forks stay out of priced totals. Count them as unmetered so Spend + /// coverage can show the gap instead of silently dropping the session. + static func unresolvedForkUnmeteredCounts( + cache: CostUsageCache, + range: CostUsageDayRange) -> [String: Int] + { + var counts: [String: Int] = [:] + for usage in cache.files.values { + guard self.isUnresolvedMissingParentFork(usage), + !self.codexFileHasBilledTokens(usage) + else { continue } + let unixMs = usage.codexSession?.startedAtUnixMs + ?? usage.codexSession?.latestActivityUnixMs + ?? usage.mtimeUnixMs + guard unixMs > 0 else { continue } + let dayKey = CostUsageDayRange.dayKey( + from: Date(timeIntervalSince1970: TimeInterval(unixMs) / 1000), + calendar: range.calendar) + guard CostUsageDayRange.isInRange(dayKey: dayKey, since: range.sinceKey, until: range.untilKey) + else { continue } + counts[dayKey, default: 0] += 1 + } + return counts + } + + static func isUnresolvedMissingParentFork(_ usage: CostUsageFileUsage) -> Bool { + guard usage.forkedFromId != nil else { return false } + if let key = usage.forkBaselineDependencyKey { + return key.hasPrefix("missing|") + } + return true + } + + static func codexFileHasBilledTokens(_ usage: CostUsageFileUsage) -> Bool { + if (usage.codexRows ?? []).contains(where: { $0.input > 0 || $0.cached > 0 || $0.output > 0 }) { + return true + } + return usage.days.values.contains { models in + models.values.contains { packed in packed.contains { $0 > 0 } } + } + } + + struct CodexReportDayPricingContext { + var rowsByDayModel: [String: [String: [CodexUsageRow]]] + var unresolvedRowGroups: Set + var modeOwnershipMismatchGroups: Set + var priorityEvidenceGroups: Set + var incompletePricingEvidenceGroups: Set + var authoritativeCostEvidenceGroups: Set + var priorityTurns: [String: CodexPriorityTurnMetadata] + var modelsDevCatalog: ModelsDevCatalog + var modelsDevCacheRoot: URL? + var customPricing: CostUsageCustomPricing + } + + static func unmeteredForkReportEntry(day: String, unmetered: Int) -> CostUsageDailyReport.Entry? { + guard unmetered > 0 else { return nil } + return CostUsageDailyReport.Entry( + date: day, + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + costUSD: nil, + modelsUsed: nil, + modelBreakdowns: nil, + unmeteredRequestCount: unmetered) + } + + static func makeCodexBilledDayEntry( + day: String, + models: [String: [Int]], + unmetered: Int, + pricing: CodexReportDayPricingContext) -> CostUsageDailyReport.Entry + { + let modelNames = models.keys.sorted() + var dayInput = 0 + var dayCacheRead = 0 + var dayOutput = 0 + var dayReasoning = 0 + var breakdown: [CostUsageDailyReport.ModelBreakdown] = [] + var dayCost: Double = 0 + var dayCostSeen = false + + for model in modelNames { + let packed = models[model] ?? [0, 0, 0] + let input = packed[safe: 0] ?? 0 + let cached = packed[safe: 1] ?? 0 + let output = packed[safe: 2] ?? 0 + let totalTokens = input + output + let rows = pricing.rowsByDayModel[day]?[model] ?? [] + let reasoning = rows.compactMap(\.reasoning).reduce(0, +) + + dayInput += input + dayCacheRead += cached + dayOutput += output + if reasoning > 0 { + dayReasoning += reasoning + } + + let rowCost = rows.isEmpty ? nil : Self.codexRowCostBreakdown( + rows: rows, + priorityTurns: pricing.priorityTurns, + modelsDevCatalog: pricing.modelsDevCatalog, + modelsDevCacheRoot: pricing.modelsDevCacheRoot, + customPricing: pricing.customPricing) + let group = CodexDayModelKey(day: day, model: model) + let rowCostIsTrusted = !pricing.unresolvedRowGroups.contains(group) + && !pricing.modeOwnershipMismatchGroups.contains(group) + && rowCost?.isTrusted(canonicalTotalTokens: totalTokens) == true + let aggregateCost = pricing.priorityEvidenceGroups.contains(group) + || pricing.incompletePricingEvidenceGroups.contains(group) + || (pricing.unresolvedRowGroups.contains(group) + && pricing.authoritativeCostEvidenceGroups.contains(group)) + || rowCost?.hasIncompletePricing == true + ? nil + : CostUsagePricing.codexAggregateCostUSD( + model: model, + inputTokens: input, + cachedInputTokens: cached, + outputTokens: output, + modelsDevCatalog: pricing.modelsDevCatalog, + modelsDevCacheRoot: pricing.modelsDevCacheRoot, + customPricing: pricing.customPricing) + let cost = rowCostIsTrusted + ? rowCost?.totalCostUSD ?? aggregateCost + : aggregateCost + let hasModeSplit = rowCostIsTrusted && rowCost?.hasModeSplit == true + breakdown.append( + CostUsageDailyReport.ModelBreakdown( + modelName: model, + costUSD: cost, + totalTokens: totalTokens, + inputTokens: input, + outputTokens: output, + cacheReadTokens: cached > 0 ? cached : nil, + reasoningTokens: reasoning > 0 ? reasoning : nil, + standardCostUSD: hasModeSplit ? rowCost?.optionalStandardCostUSD : nil, + priorityCostUSD: hasModeSplit ? rowCost?.optionalPriorityCostUSD : nil, + standardTokens: hasModeSplit ? rowCost?.optionalStandardTokens : nil, + priorityTokens: hasModeSplit ? rowCost?.optionalPriorityTokens : nil)) + if let cost { + dayCost += cost + dayCostSeen = true + } + } + + let dayTotal = dayInput + dayOutput + let entryCost = dayCostSeen ? dayCost : nil + return CostUsageDailyReport.Entry( + date: day, + inputTokens: dayInput, + outputTokens: dayOutput, + cacheReadTokens: dayCacheRead > 0 ? dayCacheRead : nil, + reasoningTokens: dayReasoning > 0 ? dayReasoning : nil, + totalTokens: dayTotal, + costUSD: entryCost, + modelsUsed: modelNames, + modelBreakdowns: Self.sortedModelBreakdowns(breakdown), + unpricedRequestCount: entryCost == nil && dayTotal > 0 ? 1 : nil, + unmeteredRequestCount: unmetered > 0 ? unmetered : nil) + } +} + +extension CostUsageFileUsage { + func touchesCodexScanWindow( + sinceKey: String, + untilKey: String, + calendar: Calendar = CostUsageScanner.CostUsageDayRange.localGregorianCalendar()) -> Bool + { + if self.days.keys.contains(where: { + CostUsageScanner.CostUsageDayRange.isInRange(dayKey: $0, since: sinceKey, until: untilKey) + }) { + return true + } + + // Missing-parent forks keep empty billed days on purpose. Session timestamps still + // place them in the scan window so force-rescan prune cannot drop the unmetered gap. + let isIncompleteFork = self.codexBufferedUnresolvedForkLines != nil + || CostUsageScanner.isUnresolvedMissingParentFork(self) + guard isIncompleteFork else { return false } + + if let unixMs = self.codexSession?.startedAtUnixMs ?? self.codexSession?.latestActivityUnixMs { + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey( + from: Date(timeIntervalSince1970: TimeInterval(unixMs) / 1000), + calendar: calendar) + return CostUsageScanner.CostUsageDayRange.isInRange( + dayKey: dayKey, + since: sinceKey, + until: untilKey) + } + return true + } +} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+PricingRows.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+PricingRows.swift index 3cd0c80980..fd83dcc51c 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+PricingRows.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+PricingRows.swift @@ -5,7 +5,8 @@ extension CostUsageScanner { for row: CodexUsageRow, priorityTurns: [String: CodexPriorityTurnMetadata] = [:], modelsDevCatalog: ModelsDevCatalog?, - modelsDevCacheRoot: URL?) -> Double? + modelsDevCacheRoot: URL?, + customPricing: CostUsageCustomPricing? = nil) -> Double? { if let authoritativeCostNanos = row.knownCostNanos { return Double(authoritativeCostNanos) / self.costScale @@ -15,13 +16,15 @@ extension CostUsageScanner { let pricedModel = priorityMetadata.map { self.codexPriorityPricingModel(for: row, priorityMetadata: $0) } ?? row.pricingModel ?? row.model + let overlay = customPricing ?? .empty let baseCost = CostUsagePricing.codexCostUSD( model: pricedModel, inputTokens: row.input, cachedInputTokens: row.cached, outputTokens: row.output, modelsDevCatalog: modelsDevCatalog, - modelsDevCacheRoot: modelsDevCacheRoot) + modelsDevCacheRoot: modelsDevCacheRoot, + customPricing: overlay) guard isPriority else { return baseCost } guard let priorityCost = CostUsagePricing.codexPriorityCostUSD( model: pricedModel, @@ -29,7 +32,8 @@ extension CostUsageScanner { cachedInputTokens: row.cached, outputTokens: row.output, modelsDevCatalog: modelsDevCatalog, - modelsDevCacheRoot: modelsDevCacheRoot) + modelsDevCacheRoot: modelsDevCacheRoot, + customPricing: overlay) else { return baseCost } return max(priorityCost, baseCost ?? priorityCost) } @@ -38,13 +42,15 @@ extension CostUsageScanner { for row: CodexUsageRow, priorityTurns: [String: CodexPriorityTurnMetadata] = [:], modelsDevCatalog: ModelsDevCatalog?, - modelsDevCacheRoot: URL?) -> Int64? + modelsDevCacheRoot: URL?, + customPricing: CostUsageCustomPricing? = nil) -> Int64? { guard let cost = self.codexResolvedCostUSD( for: row, priorityTurns: priorityTurns, modelsDevCatalog: modelsDevCatalog, - modelsDevCacheRoot: modelsDevCacheRoot) + modelsDevCacheRoot: modelsDevCacheRoot, + customPricing: customPricing) else { return nil } let nanos = cost * self.costScale guard nanos.isFinite, nanos >= Double(Int64.min), nanos <= Double(Int64.max) else { return nil } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Projects.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Projects.swift index 485785ede5..f144ca147b 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Projects.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Projects.swift @@ -35,7 +35,11 @@ extension CostUsageScanner { { continue } - guard usage.touchesCodexScanWindow(sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) else { + guard usage.touchesCodexScanWindow( + sinceKey: range.scanSinceKey, + untilKey: range.scanUntilKey, + calendar: range.calendar) + else { continue } let sessionID = usage.sessionId ?? URL(fileURLWithPath: filePath).deletingPathExtension().lastPathComponent @@ -96,7 +100,11 @@ extension CostUsageScanner { let projectPathResolver = CodexCanonicalProjectPathResolver() var accumulatorsByProjectPath: [String: CodexProjectBreakdownAccumulator] = [:] for (filePath, usage) in cache.files { - guard usage.touchesCodexScanWindow(sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) else { + guard usage.touchesCodexScanWindow( + sinceKey: range.scanSinceKey, + untilKey: range.scanUntilKey, + calendar: range.calendar) + else { continue } var fileCache = CostUsageCache() diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+ReportReconciliation.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+ReportReconciliation.swift index a55e91667c..8614b0019d 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+ReportReconciliation.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+ReportReconciliation.swift @@ -103,7 +103,8 @@ extension CostUsageScanner { range: CostUsageDayRange, priorityTurns: [String: CodexPriorityTurnMetadata], modelsDevCatalog: ModelsDevCatalog?, - modelsDevCacheRoot: URL?) -> Set + modelsDevCacheRoot: URL?, + customPricing: CostUsageCustomPricing? = nil) -> Set { let rowsByGroup = Dictionary(grouping: usage.codexRows ?? []) { CodexDayModelKey(day: $0.day, model: $0.model) @@ -118,7 +119,8 @@ extension CostUsageScanner { rows: rows, priorityTurns: priorityTurns, modelsDevCatalog: modelsDevCatalog, - modelsDevCacheRoot: modelsDevCacheRoot) + modelsDevCacheRoot: modelsDevCacheRoot, + customPricing: customPricing) return breakdown.hasIncompletePricing ? group : nil }) } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 744f2b7288..465c43cb06 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -5068,7 +5068,8 @@ enum CostUsageScanner { let turnIDCacheMigrationPathKeys = hasPriorityMetadata ? Set(cache.files.compactMap { path, usage in usage.codexTurnIDs == nil && usage.touchesCodexScanWindow( sinceKey: range.scanSinceKey, - untilKey: range.scanUntilKey) + untilKey: range.scanUntilKey, + calendar: range.calendar) ? Self.codexPathKey(URL(fileURLWithPath: path)) : nil }) : [] @@ -5531,7 +5532,10 @@ enum CostUsageScanner { { guard let old = cache.files[key] else { continue } let shouldDrop = shouldDropAllUnscannedFiles || - old.touchesCodexScanWindow(sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) + old.touchesCodexScanWindow( + sinceKey: range.scanSinceKey, + untilKey: range.scanUntilKey, + calendar: range.calendar) guard shouldDrop else { continue } Self.applyFileDays(cache: &cache, fileDays: old.days, sign: -1) cache.files.removeValue(forKey: key) @@ -5540,7 +5544,10 @@ enum CostUsageScanner { for key in cache.files.keys { guard !shouldDropAllUnscannedFiles else { break } guard let old = cache.files[key] else { continue } - guard old.touchesCodexScanWindow(sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) + guard old.touchesCodexScanWindow( + sinceKey: range.scanSinceKey, + untilKey: range.scanUntilKey, + calendar: range.calendar) else { continue } guard FileManager.default.fileExists(atPath: key) else { Self.applyFileDays(cache: &cache, fileDays: old.days, sign: -1) diff --git a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageAggregator.swift b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageAggregator.swift new file mode 100644 index 0000000000..308d18a2c2 --- /dev/null +++ b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageAggregator.swift @@ -0,0 +1,316 @@ +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 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] = [:] + } + + static func snapshot( + entries: [OpenCodexUsageEntry], + now: Date, + historyDays: Int, + calendar: Calendar, + customPricing: CostUsageCustomPricing = .empty) -> CostUsageTokenSnapshot + { + let days = max(1, min(365, historyDays)) + let today = calendar.startOfDay(for: now) + let windowStart = calendar.date(byAdding: .day, value: -(days - 1), to: today) ?? today + var unique: [String: OpenCodexUsageEntry] = [:] + for entry in entries { + unique[entry.requestID] = entry + } + let windowed = unique.values.filter { $0.timestamp >= windowStart && $0.timestamp <= now } + .sorted { lhs, rhs in + if lhs.timestamp != rhs.timestamp { + return lhs.timestamp < rhs.timestamp + } + return lhs.requestID < rhs.requestID + } + + var daysByKey: [String: DayAccumulator] = [:] + var sessions: [String: SessionAccumulator] = [:] + for entry in windowed { + let dayKey = CostUsageLocalDay.key(from: entry.timestamp, calendar: calendar) + var day = daysByKey[dayKey] ?? DayAccumulator() + Self.merge(entry, into: &day, customPricing: customPricing) + daysByKey[dayKey] = day + + let sessionID = entry.conversationID ?? entry.requestID + var session = sessions[sessionID] ?? SessionAccumulator() + session.lastActivity = max(session.lastActivity, entry.timestamp) + session.requests += 1 + Self.merge(entry, into: &session, customPricing: customPricing) + sessions[sessionID] = session + } + + let daily = daysByKey.keys.sorted().compactMap { key -> CostUsageDailyReport.Entry? in + guard let day = daysByKey[key] else { return nil } + return Self.entry(dayKey: key, day: day) + } + let sessionRows = sessions.keys.sorted().compactMap { key -> CostUsageSessionBreakdown? in + guard let session = sessions[key] else { return nil } + return CostUsageSessionBreakdown( + sessionID: key, + lastActivity: session.lastActivity, + inputTokens: session.input, + cachedInputTokens: session.cacheRead, + outputTokens: session.output, + reasoningTokens: session.reasoning, + totalTokens: session.tokens, + requestCount: session.requests, + costUSD: session.cost, + modelBreakdowns: Self.modelBreakdowns(session.models)) + } + .sorted { lhs, rhs in + if lhs.lastActivity != rhs.lastActivity { + return lhs.lastActivity > rhs.lastActivity + } + return lhs.sessionID < rhs.sessionID + } + + let todayEntry = CostUsageTokenSnapshot.entry( + in: daily, + forLocalDayContaining: now, + calendar: calendar) + let windowSummary = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: nil, + last30DaysCostUSD: nil, + historyDays: days, + daily: daily, + sessions: Array(sessionRows.prefix(64)), + updatedAt: now) + .summary(forLastDays: min(30, days), calendar: calendar) + + return CostUsageTokenSnapshot( + sessionTokens: todayEntry?.totalTokens ?? (daily.isEmpty ? nil : 0), + sessionCostUSD: todayEntry?.costUSD ?? (daily.isEmpty ? nil : 0), + sessionRequests: todayEntry?.requestCount ?? (daily.isEmpty ? nil : 0), + last30DaysTokens: windowSummary.totalTokens, + last30DaysCostUSD: windowSummary.totalCostUSD, + last30DaysRequests: windowSummary.totalRequests, + historyDays: days, + historyLabel: "OpenCodex usage.jsonl", + costProvenance: .listPriceEstimate, + daily: daily, + sessions: Array(sessionRows.prefix(64)), + updatedAt: now) + } + + private static func merge( + _ entry: OpenCodexUsageEntry, + into day: inout DayAccumulator, + customPricing: CostUsageCustomPricing) + { + 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.priced += entry.usageStatus == .reported ? 1 : 0 + day.estimated += entry.usageStatus == .estimated ? 1 : 0 + day.unmetered += entry.usageStatus == .unsupported ? 1 : 0 + day.unpriced += entry.usageStatus == .unreported ? 1 : 0 + + let cost = Self.listPriceUSD(entry: entry, customPricing: customPricing) + if let cost { + day.cost += cost + day.sawCost = true + } else if entry.usageStatus == .reported { + day.unpriced += 1 + if day.priced > 0 { day.priced -= 1 } + } else if entry.usageStatus == .estimated { + day.unpriced += 1 + if day.estimated > 0 { day.estimated -= 1 } + } + + var model = day.models[entry.model] ?? ModelAccumulator() + Self.merge(entry, cost: cost, into: &model) + day.models[entry.model] = model + } + + private static func merge( + _ entry: OpenCodexUsageEntry, + into session: inout SessionAccumulator, + customPricing: CostUsageCustomPricing) + { + 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) + let cost = self.listPriceUSD(entry: entry, customPricing: customPricing) + session.cost = self.add(session.cost, cost) + var model = session.models[entry.model] ?? ModelAccumulator() + self.merge(entry, cost: cost, into: &model) + session.models[entry.model] = model + } + + private static func merge( + _ entry: OpenCodexUsageEntry, + 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 + } + if let cost { + model.cost += cost + model.sawCost = true + } + } + + 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, + requestCount: day.priced + day.unpriced + day.unmetered + day.estimated, + costUSD: day.sawCost ? day.cost : nil, + modelsUsed: day.models.keys.sorted(), + modelBreakdowns: self.modelBreakdowns(day.models), + unpricedRequestCount: day.unpriced, + unmeteredRequestCount: day.unmetered, + estimatedRequestCount: day.estimated) + } + + private static func modelBreakdowns(_ models: [String: ModelAccumulator]) -> [CostUsageDailyReport.ModelBreakdown] { + models.keys.sorted().map { name in + let model = models[name] ?? ModelAccumulator() + 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) + } + } + + private static func listPriceUSD( + entry: OpenCodexUsageEntry, + customPricing: CostUsageCustomPricing) -> Double? + { + guard entry.usageStatus == .reported || entry.usageStatus == .estimated else { return nil } + let usage = entry.usage + let hasTokenData = entry.resolvedTotalTokens != nil + || usage?.inputTokens != nil + || usage?.outputTokens != nil + || usage?.cacheReadTokens != nil + || usage?.cacheCreationInputTokens != nil + guard hasTokenData else { return nil } + let input = usage?.inputTokens ?? 0 + let output = usage?.outputTokens ?? 0 + let cacheRead = usage?.cacheReadTokens ?? 0 + let cacheWrite = usage?.cacheCreationInputTokens ?? 0 + if let overlay = customPricing.costUSD( + providerID: entry.provider, + model: entry.model, + inputTokens: input, + outputTokens: output, + cacheReadTokens: cacheRead, + cacheWriteTokens: cacheWrite) + { + return overlay + } + return CostUsagePricing.codexCostUSD( + model: entry.model, + inputTokens: input, + cachedInputTokens: cacheRead, + outputTokens: output, + cacheWriteInputTokens: cacheWrite) + } + + 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 + case let (left?, nil): left + case let (nil, right?): right + case (nil, nil): nil + } + } +} diff --git a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageModels.swift b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageModels.swift new file mode 100644 index 0000000000..4bfcab7bd8 --- /dev/null +++ b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageModels.swift @@ -0,0 +1,172 @@ +import Foundation + +public enum OpenCodexUsageStatus: String, Sendable, Equatable, Codable { + case reported + case estimated + case unreported + case unsupported +} + +public struct OpenCodexTokenUsage: Sendable, Equatable { + public var inputTokens: Int? + public var outputTokens: Int? + public var cachedInputTokens: Int? + public var cacheReadInputTokens: Int? + public var cacheCreationInputTokens: Int? + public var reasoningOutputTokens: Int? + public var totalTokens: Int? + + public init( + inputTokens: Int? = nil, + outputTokens: Int? = nil, + cachedInputTokens: Int? = nil, + cacheReadInputTokens: Int? = nil, + cacheCreationInputTokens: Int? = nil, + reasoningOutputTokens: Int? = nil, + totalTokens: Int? = nil) + { + self.inputTokens = Self.nonnegative(inputTokens) + self.outputTokens = Self.nonnegative(outputTokens) + self.cachedInputTokens = Self.nonnegative(cachedInputTokens) + self.cacheReadInputTokens = Self.nonnegative(cacheReadInputTokens) + self.cacheCreationInputTokens = Self.nonnegative(cacheCreationInputTokens) + self.reasoningOutputTokens = Self.nonnegative(reasoningOutputTokens) + self.totalTokens = Self.nonnegative(totalTokens) + } + + public var cacheReadTokens: Int? { + self.cacheReadInputTokens ?? self.cachedInputTokens + } + + public var resolvedTotalTokens: Int? { + if let totalTokens { + return totalTokens + } + let parts = [ + self.inputTokens, + self.outputTokens, + self.cacheReadTokens, + self.cacheCreationInputTokens, + ].compactMap(\.self) + guard !parts.isEmpty else { return nil } + return parts.reduce(0, +) + } + + private static func nonnegative(_ value: Int?) -> Int? { + guard let value, value >= 0 else { return nil } + return value + } +} + +public struct OpenCodexUsageEntry: Sendable, Equatable { + public let requestID: String + public let timestamp: Date + public let provider: String + public let model: String + public let usageStatus: OpenCodexUsageStatus + public let accountLogLabel: String? + public let surface: String? + public let conversationID: String? + public let usage: OpenCodexTokenUsage? + public let totalTokens: Int? + + public init( + requestID: String, + timestamp: Date, + provider: String, + model: String, + usageStatus: OpenCodexUsageStatus, + accountLogLabel: String? = nil, + surface: String? = nil, + conversationID: String? = nil, + usage: OpenCodexTokenUsage? = nil, + totalTokens: Int? = nil) + { + self.requestID = requestID + self.timestamp = timestamp + self.provider = provider + self.model = model + self.usageStatus = usageStatus + self.accountLogLabel = Self.normalizedAccountLogLabel(accountLogLabel) + self.surface = surface + self.conversationID = conversationID + self.usage = usage + self.totalTokens = totalTokens + } + + public var resolvedTotalTokens: Int? { + self.totalTokens ?? self.usage?.resolvedTotalTokens + } + + public var displayAccountLabel: String { + self.accountLogLabel ?? "main" + } + + static func normalizedAccountLogLabel(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed == "main" { return "main" } + guard trimmed.count >= 2, trimmed.first == "p" else { return nil } + let digits = trimmed.dropFirst() + guard !digits.isEmpty, digits.allSatisfy(\.isNumber) else { return nil } + return trimmed + } +} + +public enum OpenCodexUsageLog { + public static let sourceID = "opencodex" + public static let displayName = "OpenCodex" + + public static func usageLogURL( + environment: [String: String] = ProcessInfo.processInfo.environment, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) -> URL? + { + if let override = environment["OPENCODEX_HOME"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !override.isEmpty + { + return URL(fileURLWithPath: override, isDirectory: true) + .appendingPathComponent("usage.jsonl", isDirectory: false) + } + if Self.isRunningTests(environment) || Self.isRunningTests(ProcessInfo.processInfo.environment) { + return nil + } + return homeDirectory + .appendingPathComponent(".opencodex", isDirectory: true) + .appendingPathComponent("usage.jsonl", isDirectory: false) + } + + public static func cacheRoot( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default) -> URL + { + if let override = environment["OPENCODEX_HOME"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !override.isEmpty + { + return URL(fileURLWithPath: override, isDirectory: true) + .appendingPathComponent("codexbar-cache", isDirectory: true) + } + return AppGroupSupport.localFallbackDirectory(fileManager: fileManager) + .appendingPathComponent("OpenCodexUsage", isDirectory: true) + } + + private static func isRunningTests(_ environment: [String: String]) -> Bool { + let keys = [ + "XCTestConfigurationFilePath", + "XCTestBundlePath", + "XCTestSessionIdentifier", + "SWIFT_TESTING_ENABLED", + "TESTING_LIBRARY_VERSION", + "SWIFT_TESTING", + ] + if keys.contains(where: { environment[$0] != nil }) { + return true + } + if keys.contains(where: { ProcessInfo.processInfo.environment[$0] != nil }) { + return true + } + if NSClassFromString("XCTestCase") != nil { + return true + } + return Bundle.allBundles.contains { $0.bundlePath.hasSuffix(".xctest") } + } +} diff --git a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageParser.swift b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageParser.swift new file mode 100644 index 0000000000..cff9b00742 --- /dev/null +++ b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageParser.swift @@ -0,0 +1,128 @@ +import Foundation + +public enum OpenCodexUsageParser { + public static func parseLine(_ line: String) -> OpenCodexUsageEntry? { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, let data = trimmed.data(using: .utf8) else { return nil } + return self.parse(data) + } + + public static func parse(_ data: Data) -> OpenCodexUsageEntry? { + guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + return self.parse(object) + } + + public static func parseLines(_ text: String) -> [OpenCodexUsageEntry] { + text.split(whereSeparator: \.isNewline).compactMap { self.parseLine(String($0)) } + } + + public static func parse(fileURL: URL, fileManager: FileManager = .default) throws -> [OpenCodexUsageEntry] { + guard fileManager.fileExists(atPath: fileURL.path) else { return [] } + let data = try Data(contentsOf: fileURL) + guard let text = String(data: data, encoding: .utf8) else { return [] } + return self.parseLines(text) + } + + private static func parse(_ object: [String: Any]) -> OpenCodexUsageEntry? { + guard let requestID = self.nonEmptyString(object["requestId"]), + let timestamp = self.timestamp(object["timestamp"]), + let provider = self.nonEmptyString(object["provider"]), + let model = self.nonEmptyString(object["model"]) + else { return nil } + let status = self.usageStatus(object["usageStatus"]) + let usage = self.usage(object["usage"]) + return OpenCodexUsageEntry( + requestID: requestID, + timestamp: timestamp, + provider: provider, + model: model, + usageStatus: status, + accountLogLabel: self.nonEmptyString(object["accountLogLabel"]), + surface: self.nonEmptyString(object["surface"]), + conversationID: self.nonEmptyString(object["conversationId"]), + usage: usage, + totalTokens: self.nonnegativeInt(object["totalTokens"])) + } + + private static func usageStatus(_ value: Any?) -> OpenCodexUsageStatus { + guard let raw = self.nonEmptyString(value), + let status = OpenCodexUsageStatus(rawValue: raw) + else { return .unreported } + return status + } + + private static func usage(_ value: Any?) -> OpenCodexTokenUsage? { + guard let object = value as? [String: Any] else { return nil } + let parsed = OpenCodexTokenUsage( + inputTokens: self.nonnegativeInt(object["inputTokens"]), + outputTokens: self.nonnegativeInt(object["outputTokens"]), + cachedInputTokens: self.nonnegativeInt(object["cachedInputTokens"]), + cacheReadInputTokens: self.nonnegativeInt(object["cacheReadInputTokens"]), + cacheCreationInputTokens: self.nonnegativeInt(object["cacheCreationInputTokens"]), + reasoningOutputTokens: self.nonnegativeInt(object["reasoningOutputTokens"]), + totalTokens: self.nonnegativeInt(object["totalTokens"])) + if parsed.inputTokens == nil, + parsed.outputTokens == nil, + parsed.cachedInputTokens == nil, + parsed.cacheReadInputTokens == nil, + parsed.cacheCreationInputTokens == nil, + parsed.reasoningOutputTokens == nil, + parsed.totalTokens == nil + { + return nil + } + return parsed + } + + private static func timestamp(_ value: Any?) -> Date? { + if let number = value as? Double { + return self.date(fromEpoch: number) + } + if let number = value as? Int { + return self.date(fromEpoch: Double(number)) + } + if let number = value as? NSNumber { + return self.date(fromEpoch: number.doubleValue) + } + if let raw = value as? String { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if let number = Double(trimmed) { + return self.date(fromEpoch: number) + } + return CostUsageDateParser.parse(trimmed) + } + return nil + } + + private static func date(fromEpoch value: Double) -> Date? { + guard value.isFinite, value > 0 else { return nil } + let seconds = value >= 1_000_000_000_000 ? value / 1000 : value + return Date(timeIntervalSince1970: seconds) + } + + private static func nonEmptyString(_ value: Any?) -> String? { + guard let value else { return nil } + if let string = value as? String { + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + return nil + } + + private static func nonnegativeInt(_ value: Any?) -> Int? { + guard let value else { return nil } + if let number = value as? Int { + return number >= 0 ? number : nil + } + if let number = value as? Double, number.isFinite, number >= 0, number <= Double(Int.max) { + return Int(number) + } + if let number = value as? NSNumber { + let intValue = number.intValue + return intValue >= 0 ? intValue : nil + } + return nil + } +} diff --git a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageStore.swift b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageStore.swift new file mode 100644 index 0000000000..fac7a895ac --- /dev/null +++ b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageStore.swift @@ -0,0 +1,263 @@ +#if canImport(SQLite3) +import SQLite3 +#elseif canImport(CSQLite3) +import CSQLite3 +#endif +import Foundation + +/// Independent OpenCodex usage cache. Never writes Codex `cost-usage.sqlite`. +public struct OpenCodexUsageStore: Sendable { + public static let databaseFilename = "opencodex-usage.sqlite" + private static let schemaVersion = 1 + + private let databaseURL: URL + + public init(cacheRoot: URL) { + self.databaseURL = cacheRoot.appendingPathComponent(Self.databaseFilename, isDirectory: false) + } + + public func loadSnapshot( + logURL: URL, + now: Date, + historyDays: Int, + calendar: Calendar, + customPricing: CostUsageCustomPricing = .empty, + fileManager: FileManager = .default) throws -> CostUsageTokenSnapshot + { + let entries = try self.loadEntries(logURL: logURL, fileManager: fileManager) + return OpenCodexUsageAggregator.snapshot( + entries: entries, + now: now, + historyDays: historyDays, + calendar: calendar, + customPricing: customPricing) + } + + func loadEntries(logURL: URL, fileManager: FileManager) throws -> [OpenCodexUsageEntry] { + guard fileManager.fileExists(atPath: logURL.path) else { return [] } + let attributes = try fileManager.attributesOfItem(atPath: logURL.path) + let size = (attributes[.size] as? NSNumber)?.int64Value ?? 0 + let mtime = (attributes[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0 + let identity = "\(logURL.path)|\(size)|\(mtime)" + + if let cached = self.readCachedEntries(identity: identity), !cached.isEmpty { + return cached + } + + let parsed = try OpenCodexUsageParser.parse(fileURL: logURL, fileManager: fileManager) + var unique: [String: OpenCodexUsageEntry] = [:] + for entry in parsed { + unique[entry.requestID] = entry + } + let deduped = unique.values.sorted { + if $0.timestamp != $1.timestamp { return $0.timestamp < $1.timestamp } + return $0.requestID < $1.requestID + } + self.replaceCachedEntries(deduped, identity: identity) + return deduped + } + + private func readCachedEntries(identity: String) -> [OpenCodexUsageEntry]? { + guard let db = self.open(readOnly: true) else { return nil } + defer { sqlite3_close(db) } + guard Self.userVersion(db) == Self.schemaVersion, + Self.meta(db, key: "identity") == identity + else { return nil } + var statement: OpaquePointer? + let sql = """ + SELECT request_id, timestamp, provider, model, usage_status, account_label, surface, conversation_id, payload + FROM entries + """ + guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { return nil } + defer { sqlite3_finalize(statement) } + var entries: [OpenCodexUsageEntry] = [] + while sqlite3_step(statement) == SQLITE_ROW { + guard let payload = Self.text(statement, 8), + let data = payload.data(using: .utf8), + let entry = OpenCodexUsageParser.parse(data) + else { continue } + entries.append(entry) + } + return entries + } + + private func replaceCachedEntries(_ entries: [OpenCodexUsageEntry], identity: String) { + guard let db = self.open(readOnly: false) else { return } + defer { sqlite3_close(db) } + _ = sqlite3_exec(db, "BEGIN IMMEDIATE", nil, nil, nil) + _ = sqlite3_exec(db, "DELETE FROM entries", nil, nil, nil) + Self.setMeta(db, key: "identity", value: identity) + var statement: OpaquePointer? + let sql = """ + INSERT OR REPLACE INTO entries( + request_id, timestamp, provider, model, usage_status, account_label, surface, conversation_id, payload + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """ + guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { + _ = sqlite3_exec(db, "ROLLBACK", nil, nil, nil) + return + } + defer { sqlite3_finalize(statement) } + for entry in entries { + sqlite3_reset(statement) + sqlite3_clear_bindings(statement) + Self.bind(statement, 1, entry.requestID) + sqlite3_bind_double(statement, 2, entry.timestamp.timeIntervalSince1970) + Self.bind(statement, 3, entry.provider) + Self.bind(statement, 4, entry.model) + Self.bind(statement, 5, entry.usageStatus.rawValue) + Self.bind(statement, 6, entry.accountLogLabel) + Self.bind(statement, 7, entry.surface) + Self.bind(statement, 8, entry.conversationID) + let payload = Self.payloadJSON(entry) + Self.bind(statement, 9, payload) + guard sqlite3_step(statement) == SQLITE_DONE else { + _ = sqlite3_exec(db, "ROLLBACK", nil, nil, nil) + return + } + } + _ = sqlite3_exec(db, "COMMIT", nil, nil, nil) + } + + private func open(readOnly: Bool) -> OpaquePointer? { + if !readOnly { + try? FileManager.default.createDirectory( + at: self.databaseURL.deletingLastPathComponent(), + withIntermediateDirectories: true) + } + var db: OpaquePointer? + let flags = readOnly + ? SQLITE_OPEN_READONLY + : SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE + guard sqlite3_open_v2(self.databaseURL.path, &db, flags, nil) == SQLITE_OK else { + sqlite3_close(db) + return nil + } + sqlite3_busy_timeout(db, 250) + if !readOnly { + _ = sqlite3_exec(db, "PRAGMA journal_mode = WAL", nil, nil, nil) + _ = sqlite3_exec(db, "PRAGMA synchronous = NORMAL", nil, nil, nil) + Self.ensureSchema(db) + } + return db + } + + private static func ensureSchema(_ db: OpaquePointer?) { + guard self.userVersion(db) == 0 else { return } + let sql = """ + CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS entries ( + request_id TEXT PRIMARY KEY, + timestamp REAL NOT NULL, + provider TEXT NOT NULL, + model TEXT NOT NULL, + usage_status TEXT NOT NULL, + account_label TEXT, + surface TEXT, + conversation_id TEXT, + payload TEXT NOT NULL + ); + """ + guard sqlite3_exec(db, sql, nil, nil, nil) == SQLITE_OK else { return } + Self.setUserVersion(db, Self.schemaVersion) + } + + private static func userVersion(_ db: OpaquePointer?) -> Int { + var statement: OpaquePointer? + guard sqlite3_prepare_v2(db, "PRAGMA user_version", -1, &statement, nil) == SQLITE_OK else { return 0 } + defer { sqlite3_finalize(statement) } + guard sqlite3_step(statement) == SQLITE_ROW else { return 0 } + return Int(sqlite3_column_int(statement, 0)) + } + + private static func setUserVersion(_ db: OpaquePointer?, _ version: Int) { + _ = sqlite3_exec(db, "PRAGMA user_version = \(version)", nil, nil, nil) + } + + private static func meta(_ db: OpaquePointer?, key: String) -> String? { + var statement: OpaquePointer? + guard sqlite3_prepare_v2(db, "SELECT value FROM meta WHERE key = ?", -1, &statement, nil) == SQLITE_OK else { + return nil + } + defer { sqlite3_finalize(statement) } + Self.bind(statement, 1, key) + guard sqlite3_step(statement) == SQLITE_ROW else { return nil } + return Self.text(statement, 0) + } + + private static func setMeta(_ db: OpaquePointer?, key: String, value: String) { + var statement: OpaquePointer? + guard sqlite3_prepare_v2( + db, + "INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", + -1, + &statement, + nil) == SQLITE_OK + else { return } + defer { sqlite3_finalize(statement) } + Self.bind(statement, 1, key) + Self.bind(statement, 2, value) + _ = sqlite3_step(statement) + } + + private static func bind(_ statement: OpaquePointer?, _ index: Int32, _ value: String?) { + guard let value else { + sqlite3_bind_null(statement, index) + return + } + sqlite3_bind_text(statement, index, value, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self)) + } + + private static func text(_ statement: OpaquePointer?, _ index: Int32) -> String? { + guard let pointer = sqlite3_column_text(statement, index) else { return nil } + return String(cString: pointer) + } + + private static func payloadJSON(_ entry: OpenCodexUsageEntry) -> String { + var object: [String: Any] = [ + "requestId": entry.requestID, + "timestamp": entry.timestamp.timeIntervalSince1970 * 1000, + "provider": entry.provider, + "model": entry.model, + "usageStatus": entry.usageStatus.rawValue, + ] + if let accountLogLabel = entry.accountLogLabel { + object["accountLogLabel"] = accountLogLabel + } + if let surface = entry.surface { + object["surface"] = surface + } + if let conversationID = entry.conversationID { + object["conversationId"] = conversationID + } + if let totalTokens = entry.totalTokens { + object["totalTokens"] = totalTokens + } + if let usage = entry.usage { + var usageObject: [String: Any] = [:] + if let inputTokens = usage.inputTokens { usageObject["inputTokens"] = inputTokens } + if let outputTokens = usage.outputTokens { usageObject["outputTokens"] = outputTokens } + if let cachedInputTokens = usage.cachedInputTokens { + usageObject["cachedInputTokens"] = cachedInputTokens + } + if let cacheReadInputTokens = usage.cacheReadInputTokens { + usageObject["cacheReadInputTokens"] = cacheReadInputTokens + } + if let cacheCreationInputTokens = usage.cacheCreationInputTokens { + usageObject["cacheCreationInputTokens"] = cacheCreationInputTokens + } + if let reasoningOutputTokens = usage.reasoningOutputTokens { + usageObject["reasoningOutputTokens"] = reasoningOutputTokens + } + if let totalTokens = usage.totalTokens { usageObject["totalTokens"] = totalTokens } + object["usage"] = usageObject + } + guard let data = try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]), + let text = String(data: data, encoding: .utf8) + else { return "{}" } + return text + } +} diff --git a/Tests/CodexBarTests/CostProvenanceTests.swift b/Tests/CodexBarTests/CostProvenanceTests.swift new file mode 100644 index 0000000000..fed5ffc810 --- /dev/null +++ b/Tests/CodexBarTests/CostProvenanceTests.swift @@ -0,0 +1,180 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CostProvenanceTests { + @Test + func `cost figures are never billing receipts`() { + for provenance in [CostProvenance.listPriceEstimate, .vendorMetered, .mixed, .unknown] { + #expect(!provenance.isBillingReceipt) + } + } + + @Test + func `coverage ratio ignores missing categories instead of collapsing them`() { + let empty = CostUsageCoverageCounts() + #expect(empty.coverageRatio == nil) + + let mixed = CostUsageCoverageCounts(priced: 2, unpriced: 1, unmetered: 1, estimated: 2) + #expect(mixed.total == 6) + #expect(mixed.coverageRatio == 4.0 / 6.0) + } + + @Test + func `token mix keeps nil distinct from zero`() { + var mix = CostUsageTokenMix(inputTokens: 10, outputTokens: nil) + #expect(mix.inputTokens == 10) + #expect(mix.outputTokens == nil) + mix.merge(CostUsageTokenMix(outputTokens: 4, reasoningTokens: 0)) + #expect(mix.outputTokens == 4) + #expect(mix.reasoningTokens == 0) + mix.merge(CostUsageTokenMix(inputTokens: 5)) + #expect(mix.inputTokens == 15) + #expect(mix.cacheReadTokens == nil) + } + + @Test + func `day rows without request counts still expose priced or unpriced coverage`() { + let priced = CostUsageDailyReport.Entry( + date: "2026-07-16", + inputTokens: 10, + outputTokens: 2, + totalTokens: 12, + costUSD: 1.25, + modelsUsed: nil, + modelBreakdowns: nil) + #expect(priced.coverageCounts == CostUsageCoverageCounts(priced: 1)) + + let unpriced = CostUsageDailyReport.Entry( + date: "2026-07-16", + inputTokens: 10, + outputTokens: 2, + totalTokens: 12, + costUSD: nil, + modelsUsed: nil, + modelBreakdowns: nil) + #expect(unpriced.coverageCounts == CostUsageCoverageCounts(unpriced: 1)) + + let unmetered = CostUsageDailyReport.Entry( + date: "2026-07-16", + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + costUSD: nil, + modelsUsed: nil, + modelBreakdowns: nil, + unmeteredRequestCount: 2) + #expect(unmetered.coverageCounts == CostUsageCoverageCounts(unmetered: 2)) + } + + @Test + func `vendor reported snapshots stay vendor metered without meteredCostUSD`() throws { + let snapshot = CostUsageTokenSnapshot( + sessionTokens: 10, + sessionCostUSD: 1.25, + last30DaysTokens: 10, + last30DaysCostUSD: 1.25, + historyDays: 30, + costProvenance: .vendorMetered, + daily: [ + CostUsageDailyReport.Entry( + date: "2026-07-01", + inputTokens: 8, + outputTokens: 2, + totalTokens: 10, + costUSD: 1.25, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: Date(timeIntervalSince1970: 1_782_864_000)) + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let summary = snapshot.summary(forLastDays: 7, calendar: calendar) + #expect(summary.provenance == .vendorMetered) + #expect(summary.totalCostUSD == 1.25) + #expect(summary.meteredCostUSD == nil) + } + + @Test + func `shorter summaries omit snapshot-wide metered spend`() throws { + let snapshot = CostUsageTokenSnapshot( + sessionTokens: 10, + sessionCostUSD: 1, + last30DaysTokens: 100, + last30DaysCostUSD: 10, + historyDays: 30, + meteredCostUSD: 4.5, + costProvenance: .mixed, + daily: [ + CostUsageDailyReport.Entry( + date: "2026-07-01", + inputTokens: 8, + outputTokens: 2, + totalTokens: 10, + costUSD: 1, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: Date(timeIntervalSince1970: 1_782_864_000)) + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let week = snapshot.summary(forLastDays: 7, calendar: calendar) + let month = snapshot.summary(forLastDays: 30, calendar: calendar) + #expect(week.meteredCostUSD == nil) + #expect(week.provenance == .listPriceEstimate) + #expect(month.meteredCostUSD == 4.5) + #expect(month.provenance == .mixed) + } + + @Test + func `cached previous reports round-trip coverage counters`() throws { + let entry = CostUsageDailyReport.Entry( + date: "2026-07-16", + inputTokens: 10, + outputTokens: 2, + totalTokens: 12, + costUSD: nil, + modelsUsed: nil, + modelBreakdowns: nil, + unpricedRequestCount: 1, + unmeteredRequestCount: 2, + estimatedRequestCount: 3) + let cached = CostUsageCodexPreviousReport.Entry(entry) + let restored = cached.dailyReportValue + #expect(restored.unpricedRequestCount == 1) + #expect(restored.unmeteredRequestCount == 2) + #expect(restored.estimatedRequestCount == 3) + let data = try JSONEncoder().encode(cached) + let decoded = try JSONDecoder().decode(CostUsageCodexPreviousReport.Entry.self, from: data) + #expect(decoded.dailyReportValue.unmeteredRequestCount == 2) + #expect(decoded.dailyReportValue.estimatedRequestCount == 3) + } +} + +struct CostUsageBucketTimeZoneTests { + @Test + func `pins a valid IANA identifier and rejects junk`() { + #expect(CostUsageBucketTimeZone.isValidIdentifier("America/Los_Angeles")) + #expect(!CostUsageBucketTimeZone.isValidIdentifier("Not/AZone")) + let calendar = CostUsageBucketTimeZone.calendar(identifier: "America/Los_Angeles") + #expect(calendar.timeZone.identifier == "America/Los_Angeles") + #expect(calendar.identifier == .gregorian) + } + + @Test + func `a pinned zone keeps midnight-adjacent events on the same local day`() throws { + let timestamp = "2026-07-16T06:30:00Z" + var losAngeles = Calendar(identifier: .gregorian) + losAngeles.timeZone = try #require(TimeZone(identifier: "America/Los_Angeles")) + var shanghai = Calendar(identifier: .gregorian) + shanghai.timeZone = try #require(TimeZone(identifier: "Asia/Shanghai")) + let westKey = try #require(CostUsageScanner.dayKeyFromTimestamp(timestamp, calendar: losAngeles)) + let eastKey = try #require(CostUsageScanner.dayKeyFromTimestamp(timestamp, calendar: shanghai)) + #expect(westKey == "2026-07-15") + #expect(eastKey == "2026-07-16") + + let pinned = CostUsageBucketTimeZone.calendar(identifier: "America/Los_Angeles") + #expect(CostUsageScanner.dayKeyFromTimestamp(timestamp, calendar: pinned) == westKey) + #expect(CostUsageScanner.dayKeyFromTimestamp(timestamp, calendar: pinned) != eastKey) + } +} diff --git a/Tests/CodexBarTests/CostUsageCustomPricingTests.swift b/Tests/CodexBarTests/CostUsageCustomPricingTests.swift new file mode 100644 index 0000000000..b492ebcbfe --- /dev/null +++ b/Tests/CodexBarTests/CostUsageCustomPricingTests.swift @@ -0,0 +1,68 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CostUsageCustomPricingTests { + @Test + func `overlay exact match uses per-million rates and treats zero as free`() throws { + let pricing = CostUsageCustomPricing.parse(Data(""" + { + "openai/gpt-5.4": { "input": 2.5, "output": 15, "cacheRead": 0, "cacheWrite": 3.125 } + } + """.utf8)) + let cost = try #require(pricing.costUSD( + providerID: "openai", + model: "gpt-5.4", + inputTokens: 1_000_000, + outputTokens: 1_000_000, + cacheReadTokens: 1_000_000, + cacheWriteTokens: 1_000_000)) + #expect(abs(cost - (2.5 + 15 + 0 + 3.125)) < 0.000_001) + } + + @Test + func `missing overlay fields stay unknown instead of falling through`() { + let pricing = CostUsageCustomPricing.parse(Data(""" + { "gpt-5.4": { "input": 2.5 } } + """.utf8)) + #expect(pricing.costUSD(model: "gpt-5.4", inputTokens: 100, outputTokens: 10) == nil) + #expect(pricing.costUSD(model: "gpt-5.4", inputTokens: 100, outputTokens: 0) == 100 * 2.5 / 1_000_000) + #expect(pricing.rates(model: "other-model") == nil) + } + + @Test + func `codex cost prefers overlay over bundled list prices`() { + let overlay = CostUsageCustomPricing.parse(Data(""" + { "gpt-5.4": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 } } + """.utf8)) + let cost = CostUsagePricing.codexCostUSD( + model: "gpt-5.4", + inputTokens: 1000, + cachedInputTokens: 0, + outputTokens: 100, + customPricing: overlay) + #expect(cost == 0) + } + + @Test + func `aggregate fallback consults the overlay before bundled rates`() { + let overlay = CostUsageCustomPricing.parse(Data(""" + { "gpt-5.4": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 } } + """.utf8)) + let cost = CostUsagePricing.codexAggregateCostUSD( + model: "gpt-5.4", + inputTokens: 1000, + cachedInputTokens: 0, + outputTokens: 100, + customPricing: overlay) + #expect(cost == 0) + let bundled = CostUsagePricing.codexAggregateCostUSD( + model: "gpt-5.4", + inputTokens: 1000, + cachedInputTokens: 0, + outputTokens: 100, + customPricing: .empty) + #expect(bundled != 0) + #expect(bundled != nil) + } +} diff --git a/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift b/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift index 68487759b4..e12cf0d19e 100644 --- a/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift @@ -653,6 +653,55 @@ struct CostUsageFetcherCacheSnapshotTests { #expect(cached?.last30DaysTokens == 165) } + @Test + func `cached snapshot reads keep the pinned timezone instead of the current zone`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + var losAngeles = Calendar(identifier: .gregorian) + losAngeles.timeZone = try #require(TimeZone(identifier: "America/Los_Angeles")) + var shanghai = Calendar(identifier: .gregorian) + shanghai.timeZone = try #require(TimeZone(identifier: "Asia/Shanghai")) + let day = try #require(losAngeles.date(from: DateComponents( + timeZone: losAngeles.timeZone, + year: 2026, + month: 4, + day: 8, + hour: 12))) + try Self.writeCodexSessionFile( + homeRoot: env.codexHomeRoot, + env: env, + day: day, + filename: "cached.jsonl", + tokens: 42) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + options.calendar = losAngeles + options.refreshMinIntervalSeconds = 0 + _ = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + historyDays: 1, + refreshPricingInBackground: false, + scannerOptions: options) + + let fetcher = CostUsageFetcher(scannerOptions: options) + let pinned = await fetcher.loadCachedCodexTokenSnapshotResult( + now: day, + historyDays: 1, + calendar: losAngeles) + let travelled = await fetcher.loadCachedCodexTokenSnapshotResult( + now: day, + historyDays: 1, + calendar: shanghai) + + #expect(pinned?.snapshot.sessionTokens == 42) + #expect(pinned?.snapshot.costProvenance == .listPriceEstimate) + #expect(travelled == nil) + } + private static func writeCodexSessionFile( homeRoot: URL, env: CostUsageTestEnvironment, diff --git a/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift b/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift index 53b0395557..7fba070778 100644 --- a/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift @@ -204,7 +204,10 @@ struct CostUsageScannerBreakdownTests { CostUsageDailyReport.ModelBreakdown( modelName: "gpt-5.2-codex", costUSD: first.data[0].costUSD, - totalTokens: 110), + totalTokens: 110, + inputTokens: 100, + outputTokens: 10, + cacheReadTokens: 20), ]) #expect(first.data[0].totalTokens == 110) #expect((first.data[0].costUSD ?? 0) > 0) diff --git a/Tests/CodexBarTests/Issue2037ScannerIntegrationTests.swift b/Tests/CodexBarTests/Issue2037ScannerIntegrationTests.swift index 55cf0870be..8f56001cc0 100644 --- a/Tests/CodexBarTests/Issue2037ScannerIntegrationTests.swift +++ b/Tests/CodexBarTests/Issue2037ScannerIntegrationTests.swift @@ -178,5 +178,48 @@ struct Issue2037ScannerIntegrationTests { // sufficient cross-file identity, so both children stay uncounted until the parent // snapshot is available from its file or the persistent token index. #expect(scannedUnits == 0) + let unmetered = report.data.reduce(0) { $0 + ($1.unmeteredRequestCount ?? 0) } + #expect(unmetered == 2) + #expect(report.data.allSatisfy { $0.costUSD == nil }) + } + + @Test + func `missing parent forks stay in the scan window without billed days`() { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .gmt + let noon = Date(timeIntervalSince1970: 1_893_456_000) // 2030-01-01 12:00 UTC + let range = CostUsageScanner.CostUsageDayRange(since: noon, until: noon, calendar: calendar) + let unixMs = Int64(noon.timeIntervalSince1970 * 1000) + var siblingA = CostUsageFileUsage( + mtimeUnixMs: unixMs, + size: 1, + days: [:]) + siblingA.forkedFromId = "missing-parent" + siblingA.forkBaselineDependencyKey = "missing|missing-parent|discovery|1" + siblingA.codexSession = CostUsageCodexSessionMetadata( + sessionId: "sibling-a", + forkedFromId: "missing-parent", + cwd: nil, + title: nil, + startedAtUnixMs: unixMs, + latestActivityUnixMs: unixMs) + + #expect(CostUsageScanner.isUnresolvedMissingParentFork(siblingA)) + #expect(!CostUsageScanner.codexFileHasBilledTokens(siblingA)) + #expect(siblingA.touchesCodexScanWindow( + sinceKey: range.scanSinceKey, + untilKey: range.scanUntilKey, + calendar: calendar)) + + var siblingB = siblingA + siblingB.codexSession?.sessionId = "sibling-b" + var cache = CostUsageCache() + cache.files["sibling-a.jsonl"] = siblingA + cache.files["sibling-b.jsonl"] = siblingB + let counts = CostUsageScanner.unresolvedForkUnmeteredCounts(cache: cache, range: range) + #expect(counts.values.reduce(0, +) == 2) + let report = CostUsageScanner.buildCodexReportFromCache(cache: cache, range: range) + #expect(report.data.reduce(0) { $0 + ($1.unmeteredRequestCount ?? 0) } == 2) + #expect(report.data.allSatisfy { ($0.inputTokens ?? 0) == 0 }) } } diff --git a/Tests/CodexBarTests/OpenCodexUsageParserTests.swift b/Tests/CodexBarTests/OpenCodexUsageParserTests.swift new file mode 100644 index 0000000000..f95f6782c0 --- /dev/null +++ b/Tests/CodexBarTests/OpenCodexUsageParserTests.swift @@ -0,0 +1,188 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct OpenCodexUsageParserTests { + @Test + func `parses persisted usage rows without reading the developer home`() throws { + let line = """ + {"requestId":"req-1","timestamp":1784179200000,"provider":"openai","model":"gpt-5.4",\ + "usageStatus":"reported","accountLogLabel":"p2","surface":"claude",\ + "usage":{"inputTokens":100,"outputTokens":20,"cacheReadInputTokens":10,\ + "reasoningOutputTokens":5,"totalTokens":135},"totalTokens":135} + """ + let entry = try #require(OpenCodexUsageParser.parseLine(line)) + #expect(entry.requestID == "req-1") + #expect(entry.provider == "openai") + #expect(entry.model == "gpt-5.4") + #expect(entry.usageStatus == .reported) + #expect(entry.accountLogLabel == "p2") + #expect(entry.surface == "claude") + #expect(entry.usage?.inputTokens == 100) + #expect(entry.usage?.reasoningOutputTokens == 5) + #expect(entry.resolvedTotalTokens == 135) + #expect(entry.timestamp == Date(timeIntervalSince1970: 1_784_179_200)) + } + + @Test + func `skips malformed lines and keeps nil usage classes unset`() { + let text = """ + not-json + {"requestId":"req-2","timestamp":1784179200,"provider":"anthropic",\ + "model":"claude-sonnet-4","usageStatus":"unreported"} + """ + let entries = OpenCodexUsageParser.parseLines(text) + #expect(entries.count == 1) + #expect(entries[0].usage == nil) + #expect(entries[0].usageStatus == .unreported) + #expect(entries[0].accountLogLabel == nil) + } + + @Test + func `does not resolve a default home while tests are running`() { + #expect(OpenCodexUsageLog.usageLogURL(environment: ["TESTING_LIBRARY_VERSION": "1"]) == nil) + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("OpenCodexUsageParserTests-\(UUID().uuidString)", isDirectory: true) + let url = OpenCodexUsageLog.usageLogURL(environment: ["OPENCODEX_HOME": home.path]) + #expect(url == home.appendingPathComponent("usage.jsonl")) + } + + @Test + func `aggregates a fixture log into an independent snapshot`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("OpenCodexUsageAggregatorTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let log = root.appendingPathComponent("usage.jsonl") + let now = Date(timeIntervalSince1970: 1_784_179_200) + let millis = Int(now.timeIntervalSince1970 * 1000) + try """ + {"requestId":"a","timestamp":\(millis),"provider":"openai","model":"gpt-5.4","usageStatus":"reported",\ + "accountLogLabel":"main","conversationId":"chat-1",\ + "usage":{"inputTokens":10,"outputTokens":2,"totalTokens":12},"totalTokens":12} + {"requestId":"b","timestamp":\(millis),"provider":"openai","model":"gpt-5.4","usageStatus":"estimated",\ + "accountLogLabel":"p1","conversationId":"chat-1",\ + "usage":{"inputTokens":5,"outputTokens":1,"reasoningOutputTokens":3,"totalTokens":9},"totalTokens":9} + """.write(to: log, atomically: true, encoding: .utf8) + + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let snapshot = try OpenCodexUsageStore(cacheRoot: root).loadSnapshot( + logURL: log, + now: now, + historyDays: 7, + calendar: calendar) + #expect(snapshot.historyLabel == "OpenCodex usage.jsonl") + #expect(snapshot.daily.count == 1) + #expect(snapshot.daily[0].inputTokens == 15) + #expect(snapshot.daily[0].reasoningTokens == 3) + #expect(snapshot.daily[0].estimatedRequestCount == 1) + #expect(snapshot.sessions.count == 1) + #expect(snapshot.sessions[0].sessionID == "chat-1") + #expect(snapshot.sessions[0].reasoningTokens == 3) + #expect(snapshot.costProvenance == .listPriceEstimate) + #expect(OpenCodexUsageStore.databaseFilename == "opencodex-usage.sqlite") + #expect(FileManager.default.fileExists(atPath: root.appendingPathComponent("opencodex-usage.sqlite").path)) + } + + @Test + func `unreported rows without usage stay unpriced instead of zero spend`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let now = Date(timeIntervalSince1970: 1_784_179_200) + let snapshot = OpenCodexUsageAggregator.snapshot( + entries: [ + OpenCodexUsageEntry( + requestID: "empty", + timestamp: now, + provider: "openai", + model: "gpt-5.4", + usageStatus: .unreported), + ], + now: now, + historyDays: 7, + calendar: calendar) + #expect(snapshot.daily.count == 1) + #expect(snapshot.daily[0].costUSD == nil) + #expect(snapshot.daily[0].unpricedRequestCount == 1) + #expect(snapshot.daily[0].requestCount == 1) + #expect(snapshot.costProvenance == .listPriceEstimate) + } + + @Test + func `session totals use the current day instead of the latest historical day`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let now = Date(timeIntervalSince1970: 1_784_179_200) + let yesterday = now.addingTimeInterval(-86400) + let snapshot = OpenCodexUsageAggregator.snapshot( + entries: [ + OpenCodexUsageEntry( + requestID: "old", + timestamp: yesterday, + provider: "openai", + model: "gpt-5.4", + usageStatus: .reported, + usage: OpenCodexTokenUsage(inputTokens: 10, outputTokens: 2, totalTokens: 12), + totalTokens: 12), + ], + now: now, + historyDays: 7, + calendar: calendar) + #expect(snapshot.daily.count == 1) + #expect(snapshot.sessionTokens == 0) + #expect(snapshot.sessionCostUSD == 0) + #expect(snapshot.last30DaysTokens == 12) + } + + @Test + func `unpriced estimated requests count once`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let now = Date(timeIntervalSince1970: 1_784_179_200) + let snapshot = OpenCodexUsageAggregator.snapshot( + entries: [ + OpenCodexUsageEntry( + requestID: "est", + timestamp: now, + provider: "openai", + model: "not-a-priced-model-xyz", + usageStatus: .estimated, + usage: OpenCodexTokenUsage(inputTokens: 10, outputTokens: 2, totalTokens: 12), + totalTokens: 12), + ], + now: now, + historyDays: 7, + calendar: calendar) + #expect(snapshot.daily[0].requestCount == 1) + #expect(snapshot.daily[0].estimatedRequestCount == 0) + #expect(snapshot.daily[0].unpricedRequestCount == 1) + #expect(snapshot.daily[0].costUSD == nil) + } + + @Test + func `duplicate request ids replace instead of aborting the cache write`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("OpenCodexUsageStoreDedupe-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let log = root.appendingPathComponent("usage.jsonl") + let now = Date(timeIntervalSince1970: 1_784_179_200) + let millis = Int(now.timeIntervalSince1970 * 1000) + try """ + {"requestId":"dup","timestamp":\(millis),"provider":"openai","model":"gpt-5.4","usageStatus":"reported",\ + "usage":{"inputTokens":1,"outputTokens":1,"totalTokens":2},"totalTokens":2} + {"requestId":"dup","timestamp":\(millis),"provider":"openai","model":"gpt-5.4","usageStatus":"reported",\ + "usage":{"inputTokens":9,"outputTokens":1,"totalTokens":10},"totalTokens":10} + """.write(to: log, atomically: true, encoding: .utf8) + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let snapshot = try OpenCodexUsageStore(cacheRoot: root).loadSnapshot( + logURL: log, + now: now, + historyDays: 7, + calendar: calendar) + #expect(snapshot.daily[0].inputTokens == 9) + #expect(snapshot.daily[0].requestCount == 1) + } +}