import Foundation public struct OpenAIDashboardSnapshot: Codable, Equatable, Sendable { public let signedInEmail: String? public let codeReviewRemainingPercent: Double? public let codeReviewLimit: RateWindow? public let creditEvents: [CreditEvent] public let dailyBreakdown: [OpenAIDashboardDailyBreakdown] /// Usage breakdown time series from the Codex dashboard chart ("Usage breakdown", 30 days). /// /// This is distinct from `dailyBreakdown`, which is derived from `creditEvents` (credits usage history table). public let usageBreakdown: [OpenAIDashboardDailyBreakdown] public let creditsPurchaseURL: String? public let primaryLimit: RateWindow? public let secondaryLimit: RateWindow? /// Named model-specific limits (e.g. Codex Spark) decoded from the dashboard /// `wham/usage` response's `additional_rate_limits` array. public let extraRateWindows: [NamedRateWindow]? public let creditsRemaining: Double? public let codexCreditLimit: CodexCreditLimitSnapshot? public let accountPlan: String? public let updatedAt: Date public init( signedInEmail: String?, codeReviewRemainingPercent: Double?, codeReviewLimit: RateWindow? = nil, creditEvents: [CreditEvent], dailyBreakdown: [OpenAIDashboardDailyBreakdown], usageBreakdown: [OpenAIDashboardDailyBreakdown], creditsPurchaseURL: String?, primaryLimit: RateWindow? = nil, secondaryLimit: RateWindow? = nil, extraRateWindows: [NamedRateWindow]? = nil, creditsRemaining: Double? = nil, codexCreditLimit: CodexCreditLimitSnapshot? = nil, accountPlan: String? = nil, updatedAt: Date) { self.signedInEmail = signedInEmail self.codeReviewRemainingPercent = codeReviewRemainingPercent self.codeReviewLimit = codeReviewLimit self.creditEvents = creditEvents self.dailyBreakdown = dailyBreakdown self.usageBreakdown = OpenAIDashboardDailyBreakdown.removingSkillUsageServices(from: usageBreakdown) self.creditsPurchaseURL = creditsPurchaseURL self.primaryLimit = primaryLimit self.secondaryLimit = secondaryLimit self.extraRateWindows = extraRateWindows self.creditsRemaining = creditsRemaining self.codexCreditLimit = codexCreditLimit self.accountPlan = accountPlan self.updatedAt = updatedAt } private enum CodingKeys: String, CodingKey { case signedInEmail case codeReviewRemainingPercent case codeReviewLimit case creditEvents case dailyBreakdown case usageBreakdown case creditsPurchaseURL case primaryLimit case secondaryLimit case extraRateWindows case creditsRemaining case codexCreditLimit case accountPlan case updatedAt } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.signedInEmail = try container.decodeIfPresent(String.self, forKey: .signedInEmail) self.codeReviewRemainingPercent = try container.decodeIfPresent( Double.self, forKey: .codeReviewRemainingPercent) self.codeReviewLimit = try container.decodeIfPresent(RateWindow.self, forKey: .codeReviewLimit) self.creditEvents = try container.decodeIfPresent([CreditEvent].self, forKey: .creditEvents) ?? [] self.dailyBreakdown = try container.decodeIfPresent( [OpenAIDashboardDailyBreakdown].self, forKey: .dailyBreakdown) ?? Self.makeDailyBreakdown(from: self.creditEvents, maxDays: 30) let decodedUsageBreakdown = try container.decodeIfPresent( [OpenAIDashboardDailyBreakdown].self, forKey: .usageBreakdown) ?? [] self.usageBreakdown = OpenAIDashboardDailyBreakdown.removingSkillUsageServices( from: decodedUsageBreakdown) self.creditsPurchaseURL = try container.decodeIfPresent(String.self, forKey: .creditsPurchaseURL) self.primaryLimit = try container.decodeIfPresent(RateWindow.self, forKey: .primaryLimit) self.secondaryLimit = try container.decodeIfPresent(RateWindow.self, forKey: .secondaryLimit) // Backward-compatible: older cached snapshots simply lack the key and decode to nil. self.extraRateWindows = try container.decodeIfPresent( [NamedRateWindow].self, forKey: .extraRateWindows) self.creditsRemaining = try container.decodeIfPresent(Double.self, forKey: .creditsRemaining) self.codexCreditLimit = try container.decodeIfPresent(CodexCreditLimitSnapshot.self, forKey: .codexCreditLimit) self.accountPlan = try container.decodeIfPresent(String.self, forKey: .accountPlan) self.updatedAt = try container.decode(Date.self, forKey: .updatedAt) } public static func makeDailyBreakdown(from events: [CreditEvent], maxDays: Int) -> [OpenAIDashboardDailyBreakdown] { guard !events.isEmpty else { return [] } let calendar = Calendar(identifier: .gregorian) let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.calendar = calendar formatter.timeZone = TimeZone.current formatter.dateFormat = "yyyy-MM-dd" var totals: [String: [String: Double]] = [:] // day -> service -> credits for event in events { let day = formatter.string(from: event.date) totals[day, default: [:]][event.service, default: 0] += event.creditsUsed } let sortedDays = totals.keys.sorted(by: >).prefix(maxDays) return sortedDays.map { day in let serviceTotals = totals[day] ?? [:] let services = serviceTotals .map { OpenAIDashboardServiceUsage(service: $0.key, creditsUsed: $0.value) } .sorted { lhs, rhs in if lhs.creditsUsed == rhs.creditsUsed { return lhs.service < rhs.service } return lhs.creditsUsed > rhs.creditsUsed } let total = services.reduce(0) { $0 + $1.creditsUsed } return OpenAIDashboardDailyBreakdown(day: day, services: services, totalCreditsUsed: total) } } } extension OpenAIDashboardSnapshot { public func toUsageSnapshot( provider: UsageProvider = .codex, accountEmail: String? = nil, accountPlan: String? = nil) -> UsageSnapshot? { CodexReconciledState.fromAttachedDashboard( snapshot: self, provider: provider, accountEmail: accountEmail, accountPlan: accountPlan)? .toUsageSnapshot() } public func toCreditsSnapshot() -> CreditsSnapshot? { guard self.creditsRemaining != nil || self.codexCreditLimit != nil else { return nil } return CreditsSnapshot( remaining: self.creditsRemaining ?? 0, events: self.creditEvents, updatedAt: self.updatedAt, codexCreditLimit: self.codexCreditLimit) } } public struct OpenAIDashboardDailyBreakdown: Codable, Equatable, Sendable { /// Day key in `yyyy-MM-dd` (local time). public let day: String public let services: [OpenAIDashboardServiceUsage] public let totalCreditsUsed: Double public init(day: String, services: [OpenAIDashboardServiceUsage], totalCreditsUsed: Double) { self.day = day self.services = services self.totalCreditsUsed = totalCreditsUsed } public static func isSkillUsageService(_ service: String) -> Bool { service .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() .hasPrefix("skillusage:") } public static func removingSkillUsageServices( from breakdown: [OpenAIDashboardDailyBreakdown]) -> [OpenAIDashboardDailyBreakdown] { breakdown.compactMap { day in guard !day.services.isEmpty else { return day.totalCreditsUsed > 0 ? day : nil } let services = day.services.filter { !self.isSkillUsageService($0.service) } guard !services.isEmpty else { return nil } let total = services.reduce(0) { $0 + $1.creditsUsed } return OpenAIDashboardDailyBreakdown( day: day.day, services: services, totalCreditsUsed: total) } } public static func recentUsageSummary( from breakdown: [OpenAIDashboardDailyBreakdown], historyDays: Int = 30, now: Date = Date(), calendar: Calendar = .current) -> OpenAIDashboardUsageBreakdownSummary { let days = max(1, min(historyDays, 365)) var dayCalendar = Calendar(identifier: .gregorian) dayCalendar.timeZone = calendar.timeZone let today = dayCalendar.startOfDay(for: now) let start = dayCalendar.date(byAdding: .day, value: -(days - 1), to: today) ?? today let startKey = Self.dayKey(from: start, calendar: dayCalendar) let todayKey = Self.dayKey(from: today, calendar: dayCalendar) let recent = self.removingSkillUsageServices(from: breakdown) .compactMap { self.sanitized($0, startKey: startKey, todayKey: todayKey, calendar: dayCalendar) } .sorted { $0.day < $1.day } let todayCredits = recent.first(where: { $0.day == todayKey })?.totalCreditsUsed ?? (recent.isEmpty ? nil : 0) let totalCredits = self.finiteSum(recent.map(\.totalCreditsUsed)) return OpenAIDashboardUsageBreakdownSummary( historyDays: days, todayCredits: todayCredits, totalCredits: totalCredits, daily: recent) } private static func sanitized( _ day: OpenAIDashboardDailyBreakdown, startKey: String, todayKey: String, calendar: Calendar) -> OpenAIDashboardDailyBreakdown? { guard self.date(fromDayKey: day.day, calendar: calendar) != nil, day.day >= startKey, day.day <= todayKey else { return nil } if day.services.isEmpty { guard day.totalCreditsUsed.isFinite, day.totalCreditsUsed > 0 else { return nil } return day } let services = day.services.filter { $0.creditsUsed.isFinite && $0.creditsUsed > 0 } guard !services.isEmpty, let total = self.finiteSum(services.map(\.creditsUsed)), total > 0 else { return nil } return OpenAIDashboardDailyBreakdown(day: day.day, services: services, totalCreditsUsed: total) } private static func finiteSum(_ values: [Double]) -> Double? { var total = 0.0 for value in values { let next = total + value guard next.isFinite else { return nil } total = next } return values.isEmpty ? nil : total } private static func date(fromDayKey key: String, calendar: Calendar) -> Date? { let parts = key.split(separator: "-", omittingEmptySubsequences: false) guard parts.count == 3, let year = Int(parts[0]), let month = Int(parts[1]), let day = Int(parts[2]) else { return nil } let components = DateComponents( calendar: calendar, timeZone: calendar.timeZone, year: year, month: month, day: day, hour: 12) guard let date = calendar.date(from: components) else { return nil } let resolved = calendar.dateComponents([.year, .month, .day], from: date) guard resolved.year == year, resolved.month == month, resolved.day == day else { return nil } return date } private static func dayKey(from date: Date, calendar: Calendar) -> String { let components = calendar.dateComponents([.year, .month, .day], from: date) return String( format: "%04d-%02d-%02d", components.year ?? 0, components.month ?? 0, components.day ?? 0) } } public struct OpenAIDashboardUsageBreakdownSummary: Equatable, Sendable { public let historyDays: Int public let todayCredits: Double? public let totalCredits: Double? public let daily: [OpenAIDashboardDailyBreakdown] public init( historyDays: Int, todayCredits: Double?, totalCredits: Double?, daily: [OpenAIDashboardDailyBreakdown]) { self.historyDays = historyDays self.todayCredits = todayCredits self.totalCredits = totalCredits self.daily = daily } } public struct OpenAIDashboardServiceUsage: Codable, Equatable, Sendable { public let service: String public let creditsUsed: Double public init(service: String, creditsUsed: Double) { self.service = service self.creditsUsed = creditsUsed } } public struct OpenAIDashboardCache: Codable, Equatable, Sendable { public let accountEmail: String public let snapshot: OpenAIDashboardSnapshot public init(accountEmail: String, snapshot: OpenAIDashboardSnapshot) { self.accountEmail = accountEmail self.snapshot = snapshot } } public enum OpenAIDashboardCacheStore { @TaskLocal static var cacheURLOverride: URL? public static func load() -> OpenAIDashboardCache? { guard let url = self.cacheURL else { return nil } guard let data = try? Data(contentsOf: url) else { return nil } let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 return try? decoder.decode(OpenAIDashboardCache.self, from: data) } public static func save(_ cache: OpenAIDashboardCache) { guard let url = self.cacheURL else { return } do { try FileManager.default.createDirectory( at: url.deletingLastPathComponent(), withIntermediateDirectories: true) let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 let data = try encoder.encode(cache) try data.write(to: url, options: [.atomic]) } catch { // Best-effort cache only; ignore errors. } } public static func clear() { guard let url = self.cacheURL else { return } try? FileManager.default.removeItem(at: url) } private static var cacheURL: URL? { if let cacheURLOverride { return cacheURLOverride } guard let root = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return nil } let dir = root.appendingPathComponent("com.steipete.codexbar", isDirectory: true) return dir.appendingPathComponent("openai-dashboard.json") } }