chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,315 @@
|
||||
import Foundation
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
|
||||
public enum QoderWebSite: CaseIterable, Sendable {
|
||||
case international
|
||||
case china
|
||||
|
||||
var usageURL: URL {
|
||||
switch self {
|
||||
case .international:
|
||||
URL(string: "https://qoder.com/api/v2/me/usages/big_model_credits")!
|
||||
case .china:
|
||||
URL(string: "https://qoder.com.cn/api/v2/me/usages/big_model_credits")!
|
||||
}
|
||||
}
|
||||
|
||||
public var dashboardURL: URL {
|
||||
switch self {
|
||||
case .international:
|
||||
URL(string: "https://qoder.com/account/usage")!
|
||||
case .china:
|
||||
URL(string: "https://qoder.com.cn/account/usage")!
|
||||
}
|
||||
}
|
||||
|
||||
var webOrigin: String {
|
||||
switch self {
|
||||
case .international:
|
||||
"https://qoder.com"
|
||||
case .china:
|
||||
"https://qoder.com.cn"
|
||||
}
|
||||
}
|
||||
|
||||
var cookieDomains: [String] {
|
||||
switch self {
|
||||
case .international:
|
||||
["qoder.com", "www.qoder.com"]
|
||||
case .china:
|
||||
["qoder.com.cn", "www.qoder.com.cn"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum QoderUsageFetcher {
|
||||
private static let log = CodexBarLog.logger(LogCategories.qoderUsage)
|
||||
private static let userAgent =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " +
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"
|
||||
|
||||
public static func fetchUsage(
|
||||
cookieHeader: String,
|
||||
site: QoderWebSite = .international,
|
||||
transport: any ProviderHTTPTransport = ProviderHTTPClient.shared,
|
||||
now: Date = Date(),
|
||||
timeout: TimeInterval = 15) async throws -> QoderUsageSnapshot
|
||||
{
|
||||
let data = try await self.send(
|
||||
cookieHeader: cookieHeader,
|
||||
site: site,
|
||||
transport: transport,
|
||||
timeout: timeout)
|
||||
return try self.parseUsage(data: data, now: now)
|
||||
}
|
||||
|
||||
private static func send(
|
||||
cookieHeader: String,
|
||||
site: QoderWebSite,
|
||||
transport: any ProviderHTTPTransport,
|
||||
timeout: TimeInterval) async throws -> Data
|
||||
{
|
||||
var request = URLRequest(url: site.usageURL)
|
||||
request.httpMethod = "GET"
|
||||
request.timeoutInterval = timeout
|
||||
request.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
|
||||
request.setValue("application/json, text/plain, */*", forHTTPHeaderField: "Accept")
|
||||
request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language")
|
||||
request.setValue(self.userAgent, forHTTPHeaderField: "User-Agent")
|
||||
request.setValue(site.webOrigin, forHTTPHeaderField: "Origin")
|
||||
request.setValue("\(site.webOrigin)/account/usage", forHTTPHeaderField: "Referer")
|
||||
request.setValue("XMLHttpRequest", forHTTPHeaderField: "X-Requested-With")
|
||||
request.setValue("2.5.35", forHTTPHeaderField: "Bx-V")
|
||||
|
||||
let response: ProviderHTTPResponse
|
||||
do {
|
||||
response = try await transport.response(for: request)
|
||||
} catch is CancellationError {
|
||||
throw CancellationError()
|
||||
} catch let error as URLError where error.code == .cancelled {
|
||||
throw CancellationError()
|
||||
} catch {
|
||||
throw QoderUsageError.networkError(error.localizedDescription)
|
||||
}
|
||||
if response.statusCode == 401 || response.statusCode == 403 {
|
||||
throw QoderUsageError.invalidCredentials
|
||||
}
|
||||
guard (200..<300).contains(response.statusCode) else {
|
||||
Self.log.error("Qoder usage API returned \(response.statusCode)")
|
||||
throw QoderUsageError.apiError(response.statusCode)
|
||||
}
|
||||
return response.data
|
||||
}
|
||||
|
||||
static func parseUsage(data: Data, now: Date = Date()) throws -> QoderUsageSnapshot {
|
||||
let response: QoderUsageResponse
|
||||
do {
|
||||
response = try JSONDecoder().decode(QoderUsageResponse.self, from: data)
|
||||
} catch {
|
||||
throw QoderUsageError.parseFailed("invalid JSON: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
guard let summary = response.totalQuota?.quotaSummary else {
|
||||
throw QoderUsageError.parseFailed("missing totalQuota.quotaSummary")
|
||||
}
|
||||
|
||||
let merged = try Self.mergedQuota(
|
||||
base: summary,
|
||||
shared: response.sharedQuota?.quotaSummary)
|
||||
|
||||
return QoderUsageSnapshot(
|
||||
usedCredits: merged.usedCredits,
|
||||
totalCredits: merged.totalCredits,
|
||||
remainingCredits: merged.remainingCredits,
|
||||
usagePercentage: merged.usagePercentage,
|
||||
unit: merged.unit,
|
||||
resetsAt: response.nextResetAt,
|
||||
updatedAt: now)
|
||||
}
|
||||
|
||||
private struct MergedQuota {
|
||||
let usedCredits: Double
|
||||
let totalCredits: Double
|
||||
let remainingCredits: Double
|
||||
let usagePercentage: Double
|
||||
let unit: String?
|
||||
}
|
||||
|
||||
private static func mergedQuota(
|
||||
base: QoderQuotaSummary,
|
||||
shared: QoderQuotaSummary?) throws -> MergedQuota
|
||||
{
|
||||
let baseUsed = base.usedValue
|
||||
let baseTotal = base.limitValue
|
||||
let baseRemaining = try Self.remainingCredits(for: base)
|
||||
|
||||
guard let shared else {
|
||||
let percentage = try Self.usagePercentage(
|
||||
used: baseUsed,
|
||||
total: baseTotal,
|
||||
remaining: baseRemaining,
|
||||
provided: base.usagePercentage)
|
||||
return MergedQuota(
|
||||
usedCredits: baseUsed,
|
||||
totalCredits: baseTotal,
|
||||
remainingCredits: baseRemaining,
|
||||
usagePercentage: percentage,
|
||||
unit: base.unit)
|
||||
}
|
||||
|
||||
let sharedUsed = shared.usedValue
|
||||
let sharedTotal = shared.limitValue
|
||||
let sharedRemaining = try Self.remainingCredits(for: shared)
|
||||
let used = baseUsed + sharedUsed
|
||||
let total = baseTotal + sharedTotal
|
||||
let remaining = baseRemaining + sharedRemaining
|
||||
let percentage = try Self.usagePercentage(
|
||||
used: used,
|
||||
total: total,
|
||||
remaining: remaining,
|
||||
provided: nil)
|
||||
return MergedQuota(
|
||||
usedCredits: used,
|
||||
totalCredits: total,
|
||||
remainingCredits: remaining,
|
||||
usagePercentage: percentage,
|
||||
unit: base.unit ?? shared.unit)
|
||||
}
|
||||
|
||||
private static func remainingCredits(for summary: QoderQuotaSummary) throws -> Double {
|
||||
guard summary.usedValue >= 0,
|
||||
summary.limitValue >= 0,
|
||||
summary.remainingValue.map({ $0 >= 0 }) ?? true
|
||||
else {
|
||||
throw QoderUsageError.parseFailed("quota values must be nonnegative")
|
||||
}
|
||||
return summary.remainingValue ?? max(0, summary.limitValue - summary.usedValue)
|
||||
}
|
||||
|
||||
private static func usagePercentage(
|
||||
used: Double,
|
||||
total: Double,
|
||||
remaining: Double,
|
||||
provided: Double?) throws -> Double
|
||||
{
|
||||
guard used >= 0, total >= 0, remaining >= 0 else {
|
||||
throw QoderUsageError.parseFailed("quota values must be nonnegative")
|
||||
}
|
||||
guard total > 0 else {
|
||||
guard used == 0, remaining == 0 else {
|
||||
throw QoderUsageError.parseFailed("zero total quota must have zero usage and remaining")
|
||||
}
|
||||
return provided ?? 100
|
||||
}
|
||||
return provided ?? (used / total) * 100
|
||||
}
|
||||
}
|
||||
|
||||
private struct QoderUsageResponse: Decodable {
|
||||
let totalQuota: QoderQuotaContainer?
|
||||
let sharedQuota: QoderQuotaContainer?
|
||||
let nextResetAt: Date?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case totalQuota
|
||||
case totalQuotaSnake = "total_quota"
|
||||
case sharedQuota
|
||||
case sharedQuotaSnake = "shared_quota"
|
||||
case nextResetAt
|
||||
case nextResetAtSnake = "next_reset_at"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.totalQuota =
|
||||
try container.decodeIfPresent(QoderQuotaContainer.self, forKey: .totalQuota) ??
|
||||
container.decodeIfPresent(QoderQuotaContainer.self, forKey: .totalQuotaSnake)
|
||||
self.sharedQuota =
|
||||
try container.decodeIfPresent(QoderQuotaContainer.self, forKey: .sharedQuota) ??
|
||||
container.decodeIfPresent(QoderQuotaContainer.self, forKey: .sharedQuotaSnake)
|
||||
self.nextResetAt =
|
||||
Self.decodeDate(from: container, forKey: .nextResetAt) ??
|
||||
Self.decodeDate(from: container, forKey: .nextResetAtSnake)
|
||||
}
|
||||
|
||||
private static func decodeDate(
|
||||
from container: KeyedDecodingContainer<CodingKeys>,
|
||||
forKey key: CodingKeys) -> Date?
|
||||
{
|
||||
if let value = try? container.decode(String.self, forKey: key) {
|
||||
return self.parseISO8601Date(value)
|
||||
}
|
||||
if let value = try? container.decode(Double.self, forKey: key) {
|
||||
let seconds = value > 10_000_000_000 ? value / 1000 : value
|
||||
return Date(timeIntervalSince1970: seconds)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func parseISO8601Date(_ value: String) -> Date? {
|
||||
let fractional = ISO8601DateFormatter()
|
||||
fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let date = fractional.date(from: value) {
|
||||
return date
|
||||
}
|
||||
|
||||
let plain = ISO8601DateFormatter()
|
||||
plain.formatOptions = [.withInternetDateTime]
|
||||
return plain.date(from: value)
|
||||
}
|
||||
}
|
||||
|
||||
private struct QoderQuotaContainer: Decodable {
|
||||
let quotaSummary: QoderQuotaSummary?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case quotaSummary
|
||||
case quotaSummarySnake = "quota_summary"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.quotaSummary =
|
||||
try container.decodeIfPresent(QoderQuotaSummary.self, forKey: .quotaSummary) ??
|
||||
container.decodeIfPresent(QoderQuotaSummary.self, forKey: .quotaSummarySnake)
|
||||
}
|
||||
}
|
||||
|
||||
private struct QoderQuotaSummary: Decodable {
|
||||
let usedValue: Double
|
||||
let limitValue: Double
|
||||
let remainingValue: Double?
|
||||
let usagePercentage: Double?
|
||||
let unit: String?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case usedValue
|
||||
case usedValueSnake = "used_value"
|
||||
case limitValue
|
||||
case limitValueSnake = "limit_value"
|
||||
case remainingValue
|
||||
case remainingValueSnake = "remaining_value"
|
||||
case usagePercentage
|
||||
case usagePercentageSnake = "usage_percentage"
|
||||
case unit
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.usedValue =
|
||||
try container.decodeIfPresent(Double.self, forKey: .usedValue) ??
|
||||
container.decode(Double.self, forKey: .usedValueSnake)
|
||||
self.limitValue =
|
||||
try container.decodeIfPresent(Double.self, forKey: .limitValue) ??
|
||||
container.decode(Double.self, forKey: .limitValueSnake)
|
||||
self.remainingValue =
|
||||
try container.decodeIfPresent(Double.self, forKey: .remainingValue) ??
|
||||
container.decodeIfPresent(Double.self, forKey: .remainingValueSnake)
|
||||
self.usagePercentage =
|
||||
try container.decodeIfPresent(Double.self, forKey: .usagePercentage) ??
|
||||
container.decodeIfPresent(Double.self, forKey: .usagePercentageSnake)
|
||||
self.unit = try container.decodeIfPresent(String.self, forKey: .unit)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user