Files
2026-07-13 12:22:33 +08:00

1046 lines
35 KiB
Swift

import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
public enum ChutesUsageError: LocalizedError, Sendable {
case missingCredentials
case invalidCredentials
case invalidURL
case apiError(String)
case parseFailed(String)
public var errorDescription: String? {
switch self {
case .missingCredentials:
"Missing Chutes API key. Set apiKey in ~/.codexbar/config.json or CHUTES_API_KEY."
case .invalidCredentials:
"Chutes API key was rejected. Check the API key in Settings."
case .invalidURL:
"Chutes usage URL is invalid."
case let .apiError(message):
"Chutes usage API error: \(message)"
case let .parseFailed(message):
"Chutes usage parse error: \(message)"
}
}
}
public enum ChutesSubscriptionState: String, Sendable, Codable {
case active
case inactive
case unknown
}
public struct ChutesQuotaWindow: Sendable, Equatable {
public let label: String?
public let used: Double?
public let limit: Double?
public let remaining: Double?
public let usedPercent: Double?
public let windowMinutes: Int?
public let resetsAt: Date?
public let unit: String?
public init(
label: String?,
used: Double?,
limit: Double?,
remaining: Double?,
usedPercent: Double?,
windowMinutes: Int?,
resetsAt: Date?,
unit: String? = nil)
{
self.label = label
self.used = used
self.limit = limit
self.remaining = remaining
self.usedPercent = usedPercent
self.windowMinutes = windowMinutes
self.resetsAt = resetsAt
self.unit = unit
}
public func rateWindow(defaultWindowMinutes: Int?) -> RateWindow? {
guard let percent = self.usagePercent else { return nil }
return RateWindow(
usedPercent: max(0, min(percent, 100)),
windowMinutes: self.windowMinutes ?? defaultWindowMinutes,
resetsAt: self.resetsAt,
resetDescription: self.usageDescription)
}
public var usagePercent: Double? {
if let usedPercent { return max(0, min(usedPercent, 100)) }
var used = self.used
var limit = self.limit
var remaining = self.remaining
if limit == nil, let used, let remaining {
limit = used + remaining
}
if used == nil, let limit, let remaining {
used = limit - remaining
}
if remaining == nil, let limit, let used {
remaining = max(0, limit - used)
}
guard let used, let limit, limit > 0 else { return nil }
return (used / limit) * 100
}
private var usageDescription: String? {
guard let limit, limit > 0 else { return nil }
let used: Double
if let explicitUsed = self.used {
used = explicitUsed
} else if let remaining {
used = max(0, limit - remaining)
} else {
return nil
}
let unit = self.unit?.trimmingCharacters(in: .whitespacesAndNewlines)
let suffix = if let unit, !unit.isEmpty {
" \(unit)"
} else {
""
}
return "\(Self.formatAmount(used))/\(Self.formatAmount(limit))\(suffix)"
}
private static func formatAmount(_ value: Double) -> String {
let rounded = value.rounded()
if abs(value - rounded) < 0.0001 {
return String(Int(rounded))
}
var text = String(format: "%.2f", value)
while text.contains("."), text.hasSuffix("0") {
text.removeLast()
}
if text.hasSuffix(".") {
text.removeLast()
}
return text
}
}
public struct ChutesUsageSnapshot: Sendable, Equatable {
public let rollingWindow: ChutesQuotaWindow?
public let monthlyWindow: ChutesQuotaWindow?
public let fallbackWindows: [ChutesQuotaWindow]
public let subscriptionState: ChutesSubscriptionState
public let planName: String?
public let subscriptionRenewsAt: Date?
public let updatedAt: Date
public init(
rollingWindow: ChutesQuotaWindow?,
monthlyWindow: ChutesQuotaWindow?,
fallbackWindows: [ChutesQuotaWindow] = [],
subscriptionState: ChutesSubscriptionState,
planName: String?,
subscriptionRenewsAt: Date?,
updatedAt: Date)
{
self.rollingWindow = rollingWindow
self.monthlyWindow = monthlyWindow
self.fallbackWindows = fallbackWindows
self.subscriptionState = subscriptionState
self.planName = planName
self.subscriptionRenewsAt = subscriptionRenewsAt
self.updatedAt = updatedAt
}
public var hasUsageData: Bool {
self.toUsageSnapshot().hasRateLimitWindows
}
public func toUsageSnapshot() -> UsageSnapshot {
let fallbackRateWindows = self.fallbackWindows.compactMap {
$0.rateWindow(defaultWindowMinutes: $0.windowMinutes)
}
let monthly = self.monthlyWindow?.rateWindow(defaultWindowMinutes: Self.monthlyWindowMinutes)
let rolling = self.rollingWindow?.rateWindow(defaultWindowMinutes: Self.rollingWindowMinutes)
let primary = rolling ?? (monthly == nil ? fallbackRateWindows.first : nil)
let secondary: RateWindow? = if let monthly {
monthly
} else if rolling != nil {
fallbackRateWindows.first
} else if primary != nil {
fallbackRateWindows.dropFirst().first
} else {
nil
}
let identity = ProviderIdentitySnapshot(
providerID: .chutes,
accountEmail: nil,
accountOrganization: nil,
loginMethod: self.loginMethod(hasWindows: primary != nil || secondary != nil))
return UsageSnapshot(
primary: primary,
secondary: secondary,
tertiary: nil,
providerCost: nil,
subscriptionRenewsAt: self.subscriptionRenewsAt ?? monthly?.resetsAt,
updatedAt: self.updatedAt,
identity: identity)
}
func preservingSubscriptionContext(from fallback: ChutesUsageSnapshot) -> ChutesUsageSnapshot {
ChutesUsageSnapshot(
rollingWindow: fallback.rollingWindow ?? self.rollingWindow,
monthlyWindow: fallback.monthlyWindow ?? self.monthlyWindow,
fallbackWindows: fallback.fallbackWindows + self.fallbackWindows,
subscriptionState: fallback.subscriptionState,
planName: fallback.planName,
subscriptionRenewsAt: fallback.subscriptionRenewsAt,
updatedAt: fallback.updatedAt)
}
private func loginMethod(hasWindows: Bool) -> String? {
if let planName = self.planName?.trimmingCharacters(in: .whitespacesAndNewlines), !planName.isEmpty {
return planName
}
switch self.subscriptionState {
case .active:
return nil
case .inactive:
return "No active subscription"
case .unknown:
return hasWindows ? nil : "No usage data"
}
}
static let rollingWindowMinutes = 4 * 60
static let monthlyWindowMinutes = 30 * 24 * 60
}
public struct ChutesUsageFetcher: Sendable {
private static let requestTimeoutSeconds: TimeInterval = 15
public static func fetchUsage(
apiKey: String,
environment: [String: String] = ProcessInfo.processInfo.environment,
transport: any ProviderHTTPTransport = ProviderHTTPClient.shared,
now: Date = Date()) async throws -> ChutesUsageSnapshot
{
guard let token = ChutesSettingsReader.cleaned(apiKey) else {
throw ChutesUsageError.missingCredentials
}
try ChutesSettingsReader.validateEndpointOverrides(environment: environment)
let baseURL = ChutesSettingsReader.apiURL(environment: environment)
let subscription = try await self.fetchSnapshot(
pathComponents: ["users", "me", "subscription_usage"],
apiKey: token,
baseURL: baseURL,
transport: transport,
now: now)
guard subscription.rollingWindow == nil || subscription.monthlyWindow == nil else {
return subscription
}
do {
let quotas = try await self.fetchQuotaSnapshot(
apiKey: token,
baseURL: baseURL,
transport: transport,
now: now)
return quotas.hasUsageData ? quotas.preservingSubscriptionContext(from: subscription) : subscription
} catch ChutesUsageError.invalidCredentials {
throw ChutesUsageError.invalidCredentials
} catch {
return subscription
}
}
private static func fetchSnapshot(
pathComponents: [String],
apiKey: String,
baseURL: URL,
transport: any ProviderHTTPTransport,
now: Date) async throws -> ChutesUsageSnapshot
{
let data = try await self.fetchData(
pathComponents: pathComponents,
apiKey: apiKey,
baseURL: baseURL,
transport: transport)
do {
return try ChutesUsageParser.parse(data: data, now: now)
} catch let error as ChutesUsageError {
throw error
} catch {
throw ChutesUsageError.parseFailed(error.localizedDescription)
}
}
private static func fetchQuotaSnapshot(
apiKey: String,
baseURL: URL,
transport: any ProviderHTTPTransport,
now: Date) async throws -> ChutesUsageSnapshot
{
let data = try await self.fetchData(
pathComponents: ["users", "me", "quotas"],
apiKey: apiKey,
baseURL: baseURL,
transport: transport)
let fallback = try ChutesUsageParser.parse(data: data, now: now)
let definitions = try self.quotaDefinitions(from: data)
guard !definitions.isEmpty else { return fallback }
var enrichedDefinitions: [[String: Any]] = []
for definition in definitions {
guard let identifier = self.quotaIdentifier(from: definition) else {
enrichedDefinitions.append(definition)
continue
}
do {
let usageData = try await self.fetchData(
pathComponents: ["users", "me", "quota_usage", identifier],
apiKey: apiKey,
baseURL: baseURL,
transport: transport)
guard let usage = try self.responseDictionary(from: usageData) else {
enrichedDefinitions.append(definition)
continue
}
enrichedDefinitions.append(definition.merging(usage) { _, usageValue in usageValue })
} catch ChutesUsageError.invalidCredentials {
throw ChutesUsageError.invalidCredentials
} catch {
enrichedDefinitions.append(definition)
}
}
let enrichedData = try JSONSerialization.data(
withJSONObject: ["quotas": enrichedDefinitions],
options: [])
let enriched = try ChutesUsageParser.parse(data: enrichedData, now: now)
return enriched.hasUsageData ? enriched : fallback
}
private static func fetchData(
pathComponents: [String],
apiKey: String,
baseURL: URL,
transport: any ProviderHTTPTransport) async throws -> Data
{
let url = pathComponents.reduce(baseURL) { partial, component in
partial.appendingPathComponent(component)
}
guard url.scheme?.lowercased() == "https" else {
throw ChutesUsageError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.timeoutInterval = Self.requestTimeoutSeconds
let response = try await transport.response(for: request)
guard (200..<300).contains(response.statusCode) else {
if response.statusCode == 401 || response.statusCode == 403 {
throw ChutesUsageError.invalidCredentials
}
throw ChutesUsageError.apiError("HTTP \(response.statusCode): \(Self.responseSummary(response.data))")
}
return response.data
}
private static func quotaDefinitions(from data: Data) throws -> [[String: Any]] {
let object = try JSONSerialization.jsonObject(with: data, options: [])
if let array = object as? [Any] {
return array.compactMap { $0 as? [String: Any] }
}
guard let dictionary = object as? [String: Any] else { return [] }
if let quotas = dictionary["quotas"] as? [Any] {
return quotas.compactMap { $0 as? [String: Any] }
}
if let data = dictionary["data"] as? [Any] {
return data.compactMap { $0 as? [String: Any] }
}
if let data = dictionary["data"] as? [String: Any],
let quotas = data["quotas"] as? [Any]
{
return quotas.compactMap { $0 as? [String: Any] }
}
return []
}
private static func quotaIdentifier(from definition: [String: Any]) -> String? {
for key in ["chute_id", "chuteId", "id"] {
if let value = definition[key] as? String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { return trimmed }
}
if let value = definition[key] as? NSNumber {
return value.stringValue
}
}
return nil
}
private static func responseDictionary(from data: Data) throws -> [String: Any]? {
guard let dictionary = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else {
return nil
}
return dictionary["data"] as? [String: Any]
?? dictionary["result"] as? [String: Any]
?? dictionary
}
private static func responseSummary(_ data: Data) -> String {
String(bytes: data.prefix(240), encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)
?? ""
}
}
private final class ChutesISO8601FormatterBox: @unchecked Sendable {
let lock = NSLock()
let withFractional: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}()
let plain: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime]
return formatter
}()
}
private enum ChutesTimestampParser {
static let box = ChutesISO8601FormatterBox()
static func parse(_ value: Any?) -> Date? {
switch value {
case let date as Date:
date
case let number as NSNumber:
self.date(fromEpochValue: number.doubleValue)
case let text as String:
self.parseString(text)
default:
nil
}
}
private static func parseString(_ raw: String) -> Date? {
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return nil }
if let number = Double(text) {
return self.date(fromEpochValue: number)
}
self.box.lock.lock()
defer { self.box.lock.unlock() }
return self.box.withFractional.date(from: text) ?? self.box.plain.date(from: text)
}
private static func date(fromEpochValue value: Double) -> Date? {
guard value.isFinite, value > 0 else { return nil }
let seconds = value > 10_000_000_000 ? value / 1000 : value
return Date(timeIntervalSince1970: seconds)
}
}
enum ChutesUsageParser {
static func parse(data: Data, now: Date = Date()) throws -> ChutesUsageSnapshot {
let object = try JSONSerialization.jsonObject(with: data, options: [])
return self.parse(object: object, now: now)
}
private static func parse(object: Any, now: Date) -> ChutesUsageSnapshot {
let root: [String: Any] = {
if let dict = object as? [String: Any] { return dict }
if let array = object as? [Any] { return ["quotas": array] }
return [:]
}()
let dataRoot = self.dictionaryValue(self.value(in: root, keys: ["data", "result"])) ?? root
let subscription = self.subscriptionPayload(root: root, dataRoot: dataRoot)
let explicitRolling = self.firstDictionary(
in: root,
dataRoot: dataRoot,
keys: self.rollingPayloadKeys)
.flatMap {
self.parseQuota(
$0,
defaultLabel: "4-hour quota",
defaultWindowMinutes: ChutesUsageSnapshot.rollingWindowMinutes)
}
let explicitMonthly = self.firstDictionary(
in: root,
dataRoot: dataRoot,
keys: self.monthlyPayloadKeys)
.flatMap {
self.parseQuota(
$0,
defaultLabel: "Monthly quota",
defaultWindowMinutes: ChutesUsageSnapshot.monthlyWindowMinutes)
}
let quotaWindows = self.fallbackQuotaObjects(from: root, dataRoot: dataRoot).compactMap {
self.parseQuota($0, defaultLabel: nil, defaultWindowMinutes: nil)
}
let classifiedRolling = explicitRolling ?? quotaWindows.first { self.kind(for: $0) == .rolling }
let classifiedMonthly = explicitMonthly ?? quotaWindows.first { self.kind(for: $0) == .monthly }
let fallbackWindows = quotaWindows.filter { window in
Optional.some(window) != classifiedRolling && Optional.some(window) != classifiedMonthly
}
return ChutesUsageSnapshot(
rollingWindow: classifiedRolling,
monthlyWindow: classifiedMonthly,
fallbackWindows: fallbackWindows,
subscriptionState: self.subscriptionState(root: root, dataRoot: dataRoot, subscription: subscription),
planName: self.planName(root: root, dataRoot: dataRoot, subscription: subscription),
subscriptionRenewsAt: self.subscriptionRenewsAt(
root: root,
dataRoot: dataRoot,
subscription: subscription),
updatedAt: now)
}
private enum WindowKind {
case rolling
case monthly
}
private static func parseQuota(
_ payload: [String: Any],
defaultLabel: String?,
defaultWindowMinutes: Int?) -> ChutesQuotaWindow?
{
let label = self.firstString(in: payload, keys: self.labelKeys) ?? defaultLabel
let limit = self.firstDouble(in: payload, keys: self.limitKeys)
let used = self.firstDouble(in: payload, keys: self.usedKeys)
let remaining = self.firstDouble(in: payload, keys: self.remainingKeys)
var usedPercent = self.normalizedPercent(self.firstDouble(in: payload, keys: self.percentUsedKeys))
if usedPercent == nil,
let remainingPercent = self.normalizedPercent(self.firstDouble(in: payload, keys: percentRemainingKeys))
{
usedPercent = 100 - remainingPercent
}
let window = self.windowMinutes(from: payload) ?? defaultWindowMinutes
let resetsAt = self.firstDate(in: payload, keys: self.resetKeys)
let unit = self.firstString(in: payload, keys: self.unitKeys) ?? "credits"
let quota = ChutesQuotaWindow(
label: label,
used: used,
limit: limit,
remaining: remaining,
usedPercent: usedPercent,
windowMinutes: window,
resetsAt: resetsAt,
unit: unit)
return quota.usagePercent == nil ? nil : quota
}
private static func subscriptionPayload(root: [String: Any], dataRoot: [String: Any]) -> [String: Any]? {
self.firstDictionary(in: root, dataRoot: dataRoot, keys: [
"subscription",
"subscription_usage",
"subscriptionUsage",
"current_subscription",
"currentSubscription",
"plan",
])
}
private static func subscriptionState(
root: [String: Any],
dataRoot: [String: Any],
subscription: [String: Any]?) -> ChutesSubscriptionState
{
if let active = self.firstBool(in: root, keys: activeKeys)
?? self.firstBool(in: dataRoot, keys: activeKeys)
?? subscription.flatMap({ self.firstBool(in: $0, keys: activeKeys) })
{
return active ? .active : .inactive
}
let status = self.firstString(in: root, keys: self.statusKeys)
?? self.firstString(in: dataRoot, keys: self.statusKeys)
?? subscription.flatMap { self.firstString(in: $0, keys: self.statusKeys) }
let normalizedStatus = status?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard let normalizedStatus, !normalizedStatus.isEmpty else { return .unknown }
if normalizedStatus.contains("active") && !normalizedStatus.contains("inactive") {
return .active
}
if normalizedStatus.contains("free") ||
normalizedStatus.contains("inactive") ||
normalizedStatus.contains("cancel") ||
normalizedStatus.contains("none") ||
normalizedStatus.contains("expired")
{
return .inactive
}
return .unknown
}
private static func planName(
root: [String: Any],
dataRoot: [String: Any],
subscription: [String: Any]?) -> String?
{
self.firstString(in: root, keys: self.planKeys)
?? self.firstString(in: dataRoot, keys: self.planKeys)
?? subscription.flatMap { self.firstString(in: $0, keys: self.planKeys) }
}
private static func subscriptionRenewsAt(
root: [String: Any],
dataRoot: [String: Any],
subscription: [String: Any]?) -> Date?
{
self.firstDate(in: root, keys: self.resetKeys)
?? self.firstDate(in: dataRoot, keys: self.resetKeys)
?? subscription.flatMap { self.firstDate(in: $0, keys: self.resetKeys) }
}
private static func firstDictionary(
in root: [String: Any],
dataRoot: [String: Any],
keys: [String]) -> [String: Any]?
{
self.dictionaryValue(self.value(in: root, keys: keys))
?? self.dictionaryValue(self.value(in: dataRoot, keys: keys))
}
private static func fallbackQuotaObjects(from root: [String: Any], dataRoot: [String: Any]) -> [[String: Any]] {
let candidates: [Any?] = [
self.value(in: root, keys: self.quotaContainerKeys),
self.value(in: dataRoot, keys: self.quotaContainerKeys),
dataRoot,
root,
]
var results: [[String: Any]] = []
for candidate in candidates {
results.append(contentsOf: self.extractQuotaObjects(from: candidate))
}
return self.deduplicated(results)
}
private static func extractQuotaObjects(from candidate: Any?) -> [[String: Any]] {
if let array = candidate as? [Any] {
return array.flatMap { self.extractQuotaObjects(from: $0) }
}
guard let dict = self.dictionaryValue(candidate) else { return [] }
var results: [[String: Any]] = self.isQuotaPayload(dict) ? [dict] : []
for value in dict.values {
results.append(contentsOf: self.extractQuotaObjects(from: value))
}
return results
}
private static func deduplicated(_ objects: [[String: Any]]) -> [[String: Any]] {
var seen: Set<Data> = []
var unique: [[String: Any]] = []
for object in objects {
guard let key = try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) else {
unique.append(object)
continue
}
guard seen.insert(key).inserted else { continue }
unique.append(object)
}
return unique
}
private static func isQuotaPayload(_ payload: [String: Any]) -> Bool {
self.firstDouble(in: payload, keys: self.limitKeys) != nil ||
self.firstDouble(in: payload, keys: self.usedKeys) != nil ||
self.firstDouble(in: payload, keys: self.remainingKeys) != nil ||
self.firstDouble(in: payload, keys: self.percentUsedKeys) != nil ||
self.firstDouble(in: payload, keys: self.percentRemainingKeys) != nil
}
private static func kind(for window: ChutesQuotaWindow) -> WindowKind? {
let label = [
window.label,
window.unit,
]
.compactMap { $0?.lowercased() }
.joined(separator: " ")
if label.contains("rolling") ||
label.contains("4h") ||
label.contains("4 h") ||
label.contains("4-hour") ||
label.contains("four hour") ||
label.contains("four-hour") ||
window.windowMinutes == ChutesUsageSnapshot.rollingWindowMinutes
{
return .rolling
}
if label.contains("month") ||
label.contains("billing") ||
label.contains("subscription") ||
(window.windowMinutes ?? 0) >= 28 * 24 * 60
{
return .monthly
}
return nil
}
private static func windowMinutes(from payload: [String: Any]) -> Int? {
if let minutes = self.firstDouble(in: payload, keys: windowMinuteKeys) {
return Int(minutes.rounded())
}
if let hours = self.firstDouble(in: payload, keys: windowHourKeys) {
return Int((hours * 60).rounded())
}
if let days = self.firstDouble(in: payload, keys: windowDayKeys) {
return Int((days * 24 * 60).rounded())
}
if let seconds = self.firstDouble(in: payload, keys: windowSecondKeys) {
return Int((seconds / 60).rounded())
}
if let text = self.firstString(in: payload, keys: windowStringKeys) {
return self.windowMinutes(fromText: text)
}
return nil
}
static func windowMinutes(fromText raw: String) -> Int? {
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !text.isEmpty else { return nil }
let compact = text.replacingOccurrences(of: " ", with: "")
let scanner = Scanner(string: compact)
guard let value = scanner.scanDouble(), value > 0 else { return nil }
let suffix = String(compact[scanner.currentIndex...])
if suffix.hasPrefix("min") || suffix == "m" {
return Int(value.rounded())
}
if suffix.hasPrefix("hour") || suffix.hasPrefix("hr") || suffix == "h" {
return Int((value * 60).rounded())
}
if suffix.hasPrefix("day") || suffix == "d" {
return Int((value * 24 * 60).rounded())
}
if suffix.hasPrefix("month") || suffix == "mo" {
return Int((value * 30 * 24 * 60).rounded())
}
return nil
}
private static func firstString(in dict: [String: Any], keys: [String]) -> String? {
guard let value = self.value(in: dict, keys: keys) else { return nil }
if let string = value as? String {
let cleaned = string.trimmingCharacters(in: .whitespacesAndNewlines)
return cleaned.isEmpty ? nil : cleaned
}
if let number = value as? NSNumber {
return number.stringValue
}
return nil
}
private static func firstBool(in dict: [String: Any], keys: [String]) -> Bool? {
guard let value = self.value(in: dict, keys: keys) else { return nil }
if let bool = value as? Bool { return bool }
if let number = value as? NSNumber { return number.boolValue }
if let string = value as? String {
switch string.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
case "true", "1", "yes", "active":
return true
case "false", "0", "no", "inactive", "none":
return false
default:
return nil
}
}
return nil
}
private static func firstDouble(in dict: [String: Any], keys: [String]) -> Double? {
self.doubleValue(self.value(in: dict, keys: keys))
}
private static func firstDate(in dict: [String: Any], keys: [String]) -> Date? {
ChutesTimestampParser.parse(self.value(in: dict, keys: keys))
}
private static func normalizedPercent(_ value: Double?) -> Double? {
guard let value, value.isFinite else { return nil }
let percent = abs(value) < 1 ? value * 100 : value
return max(0, min(percent, 100))
}
private static func value(in dict: [String: Any], keys: [String]) -> Any? {
for key in keys {
let normalized = self.normalizedKey(key)
for (candidateKey, value) in dict where self.normalizedKey(candidateKey) == normalized {
return value
}
}
return nil
}
private static func dictionaryValue(_ value: Any?) -> [String: Any]? {
value as? [String: Any]
}
private static func doubleValue(_ value: Any?) -> Double? {
switch value {
case let double as Double:
double.isFinite ? double : nil
case let int as Int:
Double(int)
case let number as NSNumber:
number.doubleValue.isFinite ? number.doubleValue : nil
case let string as String:
self.doubleValue(from: string)
default:
nil
}
}
private static func doubleValue(from raw: String) -> Double? {
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: ",", with: "")
.replacingOccurrences(of: "$", with: "")
.replacingOccurrences(of: "%", with: "")
guard !text.isEmpty, let value = Double(text), value.isFinite else { return nil }
return value
}
private static func normalizedKey(_ key: String) -> String {
key.lowercased().filter { $0.isLetter || $0.isNumber }
}
private static let rollingPayloadKeys = [
"rolling",
"rolling_window",
"rollingWindow",
"rolling_4h",
"rolling4h",
"four_hour",
"fourHour",
"four_hour_usage",
"fourHourUsage",
"window_4h",
"window4h",
]
private static let monthlyPayloadKeys = [
"monthly",
"monthly_usage",
"monthlyUsage",
"subscription",
"subscription_usage",
"subscriptionUsage",
"billing_period",
"billingPeriod",
]
private static let quotaContainerKeys = [
"quotas",
"quota",
"quota_usage",
"quotaUsage",
"limits",
"usage",
"entries",
"subscription_usage",
"subscriptionUsage",
]
private static let labelKeys = [
"label",
"name",
"title",
"type",
"quota_type",
"quotaType",
"period",
"window",
"window_name",
"windowName",
"chute_id",
"chuteId",
]
private static let limitKeys = [
"limit",
"cap",
"max",
"maximum",
"quota",
"quota_limit",
"quotaLimit",
"monthly_cap",
"monthlyCap",
"monthly_limit",
"monthlyLimit",
"request_limit",
"requestLimit",
"token_limit",
"tokenLimit",
"hard_limit",
"hardLimit",
"total",
]
private static let usedKeys = [
"used",
"usage",
"used_amount",
"usedAmount",
"consumed",
"consumed_amount",
"consumedAmount",
"current",
"current_usage",
"currentUsage",
"requests",
"request_count",
"requestCount",
"tokens",
"token_usage",
"tokenUsage",
"monthly_usage",
"monthlyUsage",
]
private static let remainingKeys = [
"remaining",
"available",
"balance",
"left",
"remaining_amount",
"remainingAmount",
"available_amount",
"availableAmount",
]
private static let percentUsedKeys = [
"percent_used",
"percentUsed",
"usage_percent",
"usagePercent",
"used_percent",
"usedPercent",
"utilization",
"utilization_percent",
"utilizationPercent",
]
private static let percentRemainingKeys = [
"percent_remaining",
"percentRemaining",
"remaining_percent",
"remainingPercent",
]
private static let resetKeys = [
"reset_at",
"resetAt",
"resets_at",
"resetsAt",
"reset_time",
"resetTime",
"next_reset_at",
"nextResetAt",
"renews_at",
"renewsAt",
"renewal_at",
"renewalAt",
"period_end",
"periodEnd",
"current_period_end",
"currentPeriodEnd",
"expires_at",
"expiresAt",
"window_end",
"windowEnd",
"end_time",
"endTime",
]
private static let unitKeys = [
"unit",
"units",
"currency",
"quota_unit",
"quotaUnit",
]
private static let activeKeys = [
"active",
"is_active",
"isActive",
"subscription_active",
"subscriptionActive",
"has_subscription",
"hasSubscription",
]
private static let statusKeys = [
"status",
"state",
"subscription_status",
"subscriptionStatus",
]
private static let planKeys = [
"plan_name",
"planName",
"plan",
"tier",
"subscription_plan",
"subscriptionPlan",
"subscription_tier",
"subscriptionTier",
]
private static let windowMinuteKeys = [
"window_minutes",
"windowMinutes",
"period_minutes",
"periodMinutes",
"duration_minutes",
"durationMinutes",
]
private static let windowHourKeys = [
"window_hours",
"windowHours",
"period_hours",
"periodHours",
"duration_hours",
"durationHours",
]
private static let windowDayKeys = [
"window_days",
"windowDays",
"period_days",
"periodDays",
"duration_days",
"durationDays",
]
private static let windowSecondKeys = [
"window_seconds",
"windowSeconds",
"period_seconds",
"periodSeconds",
"duration_seconds",
"durationSeconds",
]
private static let windowStringKeys = [
"window",
"period",
"interval",
"duration",
]
}