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

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