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

368 lines
15 KiB
Swift

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")
}
}