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

1001 lines
39 KiB
Swift

import Foundation
public struct CostUsageWindowSummary: Sendable, Equatable {
public let days: Int
public let totalTokens: Int?
public let totalCostUSD: Double?
public let totalRequests: Int?
public let entryCount: Int
public init(
days: Int,
totalTokens: Int?,
totalCostUSD: Double?,
totalRequests: Int?,
entryCount: Int)
{
self.days = days
self.totalTokens = totalTokens
self.totalCostUSD = totalCostUSD
self.totalRequests = totalRequests
self.entryCount = entryCount
}
}
public struct CostUsageTokenSnapshot: Sendable, Equatable {
public let sessionTokens: Int?
public let sessionCostUSD: Double?
public let sessionRequests: Int?
public let last30DaysTokens: Int?
public let last30DaysCostUSD: Double?
public let last30DaysRequests: Int?
public let currencyCode: String
public let historyDays: Int
public let historyLabel: String?
public let daily: [CostUsageDailyReport.Entry]
public let projects: [CostUsageProjectBreakdown]
public let updatedAt: Date
public init(
sessionTokens: Int?,
sessionCostUSD: Double?,
sessionRequests: Int? = nil,
last30DaysTokens: Int?,
last30DaysCostUSD: Double?,
last30DaysRequests: Int? = nil,
currencyCode: String = "USD",
historyDays: Int = 30,
historyLabel: String? = nil,
daily: [CostUsageDailyReport.Entry],
projects: [CostUsageProjectBreakdown] = [],
updatedAt: Date)
{
self.sessionTokens = sessionTokens
self.sessionCostUSD = sessionCostUSD
self.sessionRequests = sessionRequests
self.last30DaysTokens = last30DaysTokens
self.last30DaysCostUSD = last30DaysCostUSD
self.last30DaysRequests = last30DaysRequests
self.currencyCode = currencyCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? "USD"
: currencyCode.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
self.historyDays = historyDays
self.historyLabel = historyLabel
self.daily = daily
self.projects = projects
self.updatedAt = updatedAt
}
public func currentDayEntry(calendar: Calendar = .current) -> CostUsageDailyReport.Entry? {
Self.entry(in: self.daily, forLocalDayContaining: self.updatedAt, calendar: calendar)
}
public func summary(forLastDays requestedDays: Int, calendar: Calendar = .current) -> CostUsageWindowSummary {
let days = max(1, requestedDays)
let today = calendar.startOfDay(for: self.updatedAt)
let start = calendar.date(byAdding: .day, value: -(days - 1), to: today) ?? today
let startKey = CostUsageLocalDay.key(from: start, calendar: calendar)
let endKey = CostUsageLocalDay.key(from: today, calendar: calendar)
let entries = self.daily.filter { entry in
guard let dayKey = Self.localDayKey(for: entry.date, calendar: calendar) else { return false }
return dayKey >= startKey && dayKey <= endKey
}
let costs = entries.compactMap(\.costUSD)
let tokens = entries.compactMap(\.totalTokens)
let requests = entries.compactMap(\.requestCount)
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)
}
public func comparisonSummaries(
periods: [Int] = [7, 30, 90],
calendar: Calendar = .current) -> [CostUsageWindowSummary]
{
Array(Set(periods.map { max(1, $0) }))
.filter { $0 < self.historyDays }
.sorted()
.map { self.summary(forLastDays: $0, calendar: calendar) }
}
public static func latestEntry(in entries: [CostUsageDailyReport.Entry]) -> CostUsageDailyReport.Entry? {
entries.compactMap { entry -> (entry: CostUsageDailyReport.Entry, date: Date)? in
guard let date = CostUsageDateParser.parse(entry.date) else { return nil }
return (entry, date)
}
.max { lhs, rhs in
if lhs.date != rhs.date { return lhs.date < rhs.date }
let lCost = lhs.entry.costUSD ?? -1
let rCost = rhs.entry.costUSD ?? -1
if lCost != rCost { return lCost < rCost }
let lTokens = lhs.entry.totalTokens ?? -1
let rTokens = rhs.entry.totalTokens ?? -1
if lTokens != rTokens { return lTokens < rTokens }
return lhs.entry.date < rhs.entry.date
}?.entry
}
public static func entry(
in entries: [CostUsageDailyReport.Entry],
forLocalDayContaining date: Date,
calendar: Calendar = .current) -> CostUsageDailyReport.Entry?
{
let dayKey = CostUsageLocalDay.key(from: date, calendar: calendar)
return entries.first { entry in
let rawDate = entry.date.trimmingCharacters(in: .whitespacesAndNewlines)
if rawDate == dayKey { return true }
guard let parsed = CostUsageDateParser.parse(rawDate) else { return false }
return CostUsageLocalDay.key(from: parsed, calendar: calendar) == dayKey
}
}
private static func localDayKey(for rawDate: String, calendar: Calendar) -> String? {
let trimmed = rawDate.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.count >= 10 {
let prefix = String(trimmed.prefix(10))
if prefix.count == 10, prefix[prefix.index(prefix.startIndex, offsetBy: 4)] == "-",
prefix[prefix.index(prefix.startIndex, offsetBy: 7)] == "-"
{
return prefix
}
}
guard let parsed = CostUsageDateParser.parse(trimmed) else { return nil }
return CostUsageLocalDay.key(from: parsed, calendar: calendar)
}
}
public struct CostUsageProjectBreakdown: Sendable, Equatable {
public static let unknownProjectName = "Unknown project"
public let name: String
public let path: String?
public let totalTokens: Int?
public let totalCostUSD: Double?
public let daily: [CostUsageDailyReport.Entry]
public let modelBreakdowns: [CostUsageDailyReport.ModelBreakdown]?
public let sources: [CostUsageProjectSourceBreakdown]
public init(
name: String,
path: String?,
totalTokens: Int?,
totalCostUSD: Double?,
daily: [CostUsageDailyReport.Entry],
modelBreakdowns: [CostUsageDailyReport.ModelBreakdown]?,
sources: [CostUsageProjectSourceBreakdown] = [])
{
self.name = name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? Self.unknownProjectName
: name
let cleanPath = path?.trimmingCharacters(in: .whitespacesAndNewlines)
self.path = cleanPath?.isEmpty == true ? nil : cleanPath
self.totalTokens = totalTokens
self.totalCostUSD = totalCostUSD
self.daily = daily
self.modelBreakdowns = modelBreakdowns
self.sources = sources
}
}
public struct CostUsageProjectSourceBreakdown: Sendable, Equatable {
public let name: String
public let path: String?
public let totalTokens: Int?
public let totalCostUSD: Double?
public let daily: [CostUsageDailyReport.Entry]
public let modelBreakdowns: [CostUsageDailyReport.ModelBreakdown]?
public init(
name: String,
path: String?,
totalTokens: Int?,
totalCostUSD: Double?,
daily: [CostUsageDailyReport.Entry],
modelBreakdowns: [CostUsageDailyReport.ModelBreakdown]?)
{
self.name = name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? CostUsageProjectBreakdown.unknownProjectName
: name
let cleanPath = path?.trimmingCharacters(in: .whitespacesAndNewlines)
self.path = cleanPath?.isEmpty == true ? nil : cleanPath
self.totalTokens = totalTokens
self.totalCostUSD = totalCostUSD
self.daily = daily
self.modelBreakdowns = modelBreakdowns
}
}
public struct CostUsageDailyReport: Sendable, Decodable {
public struct ModelBreakdown: Sendable, Decodable, Equatable {
public let modelName: String
public let costUSD: Double?
public let totalTokens: Int?
public let requestCount: Int?
public let standardCostUSD: Double?
public let priorityCostUSD: Double?
public let standardTokens: Int?
public let priorityTokens: Int?
private enum CodingKeys: String, CodingKey {
case modelName
case costUSD
case cost
case totalTokens
case requestCount
case requests
case standardCostUSD
case priorityCostUSD
case standardTokens
case priorityTokens
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.modelName = try container.decode(String.self, forKey: .modelName)
self.costUSD =
try container.decodeIfPresent(Double.self, forKey: .costUSD)
?? container.decodeIfPresent(Double.self, forKey: .cost)
self.totalTokens = try container.decodeIfPresent(Int.self, forKey: .totalTokens)
self.requestCount =
try container.decodeIfPresent(Int.self, forKey: .requestCount)
?? container.decodeIfPresent(Int.self, forKey: .requests)
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)
self.priorityTokens = try container.decodeIfPresent(Int.self, forKey: .priorityTokens)
}
public init(
modelName: String,
costUSD: Double?,
totalTokens: Int? = nil,
requestCount: Int? = nil,
standardCostUSD: Double? = nil,
priorityCostUSD: Double? = nil,
standardTokens: Int? = nil,
priorityTokens: Int? = nil)
{
self.modelName = modelName
self.costUSD = costUSD
self.totalTokens = totalTokens
self.requestCount = requestCount
self.standardCostUSD = standardCostUSD
self.priorityCostUSD = priorityCostUSD
self.standardTokens = standardTokens
self.priorityTokens = priorityTokens
}
}
public struct Entry: Sendable, Decodable, Equatable {
public let date: String
public let inputTokens: Int?
public let cacheReadTokens: Int?
public let cacheCreationTokens: Int?
public let outputTokens: Int?
public let totalTokens: Int?
public let requestCount: Int?
public let costUSD: Double?
public let modelsUsed: [String]?
public let modelBreakdowns: [ModelBreakdown]?
private enum CodingKeys: String, CodingKey {
case date
case inputTokens
case cacheReadTokens
case cacheCreationTokens
case cacheReadInputTokens
case cacheCreationInputTokens
case outputTokens
case totalTokens
case requestCount
case requests
case costUSD
case totalCost
case modelsUsed
case models
case modelBreakdowns
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.date = try container.decode(String.self, forKey: .date)
self.inputTokens = try container.decodeIfPresent(Int.self, forKey: .inputTokens)
self.cacheReadTokens =
try container.decodeIfPresent(Int.self, forKey: .cacheReadTokens)
?? container.decodeIfPresent(Int.self, forKey: .cacheReadInputTokens)
self.cacheCreationTokens =
try container.decodeIfPresent(Int.self, forKey: .cacheCreationTokens)
?? container.decodeIfPresent(Int.self, forKey: .cacheCreationInputTokens)
self.outputTokens = try container.decodeIfPresent(Int.self, forKey: .outputTokens)
self.totalTokens = try container.decodeIfPresent(Int.self, forKey: .totalTokens)
self.requestCount =
try container.decodeIfPresent(Int.self, forKey: .requestCount)
?? container.decodeIfPresent(Int.self, forKey: .requests)
self.costUSD =
try container.decodeIfPresent(Double.self, forKey: .costUSD)
?? container.decodeIfPresent(Double.self, forKey: .totalCost)
self.modelsUsed = Self.decodeModelsUsed(from: container)
self.modelBreakdowns = try container.decodeIfPresent([ModelBreakdown].self, forKey: .modelBreakdowns)
}
public init(
date: String,
inputTokens: Int?,
outputTokens: Int?,
cacheReadTokens: Int? = nil,
cacheCreationTokens: Int? = nil,
totalTokens: Int?,
requestCount: Int? = nil,
costUSD: Double?,
modelsUsed: [String]?,
modelBreakdowns: [ModelBreakdown]?)
{
self.date = date
self.inputTokens = inputTokens
self.outputTokens = outputTokens
self.cacheReadTokens = cacheReadTokens
self.cacheCreationTokens = cacheCreationTokens
self.totalTokens = totalTokens
self.requestCount = requestCount
self.costUSD = costUSD
self.modelsUsed = modelsUsed
self.modelBreakdowns = modelBreakdowns
}
private static func decodeModelsUsed(from container: KeyedDecodingContainer<CodingKeys>) -> [String]? {
func decodeStringList(_ key: CodingKeys) -> [String]? {
(try? container.decodeIfPresent([String].self, forKey: key)).flatMap(\.self)
}
if let modelsUsed = decodeStringList(.modelsUsed) { return modelsUsed }
if let models = decodeStringList(.models) { return models }
guard container.contains(.models) else { return nil }
guard let modelMap = try? container.nestedContainer(keyedBy: CostUsageAnyCodingKey.self, forKey: .models)
else { return nil }
let modelNames = modelMap.allKeys.map(\.stringValue).sorted()
return modelNames.isEmpty ? nil : modelNames
}
}
public struct Summary: Sendable, Decodable, Equatable {
public let totalInputTokens: Int?
public let totalOutputTokens: Int?
public let cacheReadTokens: Int?
public let cacheCreationTokens: Int?
public let totalTokens: Int?
public let totalCostUSD: Double?
private enum CodingKeys: String, CodingKey {
case totalInputTokens
case totalOutputTokens
case cacheReadTokens
case cacheCreationTokens
case totalCacheReadTokens
case totalCacheCreationTokens
case totalTokens
case totalCostUSD
case totalCost
}
public init(
totalInputTokens: Int?,
totalOutputTokens: Int?,
cacheReadTokens: Int? = nil,
cacheCreationTokens: Int? = nil,
totalTokens: Int?,
totalCostUSD: Double?)
{
self.totalInputTokens = totalInputTokens
self.totalOutputTokens = totalOutputTokens
self.cacheReadTokens = cacheReadTokens
self.cacheCreationTokens = cacheCreationTokens
self.totalTokens = totalTokens
self.totalCostUSD = totalCostUSD
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.totalInputTokens = try container.decodeIfPresent(Int.self, forKey: .totalInputTokens)
self.totalOutputTokens = try container.decodeIfPresent(Int.self, forKey: .totalOutputTokens)
self.cacheReadTokens =
try container.decodeIfPresent(Int.self, forKey: .cacheReadTokens)
?? container.decodeIfPresent(Int.self, forKey: .totalCacheReadTokens)
self.cacheCreationTokens =
try container.decodeIfPresent(Int.self, forKey: .cacheCreationTokens)
?? container.decodeIfPresent(Int.self, forKey: .totalCacheCreationTokens)
self.totalTokens = try container.decodeIfPresent(Int.self, forKey: .totalTokens)
self.totalCostUSD =
try container.decodeIfPresent(Double.self, forKey: .totalCostUSD)
?? container.decodeIfPresent(Double.self, forKey: .totalCost)
}
}
public let data: [Entry]
public let summary: Summary?
private enum CodingKeys: String, CodingKey {
case type
case data
case summary
case daily
case totals
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
if container.contains(.type) {
_ = try container.decode(String.self, forKey: .type)
self.data = try container.decode([Entry].self, forKey: .data)
self.summary = try container.decodeIfPresent(Summary.self, forKey: .summary)
return
}
self.data = try container.decode([Entry].self, forKey: .daily)
if container.contains(.totals) {
let totals = try container.decode(CostUsageLegacyTotals.self, forKey: .totals)
self.summary = Summary(
totalInputTokens: totals.totalInputTokens,
totalOutputTokens: totals.totalOutputTokens,
cacheReadTokens: totals.cacheReadTokens,
cacheCreationTokens: totals.cacheCreationTokens,
totalTokens: totals.totalTokens,
totalCostUSD: totals.totalCost)
} else {
self.summary = nil
}
}
public init(data: [Entry], summary: Summary?) {
self.data = data
self.summary = summary
}
}
extension CostUsageDailyReport {
private struct BreakdownAccumulator {
var totalTokens: Int = 0
var sawTotalTokens = false
var costUSD: Double = 0
var sawCost = false
var standardCostUSD: Double = 0
var sawStandardCost = false
var priorityCostUSD: Double = 0
var sawPriorityCost = false
var standardTokens: Int = 0
var sawStandardTokens = false
var priorityTokens: Int = 0
var sawPriorityTokens = false
mutating func add(_ breakdown: ModelBreakdown) {
if let totalTokens = breakdown.totalTokens {
self.totalTokens += totalTokens
self.sawTotalTokens = true
}
if let costUSD = breakdown.costUSD {
self.costUSD += costUSD
self.sawCost = true
}
if let standardCostUSD = breakdown.standardCostUSD {
self.standardCostUSD += standardCostUSD
self.sawStandardCost = true
}
if let priorityCostUSD = breakdown.priorityCostUSD {
self.priorityCostUSD += priorityCostUSD
self.sawPriorityCost = true
}
if let standardTokens = breakdown.standardTokens {
self.standardTokens += standardTokens
self.sawStandardTokens = true
}
if let priorityTokens = breakdown.priorityTokens {
self.priorityTokens += priorityTokens
self.sawPriorityTokens = true
}
}
func build(modelName: String) -> ModelBreakdown {
ModelBreakdown(
modelName: modelName,
costUSD: self.sawCost ? self.costUSD : nil,
totalTokens: self.sawTotalTokens ? self.totalTokens : nil,
standardCostUSD: self.sawStandardCost ? self.standardCostUSD : nil,
priorityCostUSD: self.sawPriorityCost ? self.priorityCostUSD : nil,
standardTokens: self.sawStandardTokens ? self.standardTokens : nil,
priorityTokens: self.sawPriorityTokens ? self.priorityTokens : nil)
}
}
private struct EntryAccumulator {
var inputTokens: Int = 0
var sawInputTokens = false
var cacheReadTokens: Int = 0
var sawCacheReadTokens = false
var cacheCreationTokens: Int = 0
var sawCacheCreationTokens = false
var outputTokens: Int = 0
var sawOutputTokens = false
var totalTokens: Int = 0
var sawTotalTokens = false
var derivedTotalTokensWithoutExplicitTotal: Int = 0
var costUSD: Double = 0
var sawCost = false
var modelsUsed: Set<String> = []
var breakdowns: [String: BreakdownAccumulator] = [:]
mutating func add(_ entry: Entry) {
let entryDerivedTotalTokens = (entry.inputTokens ?? 0)
+ (entry.cacheReadTokens ?? 0)
+ (entry.cacheCreationTokens ?? 0)
+ (entry.outputTokens ?? 0)
if let inputTokens = entry.inputTokens {
self.inputTokens += inputTokens
self.sawInputTokens = true
}
if let cacheReadTokens = entry.cacheReadTokens {
self.cacheReadTokens += cacheReadTokens
self.sawCacheReadTokens = true
}
if let cacheCreationTokens = entry.cacheCreationTokens {
self.cacheCreationTokens += cacheCreationTokens
self.sawCacheCreationTokens = true
}
if let outputTokens = entry.outputTokens {
self.outputTokens += outputTokens
self.sawOutputTokens = true
}
if let totalTokens = entry.totalTokens {
self.totalTokens += totalTokens
self.sawTotalTokens = true
} else if entryDerivedTotalTokens > 0 {
self.derivedTotalTokensWithoutExplicitTotal += entryDerivedTotalTokens
}
if let costUSD = entry.costUSD {
self.costUSD += costUSD
self.sawCost = true
}
if let modelsUsed = entry.modelsUsed {
self.modelsUsed.formUnion(modelsUsed)
}
if let modelBreakdowns = entry.modelBreakdowns {
for breakdown in modelBreakdowns {
var accumulator = self.breakdowns[breakdown.modelName] ?? BreakdownAccumulator()
accumulator.add(breakdown)
self.breakdowns[breakdown.modelName] = accumulator
self.modelsUsed.insert(breakdown.modelName)
}
}
}
func build(date: String) -> Entry {
let derivedTotalTokens = self.inputTokens
+ self.cacheReadTokens
+ self.cacheCreationTokens
+ self.outputTokens
let totalTokens: Int? = if self.sawTotalTokens {
self.totalTokens + self.derivedTotalTokensWithoutExplicitTotal
} else if derivedTotalTokens > 0 {
derivedTotalTokens
} else {
nil
}
let modelBreakdowns: [ModelBreakdown]? = {
guard !self.breakdowns.isEmpty else { return nil }
return CostUsageDailyReport.sortedModelBreakdowns(
self.breakdowns
.map { modelName, accumulator in
accumulator.build(modelName: modelName)
})
}()
let modelsUsed = self.modelsUsed.isEmpty ? nil : self.modelsUsed.sorted()
return Entry(
date: date,
inputTokens: self.sawInputTokens ? self.inputTokens : nil,
outputTokens: self.sawOutputTokens ? self.outputTokens : nil,
cacheReadTokens: self.sawCacheReadTokens ? self.cacheReadTokens : nil,
cacheCreationTokens: self.sawCacheCreationTokens ? self.cacheCreationTokens : nil,
totalTokens: totalTokens,
costUSD: self.sawCost ? self.costUSD : nil,
modelsUsed: modelsUsed,
modelBreakdowns: modelBreakdowns)
}
}
public func merged(with other: CostUsageDailyReport) -> CostUsageDailyReport {
Self.merged([self, other])
}
public static func merged(_ reports: [CostUsageDailyReport]) -> CostUsageDailyReport {
let entries = self.mergedEntries(from: reports)
guard !entries.isEmpty else { return CostUsageDailyReport(data: [], summary: nil) }
return CostUsageDailyReport(data: entries, summary: self.mergedSummary(from: entries))
}
private static func mergedEntries(from reports: [CostUsageDailyReport]) -> [Entry] {
var dayAccumulators: [String: EntryAccumulator] = [:]
for report in reports {
for entry in report.data {
var accumulator = dayAccumulators[entry.date] ?? EntryAccumulator()
accumulator.add(entry)
dayAccumulators[entry.date] = accumulator
}
}
return dayAccumulators
.keys
.sorted()
.map { date in
dayAccumulators[date, default: EntryAccumulator()].build(date: date)
}
}
private static func mergedSummary(from entries: [Entry]) -> Summary {
var totalInputTokens = 0
var sawTotalInputTokens = false
var totalOutputTokens = 0
var sawTotalOutputTokens = false
var totalCacheReadTokens = 0
var sawTotalCacheReadTokens = false
var totalCacheCreationTokens = 0
var sawTotalCacheCreationTokens = false
var totalTokens = 0
var sawTotalTokens = false
var totalCostUSD = 0.0
var sawTotalCostUSD = false
for entry in entries {
if let inputTokens = entry.inputTokens {
totalInputTokens += inputTokens
sawTotalInputTokens = true
}
if let outputTokens = entry.outputTokens {
totalOutputTokens += outputTokens
sawTotalOutputTokens = true
}
if let cacheReadTokens = entry.cacheReadTokens {
totalCacheReadTokens += cacheReadTokens
sawTotalCacheReadTokens = true
}
if let cacheCreationTokens = entry.cacheCreationTokens {
totalCacheCreationTokens += cacheCreationTokens
sawTotalCacheCreationTokens = true
}
if let entryTotalTokens = entry.totalTokens {
totalTokens += entryTotalTokens
sawTotalTokens = true
}
if let costUSD = entry.costUSD {
totalCostUSD += costUSD
sawTotalCostUSD = true
}
}
return Summary(
totalInputTokens: sawTotalInputTokens ? totalInputTokens : nil,
totalOutputTokens: sawTotalOutputTokens ? totalOutputTokens : nil,
cacheReadTokens: sawTotalCacheReadTokens ? totalCacheReadTokens : nil,
cacheCreationTokens: sawTotalCacheCreationTokens ? totalCacheCreationTokens : nil,
totalTokens: sawTotalTokens ? totalTokens : nil,
totalCostUSD: sawTotalCostUSD ? totalCostUSD : nil)
}
private static func sortedModelBreakdowns(_ breakdowns: [ModelBreakdown]) -> [ModelBreakdown] {
breakdowns.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
}
}
}
public struct CostUsageSessionReport: Sendable, Decodable {
public struct Entry: Sendable, Decodable, Equatable {
public let session: String
public let inputTokens: Int?
public let outputTokens: Int?
public let totalTokens: Int?
public let costUSD: Double?
public let lastActivity: String?
private enum CodingKeys: String, CodingKey {
case session
case sessionId
case inputTokens
case outputTokens
case totalTokens
case costUSD
case totalCost
case lastActivity
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.session =
try container.decodeIfPresent(String.self, forKey: .session)
?? container.decode(String.self, forKey: .sessionId)
self.inputTokens = try container.decodeIfPresent(Int.self, forKey: .inputTokens)
self.outputTokens = try container.decodeIfPresent(Int.self, forKey: .outputTokens)
self.totalTokens = try container.decodeIfPresent(Int.self, forKey: .totalTokens)
self.costUSD =
try container.decodeIfPresent(Double.self, forKey: .costUSD)
?? container.decodeIfPresent(Double.self, forKey: .totalCost)
self.lastActivity = try container.decodeIfPresent(String.self, forKey: .lastActivity)
}
}
public struct Summary: Sendable, Decodable, Equatable {
public let totalCostUSD: Double?
private enum CodingKeys: String, CodingKey {
case totalCostUSD
case totalCost
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.totalCostUSD =
try container.decodeIfPresent(Double.self, forKey: .totalCostUSD)
?? container.decodeIfPresent(Double.self, forKey: .totalCost)
}
}
public let data: [Entry]
public let summary: Summary?
private enum CodingKeys: String, CodingKey {
case type
case data
case summary
case sessions
case totals
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
if container.contains(.type) {
_ = try container.decode(String.self, forKey: .type)
self.data = try container.decode([Entry].self, forKey: .data)
self.summary = try container.decodeIfPresent(Summary.self, forKey: .summary)
return
}
self.data = try container.decode([Entry].self, forKey: .sessions)
self.summary = try container.decodeIfPresent(Summary.self, forKey: .totals)
}
}
public struct CostUsageMonthlyReport: Sendable, Decodable {
public struct Entry: Sendable, Decodable, Equatable {
public let month: String
public let totalTokens: Int?
public let costUSD: Double?
private enum CodingKeys: String, CodingKey {
case month
case totalTokens
case costUSD
case totalCost
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.month = try container.decode(String.self, forKey: .month)
self.totalTokens = try container.decodeIfPresent(Int.self, forKey: .totalTokens)
self.costUSD =
try container.decodeIfPresent(Double.self, forKey: .costUSD)
?? container.decodeIfPresent(Double.self, forKey: .totalCost)
}
}
public struct Summary: Sendable, Decodable, Equatable {
public let totalTokens: Int?
public let totalCostUSD: Double?
private enum CodingKeys: String, CodingKey {
case totalTokens
case costUSD
case totalCostUSD
case totalCost
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.totalTokens = try container.decodeIfPresent(Int.self, forKey: .totalTokens)
self.totalCostUSD =
try container.decodeIfPresent(Double.self, forKey: .totalCostUSD)
?? container.decodeIfPresent(Double.self, forKey: .costUSD)
?? container.decodeIfPresent(Double.self, forKey: .totalCost)
}
}
public let data: [Entry]
public let summary: Summary?
private enum CodingKeys: String, CodingKey {
case type
case data
case summary
case monthly
case totals
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
if container.contains(.type) {
_ = try container.decode(String.self, forKey: .type)
self.data = try container.decode([Entry].self, forKey: .data)
self.summary = try container.decodeIfPresent(Summary.self, forKey: .summary)
return
}
self.data = try container.decode([Entry].self, forKey: .monthly)
self.summary = try container.decodeIfPresent(Summary.self, forKey: .totals)
}
}
private struct CostUsageLegacyTotals: Decodable {
let totalInputTokens: Int?
let totalOutputTokens: Int?
let cacheReadTokens: Int?
let cacheCreationTokens: Int?
let totalTokens: Int?
let totalCost: Double?
private enum CodingKeys: String, CodingKey {
case totalInputTokens
case totalOutputTokens
case cacheReadTokens
case cacheCreationTokens
case totalCacheReadTokens
case totalCacheCreationTokens
case totalTokens
case totalCost
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.totalInputTokens = try container.decodeIfPresent(Int.self, forKey: .totalInputTokens)
self.totalOutputTokens = try container.decodeIfPresent(Int.self, forKey: .totalOutputTokens)
self.cacheReadTokens =
try container.decodeIfPresent(Int.self, forKey: .cacheReadTokens)
?? container.decodeIfPresent(Int.self, forKey: .totalCacheReadTokens)
self.cacheCreationTokens =
try container.decodeIfPresent(Int.self, forKey: .cacheCreationTokens)
?? container.decodeIfPresent(Int.self, forKey: .totalCacheCreationTokens)
self.totalTokens = try container.decodeIfPresent(Int.self, forKey: .totalTokens)
self.totalCost = try container.decodeIfPresent(Double.self, forKey: .totalCost)
}
}
private struct CostUsageAnyCodingKey: CodingKey {
var intValue: Int?
var stringValue: String
init?(intValue: Int) {
self.intValue = intValue
self.stringValue = "\(intValue)"
}
init?(stringValue: String) {
self.stringValue = stringValue
self.intValue = nil
}
}
enum CostUsageDateParser {
private static let isoWithFractionalSecondsKey = "CostUsageDateParser.isoWithFractionalSeconds"
private static let isoInternetDateTimeKey = "CostUsageDateParser.isoInternetDateTime"
private static let dayFormatterKey = "CostUsageDateParser.dayFormatter"
private static let monthDayYearFormatterKey = "CostUsageDateParser.monthDayYearFormatter"
private static let monthYearFormatterKey = "CostUsageDateParser.monthYearFormatter"
private static let fullMonthYearFormatterKey = "CostUsageDateParser.fullMonthYearFormatter"
private static let yearMonthFormatterKey = "CostUsageDateParser.yearMonthFormatter"
static func parse(_ text: String?) -> Date? {
guard let text, !text.isEmpty else { return nil }
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
if let d = self.isoFormatter(
key: self.isoWithFractionalSecondsKey,
options: [.withInternetDateTime, .withFractionalSeconds])
.date(from: trimmed)
{ return d }
if let d = self.isoFormatter(key: self.isoInternetDateTimeKey, options: [.withInternetDateTime])
.date(from: trimmed)
{ return d }
if let d = self.dateFormatter(key: self.dayFormatterKey, format: "yyyy-MM-dd").date(from: trimmed) {
return d
}
if let d = self.dateFormatter(key: self.monthDayYearFormatterKey, format: "MMM d, yyyy")
.date(from: trimmed)
{ return d }
return nil
}
static func parseMonth(_ text: String?) -> Date? {
guard let text, !text.isEmpty else { return nil }
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
if let d = self.dateFormatter(key: self.monthYearFormatterKey, format: "MMM yyyy").date(from: trimmed) {
return d
}
if let d = self.dateFormatter(key: self.fullMonthYearFormatterKey, format: "MMMM yyyy").date(from: trimmed) {
return d
}
if let d = self.dateFormatter(key: self.yearMonthFormatterKey, format: "yyyy-MM").date(from: trimmed) {
return d
}
return nil
}
private static func isoFormatter(
key: String,
options: ISO8601DateFormatter.Options) -> ISO8601DateFormatter
{
let threadDict = Thread.current.threadDictionary
if let cached = threadDict[key] as? ISO8601DateFormatter {
return cached
}
let formatter = ISO8601DateFormatter()
formatter.formatOptions = options
threadDict[key] = formatter
return formatter
}
private static func dateFormatter(key: String, format: String) -> DateFormatter {
let threadDict = Thread.current.threadDictionary
let timeZone = TimeZone.current
let cacheKey = "\(key).\(timeZone.identifier)"
if let cached = threadDict[cacheKey] as? DateFormatter {
return cached
}
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = timeZone
formatter.dateFormat = format
formatter.isLenient = false
threadDict[cacheKey] = formatter
return formatter
}
}
enum CostUsageBucketInterval {
static func contains(
_ date: Date,
startTime: Date,
endTime: Date) -> Bool
{
guard startTime < endTime else { return false }
return startTime <= date && date < endTime
}
}
enum CostUsageLocalDay {
static func key(from date: Date, calendar: Calendar = .current) -> String {
let components = calendar.dateComponents([.year, .month, .day], from: date)
let year = components.year ?? 0
let month = components.month ?? 0
let day = components.day ?? 0
return String(format: "%04d-%02d-%02d", year, month, day)
}
}