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? public let creditsRemaining: Double? 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, creditsRemaining: Double? = nil, accountPlan: String? = nil, updatedAt: Date) { self.signedInEmail = signedInEmail self.codeReviewRemainingPercent = codeReviewRemainingPercent self.codeReviewLimit = codeReviewLimit self.creditEvents = creditEvents self.dailyBreakdown = dailyBreakdown self.usageBreakdown = usageBreakdown self.creditsPurchaseURL = creditsPurchaseURL self.primaryLimit = primaryLimit self.secondaryLimit = secondaryLimit self.creditsRemaining = creditsRemaining 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 creditsRemaining 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) self.usageBreakdown = try container.decodeIfPresent( [OpenAIDashboardDailyBreakdown].self, forKey: .usageBreakdown) ?? [] 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) self.creditsRemaining = try container.decodeIfPresent(Double.self, forKey: .creditsRemaining) 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 let creditsRemaining else { return nil } return CreditsSnapshot(remaining: creditsRemaining, events: self.creditEvents, updatedAt: self.updatedAt) }} 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 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 { 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? { 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") }}
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?
public let creditsRemaining: Double?
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,
creditsRemaining: Double? = nil,
accountPlan: String? = nil,
updatedAt: Date)
{
self.signedInEmail = signedInEmail
self.codeReviewRemainingPercent = codeReviewRemainingPercent
self.codeReviewLimit = codeReviewLimit
self.creditEvents = creditEvents
self.dailyBreakdown = dailyBreakdown
self.usageBreakdown = usageBreakdown
self.creditsPurchaseURL = creditsPurchaseURL
self.primaryLimit = primaryLimit
self.secondaryLimit = secondaryLimit
self.creditsRemaining = creditsRemaining
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 creditsRemaining
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)
self.usageBreakdown = try container.decodeIfPresent(
[OpenAIDashboardDailyBreakdown].self,
forKey: .usageBreakdown) ?? []
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)
self.creditsRemaining = try container.decodeIfPresent(Double.self, forKey: .creditsRemaining)
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 let creditsRemaining else { return nil }
return CreditsSnapshot(remaining: creditsRemaining, events: self.creditEvents, updatedAt: self.updatedAt)
}
}
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 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 {
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? {
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")
}
}