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

458 lines
16 KiB
Swift

import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
public enum Sub2APIUsageError: LocalizedError, Equatable, Sendable {
case missingCredentials
case missingBaseURL
case invalidCredentials
case apiError(Int)
case parseFailed(String)
public var errorDescription: String? {
switch self {
case .missingCredentials:
"Missing sub2api API key. Add a group API key in Settings or set SUB2API_API_KEY."
case .missingBaseURL:
"Missing or invalid sub2api base URL. Add one in Settings or set SUB2API_BASE_URL."
case .invalidCredentials:
"sub2api rejected the API key. Check that the key is active and assigned to a group."
case let .apiError(statusCode):
"sub2api API returned HTTP \(statusCode)."
case let .parseFailed(message):
"Could not parse sub2api usage: \(message)"
}
}
}
public struct Sub2APIUsageDetails: Codable, Sendable, Equatable {
public enum Kind: String, Codable, Sendable {
case keyQuota
case subscription
case wallet
case unknown
}
public struct Totals: Codable, Sendable, Equatable {
public let requests: Int
public let totalTokens: Int
public let actualCostUSD: Double
public init(requests: Int, totalTokens: Int, actualCostUSD: Double) {
self.requests = requests
self.totalTokens = totalTokens
self.actualCostUSD = actualCostUSD
}
}
public let kind: Kind
public let balance: Double?
public let unit: String
public let today: Totals?
public let total: Totals?
public init(kind: Kind, balance: Double?, unit: String, today: Totals?, total: Totals?) {
self.kind = kind
self.balance = balance
self.unit = unit
self.today = today
self.total = total
}
}
public struct Sub2APIUsageSnapshot: Sendable, Equatable {
public struct Quota: Sendable, Equatable {
public let limit: Double
public let used: Double
public let remaining: Double
public let unit: String
}
public struct RateLimit: Sendable, Equatable {
public let window: String
public let limit: Double
public let used: Double
public let remaining: Double
public let resetAt: Date?
}
public struct Subscription: Sendable, Equatable {
public let dailyUsageUSD: Double
public let weeklyUsageUSD: Double
public let monthlyUsageUSD: Double
public let dailyLimitUSD: Double?
public let weeklyLimitUSD: Double?
public let monthlyLimitUSD: Double?
public let expiresAt: Date?
}
public struct UsageTotals: Sendable, Equatable {
public let requests: Int
public let totalTokens: Int
public let actualCostUSD: Double
}
public let mode: String
public let isValid: Bool
public let status: String?
public let planName: String?
public let remaining: Double?
public let unit: String
public let balance: Double?
public let quota: Quota?
public let rateLimits: [RateLimit]
public let subscription: Subscription?
public let todayUsage: UsageTotals?
public let totalUsage: UsageTotals?
public let expiresAt: Date?
public let updatedAt: Date
public func toUsageSnapshot() -> UsageSnapshot {
let subscription = self.subscription
let kind: Sub2APIUsageDetails.Kind = if subscription != nil {
.subscription
} else if self.quota != nil || !self.rateLimits.isEmpty {
.keyQuota
} else if self.balance != nil {
.wallet
} else {
.unknown
}
let subscriptionWindows = subscription.map { subscription in
[
Self.rateWindow(
usage: subscription.dailyUsageUSD,
limit: subscription.dailyLimitUSD,
windowMinutes: 24 * 60),
Self.rateWindow(
usage: subscription.weeklyUsageUSD,
limit: subscription.weeklyLimitUSD,
windowMinutes: 7 * 24 * 60),
Self.rateWindow(
usage: subscription.monthlyUsageUSD,
limit: subscription.monthlyLimitUSD,
windowMinutes: 30 * 24 * 60),
]
}
let primary = subscriptionWindows?[0] ?? self.quota.map(Self.quotaWindow)
let secondary = subscriptionWindows?[1]
let tertiary = subscriptionWindows?[2]
let namedWindows = self.rateLimits.map { rateLimit in
NamedRateWindow(
id: rateLimit.window,
title: Self.rateLimitTitle(rateLimit.window),
window: RateWindow(
usedPercent: Self.usedPercent(usage: rateLimit.used, limit: rateLimit.limit),
windowMinutes: Self.windowMinutes(rateLimit.window),
resetsAt: rateLimit.resetAt,
resetDescription: Self.amountDescription(used: rateLimit.used, limit: rateLimit.limit)))
}
let usageDetails = Sub2APIUsageDetails(
kind: kind,
balance: self.balance,
unit: self.unit,
today: self.todayUsage.map {
Sub2APIUsageDetails.Totals(
requests: $0.requests,
totalTokens: $0.totalTokens,
actualCostUSD: $0.actualCostUSD)
},
total: self.totalUsage.map {
Sub2APIUsageDetails.Totals(
requests: $0.requests,
totalTokens: $0.totalTokens,
actualCostUSD: $0.actualCostUSD)
})
return UsageSnapshot(
primary: primary,
secondary: secondary,
tertiary: tertiary,
extraRateWindows: namedWindows.isEmpty ? nil : namedWindows,
sub2APIUsage: usageDetails,
subscriptionExpiresAt: subscription?.expiresAt ?? self.expiresAt,
updatedAt: self.updatedAt,
identity: ProviderIdentitySnapshot(
providerID: .sub2api,
accountEmail: nil,
accountOrganization: self.planName,
loginMethod: self.planName),
dataConfidence: .exact)
}
private static func quotaWindow(_ quota: Quota) -> RateWindow {
RateWindow(
usedPercent: self.usedPercent(usage: quota.used, limit: quota.limit),
windowMinutes: nil,
resetsAt: nil,
resetDescription: self.amountDescription(used: quota.used, limit: quota.limit, unit: quota.unit))
}
private static func rateWindow(usage: Double, limit: Double?, windowMinutes: Int) -> RateWindow? {
guard let limit, limit > 0 else { return nil }
return RateWindow(
usedPercent: self.usedPercent(usage: usage, limit: limit),
windowMinutes: windowMinutes,
resetsAt: nil,
resetDescription: self.amountDescription(used: usage, limit: limit))
}
private static func usedPercent(usage: Double, limit: Double) -> Double {
guard limit > 0 else { return 0 }
return min(100, max(0, usage / limit * 100))
}
private static func amountDescription(used: Double, limit: Double, unit: String = "USD") -> String {
"\(self.currencyString(used, unit: unit)) / \(self.currencyString(limit, unit: unit))"
}
private static func currencyString(_ value: Double, unit: String) -> String {
unit.uppercased() == "USD" ? UsageFormatter.usdString(value) : String(format: "%.2f %@", value, unit)
}
private static func windowMinutes(_ window: String) -> Int? {
switch window.lowercased() {
case "5h": 5 * 60
case "1d": 24 * 60
case "7d": 7 * 24 * 60
default: nil
}
}
private static func rateLimitTitle(_ window: String) -> String {
switch window.lowercased() {
case "5h": "5 hour limit"
case "1d": "Daily limit"
case "7d": "7 day limit"
default: "\(window) limit"
}
}
}
private struct Sub2APIUsageResponse: Decodable {
struct Quota: Decodable {
let limit: Double
let used: Double
let remaining: Double
let unit: String?
}
struct RateLimit: Decodable {
let window: String
let limit: Double
let used: Double
let remaining: Double
let resetAt: String?
private enum CodingKeys: String, CodingKey {
case window
case limit
case used
case remaining
case resetAt = "reset_at"
}
}
struct Subscription: Decodable {
let dailyUsageUSD: Double?
let weeklyUsageUSD: Double?
let monthlyUsageUSD: Double?
let dailyLimitUSD: Double?
let weeklyLimitUSD: Double?
let monthlyLimitUSD: Double?
let expiresAt: String?
private enum CodingKeys: String, CodingKey {
case dailyUsageUSD = "daily_usage_usd"
case weeklyUsageUSD = "weekly_usage_usd"
case monthlyUsageUSD = "monthly_usage_usd"
case dailyLimitUSD = "daily_limit_usd"
case weeklyLimitUSD = "weekly_limit_usd"
case monthlyLimitUSD = "monthly_limit_usd"
case expiresAt = "expires_at"
}
}
struct Usage: Decodable {
struct Totals: Decodable {
let requests: Int?
let totalTokens: Int?
let actualCost: Double?
private enum CodingKeys: String, CodingKey {
case requests
case totalTokens = "total_tokens"
case actualCost = "actual_cost"
}
}
let today: Totals?
let total: Totals?
}
let mode: String?
let isValid: Bool?
let status: String?
let planName: String?
let remaining: Double?
let unit: String?
let balance: Double?
let quota: Quota?
let rateLimits: [RateLimit]?
let subscription: Subscription?
let usage: Usage?
let expiresAt: String?
private enum CodingKeys: String, CodingKey {
case mode
case isValid
case status
case planName
case remaining
case unit
case balance
case quota
case rateLimits = "rate_limits"
case subscription
case usage
case expiresAt = "expires_at"
}
}
public struct Sub2APIUsageFetcher: Sendable {
public init() {}
public static func fetchUsage(
apiKey: String,
baseURL: URL,
transport: any ProviderHTTPTransport = ProviderHTTPClient.shared,
timeout: Duration = .seconds(15),
updatedAt: Date = Date()) async throws -> Sub2APIUsageSnapshot
{
let cleanedAPIKey = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !cleanedAPIKey.isEmpty else { throw Sub2APIUsageError.missingCredentials }
var request = URLRequest(url: self.usageRequestURL(baseURL: baseURL))
request.httpMethod = "GET"
request.setValue("Bearer \(cleanedAPIKey)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.timeoutInterval = 15
let responseTask = Task {
try await transport.response(for: request)
}
let response: ProviderHTTPResponse = switch await BoundedTaskJoin(sourceTask: responseTask)
.value(joinGrace: timeout)
{
case let .value(response): response
case let .failure(error): throw error
case .timedOut: throw URLError(.timedOut)
}
switch response.statusCode {
case 200..<300:
let snapshot = try self.parseSnapshot(data: response.data, updatedAt: updatedAt)
guard snapshot.isValid else { throw Sub2APIUsageError.invalidCredentials }
return snapshot
case 401, 403:
throw Sub2APIUsageError.invalidCredentials
default:
throw Sub2APIUsageError.apiError(response.statusCode)
}
}
public static func _parseSnapshotForTesting(_ data: Data, updatedAt: Date) throws -> Sub2APIUsageSnapshot {
try self.parseSnapshot(data: data, updatedAt: updatedAt)
}
public static func _usageURLForTesting(baseURL: URL) -> URL {
self.usageURL(baseURL: baseURL)
}
private static func usageURL(baseURL: URL) -> URL {
let components = baseURL.path.split(separator: "/")
if components.suffix(2) == ["v1", "usage"] {
return baseURL
}
if components.last == "v1" {
return baseURL.appendingPathComponent("usage")
}
return baseURL.appendingPathComponent("v1/usage")
}
private static func usageRequestURL(baseURL: URL) -> URL {
let url = self.usageURL(baseURL: baseURL)
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return url }
components.queryItems = [
URLQueryItem(name: "days", value: "30"),
URLQueryItem(name: "timezone", value: TimeZone.current.identifier),
]
return components.url ?? url
}
private static func parseSnapshot(data: Data, updatedAt: Date) throws -> Sub2APIUsageSnapshot {
do {
let response = try JSONDecoder().decode(Sub2APIUsageResponse.self, from: data)
let unit = response.unit ?? response.quota?.unit ?? "USD"
return Sub2APIUsageSnapshot(
mode: response.mode ?? "unknown",
isValid: response.isValid ?? true,
status: response.status,
planName: response.planName,
remaining: response.remaining,
unit: unit,
balance: response.balance,
quota: response.quota.map {
Sub2APIUsageSnapshot.Quota(
limit: $0.limit,
used: $0.used,
remaining: $0.remaining,
unit: $0.unit ?? unit)
},
rateLimits: (response.rateLimits ?? []).map {
Sub2APIUsageSnapshot.RateLimit(
window: $0.window,
limit: $0.limit,
used: $0.used,
remaining: $0.remaining,
resetAt: self.parseDate($0.resetAt))
},
subscription: response.subscription.map {
Sub2APIUsageSnapshot.Subscription(
dailyUsageUSD: $0.dailyUsageUSD ?? 0,
weeklyUsageUSD: $0.weeklyUsageUSD ?? 0,
monthlyUsageUSD: $0.monthlyUsageUSD ?? 0,
dailyLimitUSD: $0.dailyLimitUSD,
weeklyLimitUSD: $0.weeklyLimitUSD,
monthlyLimitUSD: $0.monthlyLimitUSD,
expiresAt: self.parseDate($0.expiresAt))
},
todayUsage: self.usageTotals(response.usage?.today),
totalUsage: self.usageTotals(response.usage?.total),
expiresAt: self.parseDate(response.expiresAt),
updatedAt: updatedAt)
} catch let error as Sub2APIUsageError {
throw error
} catch {
throw Sub2APIUsageError.parseFailed(error.localizedDescription)
}
}
private static func usageTotals(_ totals: Sub2APIUsageResponse.Usage.Totals?)
-> Sub2APIUsageSnapshot.UsageTotals?
{
guard let totals else { return nil }
return Sub2APIUsageSnapshot.UsageTotals(
requests: totals.requests ?? 0,
totalTokens: totals.totalTokens ?? 0,
actualCostUSD: totals.actualCost ?? 0)
}
private static func parseDate(_ raw: String?) -> Date? {
guard let raw else { return nil }
let fractional = ISO8601DateFormatter()
fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return fractional.date(from: raw) ?? ISO8601DateFormatter().date(from: raw)
}
}