729 lines
24 KiB
Swift
729 lines
24 KiB
Swift
import Foundation
|
|
#if canImport(FoundationNetworking)
|
|
import FoundationNetworking
|
|
#endif
|
|
|
|
public struct SyntheticQuotaEntry: Sendable {
|
|
public let label: String?
|
|
public let usedPercent: Double
|
|
public let windowMinutes: Int?
|
|
public let resetsAt: Date?
|
|
public let resetDescription: String?
|
|
public let nextRegenPercent: Double?
|
|
public let cost: ProviderCostSnapshot?
|
|
|
|
public init(
|
|
label: String?,
|
|
usedPercent: Double,
|
|
windowMinutes: Int?,
|
|
resetsAt: Date?,
|
|
resetDescription: String?,
|
|
nextRegenPercent: Double? = nil,
|
|
cost: ProviderCostSnapshot? = nil)
|
|
{
|
|
self.label = label
|
|
self.usedPercent = usedPercent
|
|
self.windowMinutes = windowMinutes
|
|
self.resetsAt = resetsAt
|
|
self.resetDescription = resetDescription
|
|
self.nextRegenPercent = nextRegenPercent
|
|
self.cost = cost
|
|
}
|
|
}
|
|
|
|
public struct SyntheticUsageSnapshot: Sendable {
|
|
public let quotas: [SyntheticQuotaEntry]
|
|
/// Slot-identified lanes for the known Synthetic response shape: [rolling-5h, weekly, search-hourly].
|
|
/// When set, `toUsageSnapshot` maps slot 0 → primary, slot 1 → secondary, slot 2 → tertiary,
|
|
/// so a missing lane stays nil instead of promoting the next lane into the wrong UI label.
|
|
public let slottedQuotas: [SyntheticQuotaEntry?]?
|
|
public let planName: String?
|
|
public let updatedAt: Date
|
|
|
|
public init(
|
|
quotas: [SyntheticQuotaEntry],
|
|
slottedQuotas: [SyntheticQuotaEntry?]? = nil,
|
|
planName: String?,
|
|
updatedAt: Date)
|
|
{
|
|
self.quotas = quotas
|
|
self.slottedQuotas = slottedQuotas
|
|
self.planName = planName
|
|
self.updatedAt = updatedAt
|
|
}
|
|
}
|
|
|
|
extension SyntheticUsageSnapshot {
|
|
public func toUsageSnapshot() -> UsageSnapshot {
|
|
let slots = self.slottedQuotas
|
|
?? [self.quotas.first, self.quotas.dropFirst().first, self.quotas.dropFirst(2).first]
|
|
let entries: [SyntheticQuotaEntry?] = (0..<3).map { slots.indices.contains($0) ? slots[$0] : nil }
|
|
|
|
let primary = entries[0].map(Self.rateWindow(for:))
|
|
let secondary = entries[1].map(Self.rateWindow(for:))
|
|
let tertiary = entries[2].map(Self.rateWindow(for:))
|
|
|
|
let planName = self.planName?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let loginMethod = (planName?.isEmpty ?? true) ? nil : planName
|
|
let identity = ProviderIdentitySnapshot(
|
|
providerID: .synthetic,
|
|
accountEmail: nil,
|
|
accountOrganization: nil,
|
|
loginMethod: loginMethod)
|
|
|
|
return UsageSnapshot(
|
|
primary: primary,
|
|
secondary: secondary,
|
|
tertiary: tertiary,
|
|
providerCost: self.quotas.first(where: { $0.cost != nil })?.cost,
|
|
updatedAt: self.updatedAt,
|
|
identity: identity)
|
|
}
|
|
|
|
private static func rateWindow(for quota: SyntheticQuotaEntry) -> RateWindow {
|
|
RateWindow(
|
|
usedPercent: quota.usedPercent,
|
|
windowMinutes: quota.windowMinutes,
|
|
resetsAt: quota.resetsAt,
|
|
resetDescription: quota.resetDescription,
|
|
nextRegenPercent: quota.nextRegenPercent)
|
|
}
|
|
}
|
|
|
|
public struct SyntheticUsageFetcher: Sendable {
|
|
private static let log = CodexBarLog.logger(LogCategories.syntheticUsage)
|
|
private static let quotaAPIURL = "https://api.synthetic.new/v2/quotas"
|
|
|
|
public static func fetchUsage(apiKey: String, now: Date = Date()) async throws -> SyntheticUsageSnapshot {
|
|
guard !apiKey.isEmpty else {
|
|
throw SyntheticUsageError.invalidCredentials
|
|
}
|
|
|
|
var request = URLRequest(url: URL(string: Self.quotaAPIURL)!)
|
|
request.httpMethod = "GET"
|
|
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
|
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
|
|
let response = try await ProviderHTTPClient.shared.response(for: request)
|
|
let data = response.data
|
|
guard response.statusCode == 200 else {
|
|
let errorMessage = String(data: data, encoding: .utf8) ?? "Unknown error"
|
|
Self.log.error("Synthetic API returned \(response.statusCode): \(errorMessage)")
|
|
if response.statusCode == 401 || response.statusCode == 403 {
|
|
throw SyntheticUsageError.invalidCredentials
|
|
}
|
|
throw SyntheticUsageError.apiError("HTTP \(response.statusCode): \(errorMessage)")
|
|
}
|
|
|
|
do {
|
|
return try SyntheticUsageParser.parse(data: data, now: now)
|
|
} catch let error as SyntheticUsageError {
|
|
throw error
|
|
} catch {
|
|
Self.log.error("Synthetic parsing error: \(error.localizedDescription)")
|
|
throw SyntheticUsageError.parseFailed(error.localizedDescription)
|
|
}
|
|
}
|
|
}
|
|
|
|
private final class SyntheticISO8601FormatterBox: @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 SyntheticTimestampParser {
|
|
static let box = SyntheticISO8601FormatterBox()
|
|
|
|
static func parse(_ text: String) -> Date? {
|
|
self.box.lock.lock()
|
|
defer { self.box.lock.unlock() }
|
|
return self.box.withFractional.date(from: text) ?? self.box.plain.date(from: text)
|
|
}
|
|
}
|
|
|
|
enum SyntheticUsageParser {
|
|
static func parse(data: Data, now: Date = Date()) throws -> SyntheticUsageSnapshot {
|
|
let object = try JSONSerialization.jsonObject(with: data, options: [])
|
|
|
|
let root: [String: Any] = {
|
|
if let dict = object as? [String: Any] { return dict }
|
|
if let array = object as? [Any] { return ["quotas": array] }
|
|
return [:]
|
|
}()
|
|
|
|
let planName = self.planName(from: root)
|
|
|
|
if let slots = self.prioritizedQuotaSlots(from: root) {
|
|
let slotted: [SyntheticQuotaEntry?] = slots.map { $0.flatMap(self.parseQuota) }
|
|
let flat = slotted.compactMap(\.self)
|
|
guard !flat.isEmpty else {
|
|
throw SyntheticUsageError.parseFailed("Missing quota data.")
|
|
}
|
|
return SyntheticUsageSnapshot(
|
|
quotas: flat,
|
|
slottedQuotas: slotted,
|
|
planName: planName,
|
|
updatedAt: now)
|
|
}
|
|
|
|
let quotas = self.fallbackQuotaObjects(from: root).compactMap(self.parseQuota)
|
|
guard !quotas.isEmpty else {
|
|
throw SyntheticUsageError.parseFailed("Missing quota data.")
|
|
}
|
|
return SyntheticUsageSnapshot(
|
|
quotas: quotas,
|
|
planName: planName,
|
|
updatedAt: now)
|
|
}
|
|
|
|
/// Returns slot-positional quota payloads `[rolling-5h, weekly, search-hourly]` when the known Synthetic
|
|
/// response shape is detected. Missing lanes stay nil in their slot so downstream code doesn't shift
|
|
/// labels. Returns nil if none of the known keys appear, so the fallback path runs.
|
|
private static func prioritizedQuotaSlots(from root: [String: Any]) -> [[String: Any]?]? {
|
|
let dataDict = root["data"] as? [String: Any]
|
|
let rolling = self.namedQuota(root["rollingFiveHourLimit"], label: "Rolling five-hour limit")
|
|
?? self.namedQuota(dataDict?["rollingFiveHourLimit"], label: "Rolling five-hour limit")
|
|
let weekly = self.namedQuota(root["weeklyTokenLimit"], label: "Weekly token limit")
|
|
?? self.namedQuota(dataDict?["weeklyTokenLimit"], label: "Weekly token limit")
|
|
let searchHourly = self.namedQuota((root["search"] as? [String: Any])?["hourly"], label: "Search hourly")
|
|
?? self.namedQuota((dataDict?["search"] as? [String: Any])?["hourly"], label: "Search hourly")
|
|
let slots: [[String: Any]?] = [rolling, weekly, searchHourly]
|
|
return slots.contains(where: { $0 != nil }) ? slots : nil
|
|
}
|
|
|
|
private static func fallbackQuotaObjects(from root: [String: Any]) -> [[String: Any]] {
|
|
let dataDict = root["data"] as? [String: Any]
|
|
let candidates: [Any?] = [
|
|
root["quotas"],
|
|
root["quota"],
|
|
root["limits"],
|
|
root["usage"],
|
|
root["entries"],
|
|
root["subscription"],
|
|
root["data"],
|
|
dataDict?["quotas"],
|
|
dataDict?["quota"],
|
|
dataDict?["limits"],
|
|
dataDict?["usage"],
|
|
dataDict?["entries"],
|
|
dataDict?["subscription"],
|
|
]
|
|
|
|
for candidate in candidates {
|
|
let quotas = self.extractQuotaObjects(from: candidate)
|
|
if !quotas.isEmpty { return quotas }
|
|
}
|
|
return []
|
|
}
|
|
|
|
private static func planName(from root: [String: Any]) -> String? {
|
|
if let direct = self.firstString(in: root, keys: planKeys) { return direct }
|
|
if let dataDict = root["data"] as? [String: Any],
|
|
let plan = self.firstString(in: dataDict, keys: planKeys)
|
|
{
|
|
return plan
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private static func parseQuota(_ payload: [String: Any]) -> SyntheticQuotaEntry? {
|
|
let label = self.firstString(in: payload, keys: Self.labelKeys)
|
|
|
|
let percentUsed = self.normalizedPercent(
|
|
self.firstDouble(in: payload, keys: Self.percentUsedKeys))
|
|
let percentRemaining = self.normalizedPercent(
|
|
self.firstDouble(in: payload, keys: Self.percentRemainingKeys))
|
|
|
|
var usedPercent = percentUsed
|
|
if usedPercent == nil, let remaining = percentRemaining {
|
|
usedPercent = 100 - remaining
|
|
}
|
|
|
|
if usedPercent == nil {
|
|
var limit = self.firstDouble(in: payload, keys: Self.limitKeys)
|
|
var used = self.firstDouble(in: payload, keys: Self.usedKeys)
|
|
var remaining = self.firstDouble(in: payload, keys: Self.remainingKeys)
|
|
|
|
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)
|
|
}
|
|
|
|
if let limit, let used, limit > 0 {
|
|
usedPercent = (used / limit) * 100
|
|
}
|
|
}
|
|
|
|
guard let usedPercent else { return nil }
|
|
let clamped = max(0, min(usedPercent, 100))
|
|
|
|
let windowMinutes = windowMinutes(from: payload)
|
|
let resetsAt = self.firstDate(in: payload, keys: self.resetKeys)
|
|
// Leave resetDescription nil when resetsAt is set so the UI rebuilds the countdown each render
|
|
// against the current clock instead of freezing a stale "in Xm" string at parse time.
|
|
let resetDescription = resetsAt == nil ? self.windowDescription(minutes: windowMinutes) : nil
|
|
|
|
let cost = self.providerCost(from: payload, usedPercent: clamped, resetsAt: resetsAt)
|
|
let nextRegenPercent = self.normalizedPercent(
|
|
self.firstDouble(in: payload, keys: Self.tickPercentKeys))
|
|
|
|
return SyntheticQuotaEntry(
|
|
label: label,
|
|
usedPercent: clamped,
|
|
windowMinutes: windowMinutes,
|
|
resetsAt: resetsAt,
|
|
resetDescription: resetDescription,
|
|
nextRegenPercent: nextRegenPercent,
|
|
cost: cost)
|
|
}
|
|
|
|
private static func isQuotaPayload(_ payload: [String: Any]) -> Bool {
|
|
let checks = [
|
|
Self.limitKeys,
|
|
Self.usedKeys,
|
|
Self.remainingKeys,
|
|
Self.percentUsedKeys,
|
|
Self.percentRemainingKeys,
|
|
]
|
|
return checks.contains { self.firstDouble(in: payload, keys: $0) != nil }
|
|
}
|
|
|
|
private static func windowMinutes(from payload: [String: Any]) -> Int? {
|
|
if let minutes = self.firstInt(in: payload, keys: windowMinutesKeys) { return minutes }
|
|
if let hours = self.firstDouble(in: payload, keys: windowHoursKeys) {
|
|
return Int((hours * 60).rounded())
|
|
}
|
|
if let days = self.firstDouble(in: payload, keys: windowDaysKeys) {
|
|
return Int((days * 24 * 60).rounded())
|
|
}
|
|
if let seconds = self.firstDouble(in: payload, keys: windowSecondsKeys) {
|
|
return Int((seconds / 60).rounded())
|
|
}
|
|
if let text = self.firstString(in: payload, keys: windowStringKeys) {
|
|
return self.windowMinutes(fromText: text)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private static func namedQuota(_ candidate: Any?, label: String) -> [String: Any]? {
|
|
guard var payload = candidate as? [String: Any], self.isQuotaPayload(payload) else { return nil }
|
|
if payload["label"] == nil, payload["name"] == nil {
|
|
payload["label"] = label
|
|
}
|
|
return payload
|
|
}
|
|
|
|
private static func extractQuotaObjects(from candidate: Any?) -> [[String: Any]] {
|
|
switch candidate {
|
|
case let array as [[String: Any]]:
|
|
var nestedQuotas: [[String: Any]] = []
|
|
for entry in array {
|
|
if self.isQuotaPayload(entry) {
|
|
nestedQuotas.append(entry)
|
|
} else {
|
|
nestedQuotas.append(contentsOf: self.extractQuotaObjects(from: entry))
|
|
}
|
|
}
|
|
return nestedQuotas
|
|
case let array as [Any]:
|
|
return array.flatMap { self.extractQuotaObjects(from: $0) }
|
|
case let dict as [String: Any]:
|
|
if self.isQuotaPayload(dict) {
|
|
return [dict]
|
|
}
|
|
var nestedQuotas: [[String: Any]] = []
|
|
for key in dict.keys.sorted() {
|
|
nestedQuotas.append(contentsOf: self.extractQuotaObjects(from: dict[key]))
|
|
}
|
|
return nestedQuotas
|
|
default:
|
|
return []
|
|
}
|
|
}
|
|
|
|
/// Parses durations like `"5hr"`, `"30min"`, `"2 days"`. Suffixes are sorted longest-first so
|
|
/// multi-letter units always win over their single-letter aliases — no ordering surprises if a
|
|
/// future unit shares a trailing letter with another.
|
|
static func windowMinutes(fromText text: String) -> Int? {
|
|
let normalized = text
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
.lowercased()
|
|
.replacingOccurrences(of: " ", with: "")
|
|
guard !normalized.isEmpty else { return nil }
|
|
|
|
for (suffix, multiplier) in Self.windowSuffixMultipliers {
|
|
guard normalized.hasSuffix(suffix) else { continue }
|
|
let valueText = String(normalized.dropLast(suffix.count))
|
|
guard let value = Double(valueText), value > 0 else { return nil }
|
|
return Int((value * multiplier).rounded())
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private static let windowSuffixMultipliers: [(suffix: String, multiplier: Double)] = {
|
|
let raw: [(String, Double)] = [
|
|
("minutes", 1), ("minute", 1), ("mins", 1), ("min", 1), ("m", 1),
|
|
("hours", 60), ("hour", 60), ("hrs", 60), ("hr", 60), ("h", 60),
|
|
("days", 24 * 60), ("day", 24 * 60), ("d", 24 * 60),
|
|
]
|
|
return raw
|
|
.sorted { $0.0.count > $1.0.count }
|
|
.map { (suffix: $0.0, multiplier: $0.1) }
|
|
}()
|
|
|
|
private static func windowDescription(minutes: Int?) -> String? {
|
|
guard let minutes, minutes > 0 else { return nil }
|
|
let dayMinutes = 24 * 60
|
|
if minutes % dayMinutes == 0 {
|
|
let days = minutes / dayMinutes
|
|
return "\(days) day\(days == 1 ? "" : "s") window"
|
|
}
|
|
if minutes % 60 == 0 {
|
|
let hours = minutes / 60
|
|
return "\(hours) hour\(hours == 1 ? "" : "s") window"
|
|
}
|
|
return "\(minutes) minute\(minutes == 1 ? "" : "s") window"
|
|
}
|
|
|
|
private static func providerCost(
|
|
from payload: [String: Any],
|
|
usedPercent: Double,
|
|
resetsAt: Date?) -> ProviderCostSnapshot?
|
|
{
|
|
guard let limit = self.firstCurrency(in: payload, keys: self.costLimitKeys) else { return nil }
|
|
|
|
let remaining = self.firstCurrency(in: payload, keys: self.costRemainingKeys)
|
|
let usedFromPayload = self.firstCurrency(in: payload, keys: self.costUsedKeys)
|
|
let nextRegenAmount = self.firstCurrency(in: payload, keys: self.regenAmountKeys)
|
|
let used = if let usedFromPayload {
|
|
usedFromPayload
|
|
} else if let remaining {
|
|
max(0, limit - remaining)
|
|
} else {
|
|
(usedPercent.clamped(to: 0...100) / 100) * limit
|
|
}
|
|
|
|
return ProviderCostSnapshot(
|
|
used: used,
|
|
limit: limit,
|
|
currencyCode: "USD",
|
|
period: "Weekly",
|
|
resetsAt: resetsAt,
|
|
nextRegenAmount: nextRegenAmount,
|
|
updatedAt: Date())
|
|
}
|
|
|
|
private static func firstCurrency(in payload: [String: Any], keys: [String]) -> Double? {
|
|
for key in keys {
|
|
guard let value = payload[key] else { continue }
|
|
if let text = value as? String,
|
|
let parsed = self.parseCurrency(text)
|
|
{
|
|
return parsed
|
|
}
|
|
if let number = self.doubleValue(value) {
|
|
return number
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private static func parseCurrency(_ text: String) -> Double? {
|
|
let cleaned = text
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
.replacingOccurrences(of: "$", with: "")
|
|
.replacingOccurrences(of: ",", with: "")
|
|
return Double(cleaned)
|
|
}
|
|
|
|
private static func normalizedPercent(_ value: Double?) -> Double? {
|
|
guard let value else { return nil }
|
|
if value <= 1 { return value * 100 }
|
|
return value
|
|
}
|
|
|
|
private static func firstString(in payload: [String: Any], keys: [String]) -> String? {
|
|
for key in keys {
|
|
if let value = self.stringValue(payload[key]) { return value }
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private static func firstDouble(in payload: [String: Any], keys: [String]) -> Double? {
|
|
for key in keys {
|
|
if let value = self.doubleValue(payload[key]) { return value }
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private static func firstInt(in payload: [String: Any], keys: [String]) -> Int? {
|
|
for key in keys {
|
|
if let value = self.intValue(payload[key]) { return value }
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private static func firstDate(in payload: [String: Any], keys: [String]) -> Date? {
|
|
for key in keys {
|
|
if let value = payload[key],
|
|
let date = self.dateValue(value)
|
|
{
|
|
return date
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private static func stringValue(_ raw: Any?) -> String? {
|
|
guard let raw else { return nil }
|
|
if let string = raw as? String {
|
|
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return trimmed.isEmpty ? nil : trimmed
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private static func doubleValue(_ raw: Any?) -> Double? {
|
|
switch raw {
|
|
case let number as Double:
|
|
return number
|
|
case let number as NSNumber:
|
|
return number.doubleValue
|
|
case let string as String:
|
|
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return Double(trimmed)
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private static func intValue(_ raw: Any?) -> Int? {
|
|
switch raw {
|
|
case let number as Int:
|
|
return number
|
|
case let number as NSNumber:
|
|
return number.intValue
|
|
case let string as String:
|
|
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return Int(trimmed)
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private static func dateValue(_ raw: Any) -> Date? {
|
|
if let number = self.doubleValue(raw) {
|
|
if number > 1_000_000_000_000 {
|
|
return Date(timeIntervalSince1970: number / 1000)
|
|
}
|
|
if number > 1_000_000_000 {
|
|
return Date(timeIntervalSince1970: number)
|
|
}
|
|
}
|
|
if let string = raw as? String {
|
|
if let number = Double(string.trimmingCharacters(in: .whitespacesAndNewlines)) {
|
|
return self.dateValue(number)
|
|
}
|
|
if let date = SyntheticTimestampParser.parse(string) {
|
|
return date
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private static let planKeys = [
|
|
"plan",
|
|
"planName",
|
|
"plan_name",
|
|
"subscription",
|
|
"subscriptionPlan",
|
|
"tier",
|
|
"package",
|
|
"packageName",
|
|
]
|
|
|
|
private static let labelKeys = [
|
|
"name",
|
|
"label",
|
|
"type",
|
|
"period",
|
|
"scope",
|
|
"title",
|
|
"id",
|
|
]
|
|
|
|
private static let percentUsedKeys = [
|
|
"percentUsed",
|
|
"usedPercent",
|
|
"usagePercent",
|
|
"usage_percent",
|
|
"used_percent",
|
|
"percent_used",
|
|
"percent",
|
|
]
|
|
|
|
private static let percentRemainingKeys = [
|
|
"percentRemaining",
|
|
"remainingPercent",
|
|
"remaining_percent",
|
|
"percent_remaining",
|
|
]
|
|
|
|
private static let limitKeys = [
|
|
"limit",
|
|
"messageLimit",
|
|
"message_limit",
|
|
"messages",
|
|
"maxRequests",
|
|
"max_requests",
|
|
"requestLimit",
|
|
"request_limit",
|
|
"quota",
|
|
"max",
|
|
"total",
|
|
"capacity",
|
|
"allowance",
|
|
]
|
|
|
|
private static let usedKeys = [
|
|
"used",
|
|
"usage",
|
|
"usedMessages",
|
|
"used_messages",
|
|
"messagesUsed",
|
|
"messages_used",
|
|
"requests",
|
|
"requestCount",
|
|
"request_count",
|
|
"consumed",
|
|
"spent",
|
|
]
|
|
|
|
private static let remainingKeys = [
|
|
"remaining",
|
|
"left",
|
|
"available",
|
|
"balance",
|
|
]
|
|
|
|
private static let resetKeys = [
|
|
"resetAt",
|
|
"reset_at",
|
|
"resetsAt",
|
|
"resets_at",
|
|
"renewAt",
|
|
"renew_at",
|
|
"renewsAt",
|
|
"renews_at",
|
|
"nextTickAt",
|
|
"next_tick_at",
|
|
"nextRegenAt",
|
|
"next_regen_at",
|
|
"periodEnd",
|
|
"period_end",
|
|
"expiresAt",
|
|
"expires_at",
|
|
"endAt",
|
|
"end_at",
|
|
]
|
|
|
|
private static let regenAmountKeys = [
|
|
"nextRegenCredits",
|
|
"next_regen_credits",
|
|
]
|
|
|
|
private static let tickPercentKeys = [
|
|
"tickPercent",
|
|
"tick_percent",
|
|
"nextTickPercent",
|
|
"next_tick_percent",
|
|
]
|
|
|
|
private static let costLimitKeys = [
|
|
"maxCredits",
|
|
"max_credits",
|
|
]
|
|
|
|
private static let costRemainingKeys = [
|
|
"remainingCredits",
|
|
"remaining_credits",
|
|
]
|
|
|
|
private static let costUsedKeys = [
|
|
"usedCredits",
|
|
"used_credits",
|
|
]
|
|
|
|
private static let windowMinutesKeys = [
|
|
"windowMinutes",
|
|
"window_minutes",
|
|
"periodMinutes",
|
|
"period_minutes",
|
|
]
|
|
|
|
private static let windowHoursKeys = [
|
|
"windowHours",
|
|
"window_hours",
|
|
"periodHours",
|
|
"period_hours",
|
|
]
|
|
|
|
private static let windowDaysKeys = [
|
|
"windowDays",
|
|
"window_days",
|
|
"periodDays",
|
|
"period_days",
|
|
]
|
|
|
|
private static let windowSecondsKeys = [
|
|
"windowSeconds",
|
|
"window_seconds",
|
|
"periodSeconds",
|
|
"period_seconds",
|
|
]
|
|
|
|
private static let windowStringKeys = [
|
|
"window",
|
|
"windowLabel",
|
|
"window_label",
|
|
"period",
|
|
"periodLabel",
|
|
"period_label",
|
|
]
|
|
}
|
|
|
|
public enum SyntheticUsageError: LocalizedError, Sendable {
|
|
case invalidCredentials
|
|
case networkError(String)
|
|
case apiError(String)
|
|
case parseFailed(String)
|
|
|
|
public var errorDescription: String? {
|
|
switch self {
|
|
case .invalidCredentials:
|
|
"Invalid Synthetic API credentials"
|
|
case let .networkError(message):
|
|
"Synthetic network error: \(message)"
|
|
case let .apiError(message):
|
|
"Synthetic API error: \(message)"
|
|
case let .parseFailed(message):
|
|
"Failed to parse Synthetic response: \(message)"
|
|
}
|
|
}
|
|
}
|