codeburn/mac/Sources/CodeBurnMenubar/Data/CodexUsage.swift
Richard Boisvert 3e400bffa7
feat(codex): show the credit limit on credit-metered ChatGPT workspaces
Signed-off-by: Richard Boisvert <rboisvert@devolutions.net>
2026-07-27 08:19:59 -04:00

172 lines
6.9 KiB
Swift

import Foundation
/// Codex (ChatGPT-mode) live quota snapshot returned by /backend-api/wham/usage.
/// Two windows are exposed: primary (typically the 5-hour rolling window) and
/// secondary (typically the weekly window). Window size is dynamic per
/// account `limitWindowSeconds` tells us whether it's a 5-hour or 7-day
/// boundary so we can label correctly.
struct CodexUsage: Sendable, Equatable {
enum PlanType: Sendable, Equatable {
case guest, free, go, plus, pro, prolite, freeWorkspace, team
case business, education, quorum, k12, enterprise, edu
/// Captures any plan_type string OpenAI ships that we haven't enumerated
/// yet, so the Settings/Plan UI can still show "Plan: <raw>" instead of
/// a generic "Subscription" placeholder. Preserves forward compatibility
/// without requiring a CodeBurn update for every new tier.
case unknown(String)
var displayName: String {
switch self {
case .guest: "Guest"
case .free: "Free"
case .go: "Go"
case .plus: "Plus"
case .pro: "Pro"
case .prolite: "Pro Lite"
case .freeWorkspace: "Free Workspace"
case .team: "Team"
case .business: "Business"
case .education: "Education"
case .quorum: "Quorum"
case .k12: "K-12"
case .enterprise: "Enterprise"
case .edu: "Edu"
case let .unknown(raw):
raw.isEmpty
? "Subscription"
: raw.replacingOccurrences(of: "_", with: " ")
.replacingOccurrences(of: "-", with: " ")
.capitalized
}
}
}
struct Window: Sendable, Equatable {
let usedPercent: Double // 0.0 ... 100.0
let resetsAt: Date?
let limitWindowSeconds: Int
/// Human label inferred from window size: 5h, 1d, 7d, etc.
var windowLabel: String {
switch limitWindowSeconds {
case 0..<3600: return "Hourly"
case 3600..<7200: return "Hour"
case 18000..<19000: return "5-hour"
case 86400..<87000: return "Daily"
case 604800..<605000: return "Weekly"
default:
let hours = limitWindowSeconds / 3600
if hours < 24 { return "\(hours)-hour" }
return "\(hours / 24)-day"
}
}
}
/// Additional per-model / per-feature quotas exposed by ChatGPT alongside
/// the main rate_limit (e.g. "GPT-5.3-Codex-Spark"). Each entry has its
/// own primary/secondary windows. Only ones with non-zero utilization are
/// surfaced in the popover so users on plans that don't touch these
/// features don't see clutter.
struct AdditionalLimit: Sendable, Equatable {
let name: String
let primary: Window?
let secondary: Window?
}
/// Account-level limit-reset credits: grants that restore a rate-limit
/// window early, each with its own expiry. Only what the popover renders
/// the available count and the soonest expiry among available credits.
struct ResetCredits: Sendable, Equatable {
let availableCount: Int
let nextExpiresAt: Date?
}
/// The monthly allowance an admin sets. Credit-metered workspaces report
/// `rate_limit: null`, so this is their only limit.
struct CreditLimit: Sendable, Equatable {
let used: Double
let limit: Double
let usedPercent: Double // 0.0 ... 100.0
let resetsAt: Date?
/// Calendar month the allowance resets on, for pace projection. Not the
/// payload's `reset_after_seconds`, which is the time remaining.
let windowSeconds: Int?
/// Allowance already spent: a hard stop, not a near-limit warning.
let reached: Bool
/// `.halfUp` matches the desktop decoder's `Math.round`.
var displayLabel: String {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.maximumFractionDigits = 0
formatter.roundingMode = .halfUp
// `en_US`, not `en_US_POSIX`: the latter drops grouping entirely.
formatter.locale = Locale(identifier: "en_US")
func text(_ value: Double) -> String {
formatter.string(from: NSNumber(value: value)) ?? "\(Int(value.rounded()))"
}
let base = "Monthly usage limit · \(text(used)) / \(text(limit)) credits"
return reached ? "\(base) · limit reached" : base
}
var shortLabel: String {
reached ? "Monthly usage limit · limit reached" : "Monthly usage limit"
}
}
let plan: PlanType
let primary: Window?
let secondary: Window?
let additionalLimits: [AdditionalLimit]
let creditsBalance: Double?
/// Account settles in credits, not dollars, which changes `creditsBalance`.
let hasCredits: Bool
/// Uncapped on purpose, as distinct from a limit we failed to read.
let creditsUnlimited: Bool
let creditLimit: CreditLimit?
let resetCredits: ResetCredits?
let fetchedAt: Date
static func planType(from raw: String?) -> PlanType {
guard let original = raw?.lowercased() else { return .unknown("") }
let raw = normalizePlanType(original)
switch raw {
case "guest": return .guest
case "free": return .free
case "go": return .go
case "plus": return .plus
case "pro": return .pro
case "prolite", "pro_lite", "pro-lite": return .prolite
case "free_workspace": return .freeWorkspace
case "team": return .team
case "business": return .business
case "education": return .education
case "quorum": return .quorum
case "k12": return .k12
case "enterprise": return .enterprise
case "edu": return .edu
// Normalized, so an unknown composite reads "Some Future Tier".
default: return .unknown(raw)
}
}
/// Credit-based-pricing tiers arrive composite (`enterprise_cbp_usage_based`).
private static func normalizePlanType(_ raw: String) -> String {
var value = raw.trimmingCharacters(in: .whitespacesAndNewlines)
for suffix in ["_usage_based", "-usage-based", "_usage-based", "-usage_based"]
where value.hasSuffix(suffix) {
value.removeLast(suffix.count)
}
for prefix in ["self_serve_", "self-serve-", "self_serve-", "self-serve_"]
where value.hasPrefix(prefix) {
value.removeFirst(prefix.count)
}
for suffix in ["_cbp", "-cbp"] where value.hasSuffix(suffix) {
value.removeLast(suffix.count)
}
for infix in ["_cbp_", "-cbp-", "_cbp-", "-cbp_"] {
value = value.replacingOccurrences(of: infix, with: "_")
}
return value
}
}