Files
steipete--codexbar/Sources/CodexBarCore/CostUsageFetcher.swift
T
2026-07-13 12:22:33 +08:00

739 lines
30 KiB
Swift

import Foundation
public enum CostUsageError: LocalizedError, Sendable {
case unsupportedProvider(UsageProvider)
case timedOut(seconds: Int)
public var errorDescription: String? {
switch self {
case let .unsupportedProvider(provider):
return "Cost summary is not supported for \(provider.rawValue)."
case let .timedOut(seconds):
if seconds >= 60, seconds % 60 == 0 {
return "Cost refresh timed out after \(seconds / 60)m."
}
return "Cost refresh timed out after \(seconds)s."
}
}
}
public struct CostUsageFetcher: Sendable {
package struct CachedCodexTokenSnapshotResult: Sendable {
package let snapshot: CostUsageTokenSnapshot
package let lastRefreshAt: Date?
}
private let scannerOptions: CostUsageScanner.Options?
public init(cacheRoot: URL? = nil) {
self.scannerOptions = cacheRoot.map { CostUsageScanner.Options(cacheRoot: $0) }
}
init(scannerOptions: CostUsageScanner.Options) {
self.scannerOptions = scannerOptions
}
public func loadCachedCodexTokenSnapshot(
now: Date = Date(),
codexHomePath: String? = nil,
historyDays: Int = 30) async -> CostUsageTokenSnapshot?
{
await Self.loadCachedCodexTokenSnapshot(
now: now,
codexHomePath: codexHomePath,
historyDays: historyDays,
scannerOptions: self.scannerOptionsOverride())
}
package func loadCachedCodexTokenSnapshotResult(
now: Date = Date(),
codexHomePath: String? = nil,
historyDays: Int = 30) async -> CachedCodexTokenSnapshotResult?
{
await Self.loadCachedCodexTokenSnapshotResult(
now: now,
codexHomePath: codexHomePath,
historyDays: historyDays,
scannerOptions: self.scannerOptionsOverride())
}
public func loadTokenSnapshot(
provider: UsageProvider,
environment: [String: String] = ProcessInfo.processInfo.environment,
now: Date = Date(),
forceRefresh: Bool = false,
allowVertexClaudeFallback: Bool = false,
codexHomePath: String? = nil,
historyDays: Int = 30,
refreshPricingInBackground: Bool = true) async throws -> CostUsageTokenSnapshot
{
try await Self.loadTokenSnapshot(
provider: provider,
environment: environment,
now: now,
forceRefresh: forceRefresh,
allowVertexClaudeFallback: allowVertexClaudeFallback,
codexHomePath: codexHomePath,
historyDays: historyDays,
refreshPricingInBackground: refreshPricingInBackground,
bypassScannerDebounce: false,
scannerOptions: self.scannerOptionsOverride())
}
package func loadTokenSnapshot(
provider: UsageProvider,
environment: [String: String] = ProcessInfo.processInfo.environment,
now: Date = Date(),
forceRefresh: Bool = false,
allowVertexClaudeFallback: Bool = false,
codexHomePath: String? = nil,
historyDays: Int = 30,
refreshPricingInBackground: Bool = true,
bypassScannerDebounce: Bool) async throws -> CostUsageTokenSnapshot
{
try await Self.loadTokenSnapshot(
provider: provider,
environment: environment,
now: now,
forceRefresh: forceRefresh,
allowVertexClaudeFallback: allowVertexClaudeFallback,
codexHomePath: codexHomePath,
historyDays: historyDays,
refreshPricingInBackground: refreshPricingInBackground,
bypassScannerDebounce: bypassScannerDebounce,
scannerOptions: self.scannerOptionsOverride())
}
@available(*, deprecated, message: "Codex token-cost scans are uncapped; this limit is ignored.")
public func loadTokenSnapshot(
provider: UsageProvider,
environment: [String: String] = ProcessInfo.processInfo.environment,
now: Date = Date(),
forceRefresh: Bool = false,
allowVertexClaudeFallback: Bool = false,
codexHomePath: String? = nil,
historyDays: Int = 30,
refreshPricingInBackground: Bool = true,
automaticCodexScanByteLimit _: Int64?) async throws -> CostUsageTokenSnapshot
{
try await self.loadTokenSnapshot(
provider: provider,
environment: environment,
now: now,
forceRefresh: forceRefresh,
allowVertexClaudeFallback: allowVertexClaudeFallback,
codexHomePath: codexHomePath,
historyDays: historyDays,
refreshPricingInBackground: refreshPricingInBackground)
}
private func scannerOptionsOverride() -> CostUsageScanner.Options? {
self.scannerOptions
}
static func loadTokenSnapshot(
provider: UsageProvider,
environment: [String: String] = ProcessInfo.processInfo.environment,
now: Date = Date(),
forceRefresh: Bool = false,
allowVertexClaudeFallback: Bool = false,
codexHomePath: String? = nil,
historyDays: Int = 30,
refreshPricingInBackground: Bool = true,
bypassScannerDebounce: Bool = false,
scannerOptions overrideScannerOptions: CostUsageScanner.Options? = nil,
piScannerOptions overridePiScannerOptions: PiSessionCostScanner
.Options? = nil,
modelsDevClient: ModelsDevClient = ModelsDevClient(),
retryUnknownPricing: Bool = true) async throws -> CostUsageTokenSnapshot
{
guard provider == .codex || provider == .claude || provider == .vertexai || provider == .bedrock else {
throw CostUsageError.unsupportedProvider(provider)
}
let until = now
let clampedHistoryDays = max(1, min(365, historyDays))
// Rolling window is inclusive, so a 30-day display starts 29 days before `now`.
let since = Calendar.current.date(byAdding: .day, value: -(clampedHistoryDays - 1), to: now) ?? now
if provider == .bedrock {
let daily = try await Self.loadBedrockDailyReport(
environment: environment,
since: since,
until: until)
return Self.tokenSnapshot(
from: daily,
now: now,
historyDays: clampedHistoryDays,
useCurrentLocalDayForSession: false)
}
var options = overrideScannerOptions ?? CostUsageScanner.Options()
if provider == .codex,
let codexHomePath = codexHomePath?.trimmingCharacters(in: .whitespacesAndNewlines),
!codexHomePath.isEmpty
{
options.codexSessionsRoot = URL(fileURLWithPath: codexHomePath, isDirectory: true)
.appendingPathComponent("sessions", isDirectory: true)
}
if retryUnknownPricing, provider == .codex || provider == .claude {
let pricingCacheRoot = options.cacheRoot
if refreshPricingInBackground {
Task.detached(priority: .utility) {
await ModelsDevPricingPipeline.refreshIfNeeded(
now: now,
cacheRoot: pricingCacheRoot,
client: modelsDevClient)
}
} else {
await ModelsDevPricingPipeline.refreshIfNeeded(
now: now,
cacheRoot: pricingCacheRoot,
client: modelsDevClient)
}
}
if provider == .vertexai {
options.claudeLogProviderFilter = allowVertexClaudeFallback ? .all : .vertexAIOnly
} else if provider == .claude {
options.claudeLogProviderFilter = .excludeVertexAI
}
if forceRefresh || bypassScannerDebounce {
options.refreshMinIntervalSeconds = 0
}
var resolvedPiOptions = overridePiScannerOptions ?? PiSessionCostScanner.Options()
if resolvedPiOptions.cacheRoot == nil {
resolvedPiOptions.cacheRoot = options.cacheRoot
}
if forceRefresh || bypassScannerDebounce {
resolvedPiOptions.refreshMinIntervalSeconds = 0
}
let piOptions = resolvedPiOptions
try Task.checkCancellation()
// The corpus scans below are synchronous and can run for minutes on large session
// archives. They execute on the dedicated scan queue so they never occupy a cooperative
// pool thread; CostUsageScanExecutor bridges this task's cancellation into the
// scanner-level checks.
let scanOptions = options
let scanResult = try await CostUsageScanExecutor.run { checkCancellation in
var daily = try CostUsageScanner.loadDailyReportCancellable(
provider: provider,
since: since,
until: until,
now: now,
options: scanOptions,
checkCancellation: checkCancellation)
try checkCancellation()
if provider == .vertexai,
!allowVertexClaudeFallback,
scanOptions.claudeLogProviderFilter == .vertexAIOnly,
daily.data.isEmpty
{
var fallback = scanOptions
fallback.claudeLogProviderFilter = .all
daily = try CostUsageScanner.loadDailyReportCancellable(
provider: provider,
since: since,
until: until,
now: now,
options: fallback,
checkCancellation: checkCancellation)
try checkCancellation()
}
var projects: [CostUsageProjectBreakdown] = []
var piDaily: CostUsageDailyReport?
if provider == .codex {
let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: scanOptions.cacheRoot)
projects = CostUsageScanner.buildCodexProjectBreakdownsFromCache(
cache: cache,
range: CostUsageScanner.CostUsageDayRange(since: since, until: until),
modelsDevCacheRoot: scanOptions.cacheRoot)
}
if provider == .codex || provider == .claude {
let piReport = try PiSessionCostScanner.loadDailyReportCancellable(
provider: provider,
since: since,
until: until,
now: now,
options: piOptions,
checkCancellation: checkCancellation)
try checkCancellation()
if provider == .codex {
piDaily = piReport
}
daily = CostUsageDailyReport.merged([daily, piReport])
}
if provider == .codex {
projects = Self.mergedProjectBreakdowns(
projects + [piDaily.flatMap(Self.unknownProjectBreakdown(from:))].compactMap(\.self))
}
return (daily: daily, projects: projects)
}
if retryUnknownPricing,
let request = Self.unknownPricingRefreshRequest(
provider: provider,
daily: scanResult.daily,
now: now,
cacheRoot: options.cacheRoot,
client: modelsDevClient),
await Self.refreshUnknownPricingIfNeeded(request, inBackground: refreshPricingInBackground)
{
return try await self.loadTokenSnapshot(
provider: provider,
environment: environment,
now: now,
forceRefresh: forceRefresh,
allowVertexClaudeFallback: allowVertexClaudeFallback,
codexHomePath: codexHomePath,
historyDays: historyDays,
refreshPricingInBackground: false,
scannerOptions: options,
piScannerOptions: piOptions,
modelsDevClient: modelsDevClient,
retryUnknownPricing: false)
}
return Self.tokenSnapshot(
from: scanResult.daily,
now: now,
historyDays: clampedHistoryDays,
projects: scanResult.projects)
}
private struct UnknownPricingRefreshRequest: Sendable {
let providerID: String
let modelIDs: Set<String>
let now: Date
let cacheRoot: URL?
let client: ModelsDevClient
}
private static func unknownPricingRefreshRequest(
provider: UsageProvider,
daily: CostUsageDailyReport,
now: Date,
cacheRoot: URL?,
client: ModelsDevClient) -> UnknownPricingRefreshRequest?
{
guard provider == .codex || provider == .claude else { return nil }
let unknownModelIDs = Set(daily.data.flatMap { entry in
entry.modelBreakdowns?.compactMap { breakdown -> String? in
guard breakdown.costUSD == nil else { return nil }
if provider == .codex,
CostUsagePricing.isCodexUnattributedModel(breakdown.modelName)
{
return nil
}
return breakdown.modelName
} ?? []
})
guard !unknownModelIDs.isEmpty else { return nil }
return UnknownPricingRefreshRequest(
providerID: provider == .codex ? "openai" : "anthropic",
modelIDs: unknownModelIDs,
now: now,
cacheRoot: cacheRoot,
client: client)
}
private static func refreshUnknownPricingIfNeeded(
_ request: UnknownPricingRefreshRequest,
inBackground: Bool) async -> Bool
{
if inBackground {
Task.detached(priority: .utility) {
_ = await ModelsDevPricingPipeline.refreshForUnknownModelsIfNeeded(
providerID: request.providerID,
modelIDs: request.modelIDs,
now: request.now,
cacheRoot: request.cacheRoot,
client: request.client)
}
return false
}
return await ModelsDevPricingPipeline.refreshForUnknownModelsIfNeeded(
providerID: request.providerID,
modelIDs: request.modelIDs,
now: request.now,
cacheRoot: request.cacheRoot,
client: request.client) == .pricingAvailable
}
static func loadCachedCodexTokenSnapshot(
now: Date = Date(),
codexHomePath: String? = nil,
historyDays: Int = 30,
scannerOptions overrideScannerOptions: CostUsageScanner.Options? = nil) async -> CostUsageTokenSnapshot?
{
await self.loadCachedCodexTokenSnapshotResult(
now: now,
codexHomePath: codexHomePath,
historyDays: historyDays,
scannerOptions: overrideScannerOptions)?.snapshot
}
static func loadCachedCodexTokenSnapshotResult(
now: Date = Date(),
codexHomePath: String? = nil,
historyDays: Int = 30,
scannerOptions overrideScannerOptions: CostUsageScanner.Options? = nil) async
-> CachedCodexTokenSnapshotResult?
{
if let codexHomePath = codexHomePath?.trimmingCharacters(in: .whitespacesAndNewlines),
!codexHomePath.isEmpty
{
return nil
}
// Decoding the persisted scan cache parses multi-megabyte JSON; keep it off the
// cooperative pool alongside the scans themselves.
let cachedSnapshot: CachedCodexTokenSnapshotResult?? = try? await CostUsageScanExecutor.run { _ in
let clampedHistoryDays = max(1, min(365, historyDays))
let until = now
let since = Calendar.current.date(byAdding: .day, value: -(clampedHistoryDays - 1), to: now) ?? now
let range = CostUsageScanner.CostUsageDayRange(since: since, until: until)
let options = overrideScannerOptions ?? CostUsageScanner.Options()
let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: options.cacheRoot)
var reports: [CostUsageDailyReport] = []
var projects: [CostUsageProjectBreakdown] = []
// Raw inputs for the derived result fields below: the native cache's own scan
// time, every constituent scan time, and whether a second source joined the merge.
var nativeScanAt: Date?
var scanTimes: [Date] = []
var piMerged = false
if !cache.days.isEmpty,
cache.roots == CostUsageScanner.codexRootsFingerprint(options: options),
!CostUsageScanner.requestedWindowExpandsCache(range: range, cache: cache)
{
let daily = CostUsageScanner.buildCodexReportFromCache(
cache: cache,
range: range,
modelsDevCacheRoot: options.cacheRoot)
if !daily.data.isEmpty {
reports.append(daily)
if cache.lastScanUnixMs > 0 {
let scanAt = Date(timeIntervalSince1970: TimeInterval(cache.lastScanUnixMs) / 1000)
nativeScanAt = scanAt
scanTimes.append(scanAt)
}
if cache.codexProjectMetadataVersion == CostUsageScanner.codexProjectMetadataVersion {
projects.append(contentsOf: CostUsageScanner.buildCodexProjectBreakdownsFromCache(
cache: cache,
range: range,
modelsDevCacheRoot: options.cacheRoot))
}
}
}
if let piResult = PiSessionCostScanner.loadCachedDailyReportResult(
provider: .codex,
since: since,
until: until,
now: now,
cacheRoot: options.cacheRoot)
{
reports.append(piResult.report)
piMerged = true
if let piLastScanAt = piResult.lastScanAt {
scanTimes.append(piLastScanAt)
}
if let piProject = Self.unknownProjectBreakdown(from: piResult.report) {
projects.append(piProject)
}
}
guard !reports.isEmpty else { return nil }
// updatedAt keeps the caches' real (oldest) scan time; stamping the hydration time
// would let stale token rows inherit app-start freshness (#1964). lastRefreshAt
// drives TTL suppression and stays native-only: a merged load must never delay a
// rescan on the strength of another source's scan.
return CachedCodexTokenSnapshotResult(
snapshot: Self.tokenSnapshot(
from: CostUsageDailyReport.merged(reports),
now: now,
historyDays: clampedHistoryDays,
projects: Self.mergedProjectBreakdowns(projects),
updatedAt: scanTimes.min()),
lastRefreshAt: piMerged ? nil : nativeScanAt)
}
return cachedSnapshot.flatMap(\.self)
}
private static func loadBedrockDailyReport(
environment: [String: String],
since: Date,
until: Date) async throws -> CostUsageDailyReport
{
let resolved = try await BedrockCredentialResolver.resolve(environment: environment)
return try await BedrockUsageFetcher.fetchDailyReport(
credentials: resolved.credentials,
since: since,
until: until,
environment: environment)
}
static func tokenSnapshot(
from daily: CostUsageDailyReport,
now: Date,
historyDays: Int = 30,
useCurrentLocalDayForSession: Bool = true,
projects: [CostUsageProjectBreakdown] = [],
updatedAt: Date? = nil) -> CostUsageTokenSnapshot
{
let sessionEntry = useCurrentLocalDayForSession
? CostUsageTokenSnapshot.entry(in: daily.data, forLocalDayContaining: now)
: CostUsageTokenSnapshot.latestEntry(in: daily.data)
let hasHistoricalRows = !daily.data.isEmpty
let sessionTokens: Int? = if let sessionEntry {
sessionEntry.totalTokens
} else if hasHistoricalRows {
0
} else {
nil
}
let sessionCostUSD: Double? = if let sessionEntry {
sessionEntry.costUSD
} else if hasHistoricalRows {
0
} else {
nil
}
// Prefer summary totals when present; fall back to summing daily entries.
let totalFromSummary = daily.summary?.totalCostUSD
let totalFromEntries = daily.data.compactMap(\.costUSD).reduce(0, +)
let last30DaysCostUSD = totalFromSummary ?? (totalFromEntries > 0 ? totalFromEntries : nil)
let totalTokensFromSummary = daily.summary?.totalTokens
let totalTokensFromEntries = daily.data.compactMap(\.totalTokens).reduce(0, +)
let last30DaysTokens = totalTokensFromSummary ?? (totalTokensFromEntries > 0 ? totalTokensFromEntries : nil)
return CostUsageTokenSnapshot(
sessionTokens: sessionTokens,
sessionCostUSD: sessionCostUSD,
last30DaysTokens: last30DaysTokens,
last30DaysCostUSD: last30DaysCostUSD,
historyDays: historyDays,
daily: daily.data,
projects: projects,
updatedAt: updatedAt ?? now)
}
private static func unknownProjectBreakdown(from daily: CostUsageDailyReport) -> CostUsageProjectBreakdown? {
guard !daily.data.isEmpty else { return nil }
return CostUsageProjectBreakdown(
name: CostUsageProjectBreakdown.unknownProjectName,
path: nil,
totalTokens: daily.summary?.totalTokens,
totalCostUSD: daily.summary?.totalCostUSD,
daily: daily.data,
modelBreakdowns: self.projectModelBreakdowns(from: daily.data),
sources: [
CostUsageProjectSourceBreakdown(
name: CostUsageProjectBreakdown.unknownProjectName,
path: nil,
totalTokens: daily.summary?.totalTokens,
totalCostUSD: daily.summary?.totalCostUSD,
daily: daily.data,
modelBreakdowns: self.projectModelBreakdowns(from: daily.data)),
])
}
private static func mergedProjectBreakdowns(
_ projects: [CostUsageProjectBreakdown]) -> [CostUsageProjectBreakdown]
{
var dailyByPath: [String: [CostUsageDailyReport]] = [:]
var namesByPath: [String: String] = [:]
var sourceDailyByProjectPath: [String: [String: [CostUsageDailyReport]]] = [:]
var sourceNamesByProjectPath: [String: [String: String]] = [:]
for project in projects {
let key = project.path ?? ""
namesByPath[key] = project.name
dailyByPath[key, default: []].append(CostUsageDailyReport(data: project.daily, summary: nil))
let sources = project.sources.isEmpty
? [
CostUsageProjectSourceBreakdown(
name: project.name,
path: project.path,
totalTokens: project.totalTokens,
totalCostUSD: project.totalCostUSD,
daily: project.daily,
modelBreakdowns: project.modelBreakdowns),
]
: project.sources
for source in sources {
let sourceKey = source.path ?? ""
sourceNamesByProjectPath[key, default: [:]][sourceKey] = source.name
sourceDailyByProjectPath[key, default: [:]][sourceKey, default: []]
.append(CostUsageDailyReport(data: source.daily, summary: nil))
}
}
return dailyByPath.map { key, reports in
let merged = CostUsageDailyReport.merged(reports)
return CostUsageProjectBreakdown(
name: namesByPath[key] ?? CostUsageProjectBreakdown.unknownProjectName,
path: key.isEmpty ? nil : key,
totalTokens: merged.summary?.totalTokens,
totalCostUSD: merged.summary?.totalCostUSD,
daily: merged.data,
modelBreakdowns: Self.projectModelBreakdowns(from: merged.data),
sources: Self.mergedProjectSources(
sourceDailyByPath: sourceDailyByProjectPath[key] ?? [:],
sourceNamesByPath: sourceNamesByProjectPath[key] ?? [:]))
}
.sorted { lhs, rhs in
let lhsCost = lhs.totalCostUSD ?? -1
let rhsCost = rhs.totalCostUSD ?? -1
if lhsCost != rhsCost {
return lhsCost > rhsCost
}
let lhsTokens = lhs.totalTokens ?? -1
let rhsTokens = rhs.totalTokens ?? -1
if lhsTokens != rhsTokens {
return lhsTokens > rhsTokens
}
return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending
}
}
private static func mergedProjectSources(
sourceDailyByPath: [String: [CostUsageDailyReport]],
sourceNamesByPath: [String: String]) -> [CostUsageProjectSourceBreakdown]
{
sourceDailyByPath.map { key, reports in
let merged = CostUsageDailyReport.merged(reports)
return CostUsageProjectSourceBreakdown(
name: sourceNamesByPath[key] ?? CostUsageProjectBreakdown.unknownProjectName,
path: key.isEmpty ? nil : key,
totalTokens: merged.summary?.totalTokens,
totalCostUSD: merged.summary?.totalCostUSD,
daily: merged.data,
modelBreakdowns: Self.projectModelBreakdowns(from: merged.data))
}
.sorted { lhs, rhs in
let lhsCost = lhs.totalCostUSD ?? -1
let rhsCost = rhs.totalCostUSD ?? -1
if lhsCost != rhsCost {
return lhsCost > rhsCost
}
let lhsTokens = lhs.totalTokens ?? -1
let rhsTokens = rhs.totalTokens ?? -1
if lhsTokens != rhsTokens {
return lhsTokens > rhsTokens
}
return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending
}
}
private struct ProjectBreakdownAccumulator {
var totalTokens = 0
var sawTotalTokens = false
var costUSD: Double = 0
var sawCost = false
mutating func add(_ breakdown: CostUsageDailyReport.ModelBreakdown) {
if let totalTokens = breakdown.totalTokens {
self.totalTokens += totalTokens
self.sawTotalTokens = true
}
if let costUSD = breakdown.costUSD {
self.costUSD += costUSD
self.sawCost = true
}
}
func build(modelName: String) -> CostUsageDailyReport.ModelBreakdown {
CostUsageDailyReport.ModelBreakdown(
modelName: modelName,
costUSD: self.sawCost ? self.costUSD : nil,
totalTokens: self.sawTotalTokens ? self.totalTokens : nil)
}
}
private static func projectModelBreakdowns(
from entries: [CostUsageDailyReport.Entry]) -> [CostUsageDailyReport.ModelBreakdown]?
{
var accumulators: [String: ProjectBreakdownAccumulator] = [:]
for entry in entries {
for breakdown in entry.modelBreakdowns ?? [] {
var accumulator = accumulators[breakdown.modelName] ?? ProjectBreakdownAccumulator()
accumulator.add(breakdown)
accumulators[breakdown.modelName] = accumulator
}
}
guard !accumulators.isEmpty else { return nil }
return accumulators.map { modelName, accumulator in
accumulator.build(modelName: modelName)
}
.sorted { lhs, rhs in
let lhsCost = lhs.costUSD ?? -1
let rhsCost = rhs.costUSD ?? -1
if lhsCost != rhsCost {
return lhsCost > rhsCost
}
let lhsTokens = lhs.totalTokens ?? -1
let rhsTokens = rhs.totalTokens ?? -1
if lhsTokens != rhsTokens {
return lhsTokens > rhsTokens
}
return lhs.modelName > rhs.modelName
}
}
static func selectCurrentSession(from sessions: [CostUsageSessionReport.Entry])
-> CostUsageSessionReport.Entry?
{
if sessions.isEmpty {
return nil
}
return sessions.max { lhs, rhs in
let lDate = CostUsageDateParser.parse(lhs.lastActivity) ?? .distantPast
let rDate = CostUsageDateParser.parse(rhs.lastActivity) ?? .distantPast
if lDate != rDate {
return lDate < rDate
}
let lCost = lhs.costUSD ?? -1
let rCost = rhs.costUSD ?? -1
if lCost != rCost {
return lCost < rCost
}
let lTokens = lhs.totalTokens ?? -1
let rTokens = rhs.totalTokens ?? -1
if lTokens != rTokens {
return lTokens < rTokens
}
return lhs.session < rhs.session
}
}
static func selectMostRecentMonth(from months: [CostUsageMonthlyReport.Entry])
-> CostUsageMonthlyReport.Entry?
{
if months.isEmpty {
return nil
}
return months.max { lhs, rhs in
let lDate = CostUsageDateParser.parseMonth(lhs.month) ?? .distantPast
let rDate = CostUsageDateParser.parseMonth(rhs.month) ?? .distantPast
if lDate != rDate {
return lDate < rDate
}
let lCost = lhs.costUSD ?? -1
let rCost = rhs.costUSD ?? -1
if lCost != rCost {
return lCost < rCost
}
let lTokens = lhs.totalTokens ?? -1
let rTokens = rhs.totalTokens ?? -1
if lTokens != rTokens {
return lTokens < rTokens
}
return lhs.month < rhs.month
}
}
}