chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
import Foundation
|
||||
|
||||
#if os(macOS)
|
||||
import SweetCookieKit
|
||||
|
||||
public enum QoderCookieImporter {
|
||||
private static let log = CodexBarLog.logger(LogCategories.qoderCookie)
|
||||
private static let cookieClient = BrowserCookieClient()
|
||||
private static let cookieImportOrder: BrowserCookieImportOrder =
|
||||
ProviderDefaults.metadata[.qoder]?.browserCookieOrder ?? Browser.defaultImportOrder
|
||||
|
||||
public struct SessionInfo: Sendable {
|
||||
public let cookies: [HTTPCookie]
|
||||
public let sourceLabel: String
|
||||
public let site: QoderWebSite
|
||||
|
||||
public init(cookies: [HTTPCookie], sourceLabel: String, site: QoderWebSite) {
|
||||
self.cookies = cookies
|
||||
self.sourceLabel = sourceLabel
|
||||
self.site = site
|
||||
}
|
||||
|
||||
public var cookieHeader: String {
|
||||
self.cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ")
|
||||
}
|
||||
}
|
||||
|
||||
public static func importSession(
|
||||
browserDetection: BrowserDetection = BrowserDetection(),
|
||||
preferredBrowsers: [Browser] = [],
|
||||
logger: ((String) -> Void)? = nil) throws -> SessionInfo
|
||||
{
|
||||
guard let session = try self.importSessions(
|
||||
browserDetection: browserDetection,
|
||||
preferredBrowsers: preferredBrowsers,
|
||||
logger: logger).first
|
||||
else {
|
||||
throw QoderUsageError.missingCredentials
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
public static func importSessions(
|
||||
browserDetection: BrowserDetection = BrowserDetection(),
|
||||
preferredBrowsers: [Browser] = [],
|
||||
logger: ((String) -> Void)? = nil) throws -> [SessionInfo]
|
||||
{
|
||||
let installedBrowsers = preferredBrowsers.isEmpty
|
||||
? self.cookieImportOrder.cookieImportCandidates(using: browserDetection)
|
||||
: preferredBrowsers.cookieImportCandidates(using: browserDetection)
|
||||
var sessions: [SessionInfo] = []
|
||||
|
||||
for browserSource in installedBrowsers {
|
||||
for site in QoderWebSite.allCases {
|
||||
do {
|
||||
let query = Self.cookieQuery(for: site)
|
||||
let sources = try Self.cookieClient.codexBarRecords(
|
||||
matching: query,
|
||||
in: browserSource,
|
||||
logger: { msg in self.emit(msg, logger: logger) })
|
||||
for source in sources where !source.records.isEmpty {
|
||||
let records = self.records(source.records, for: site)
|
||||
let cookies = BrowserCookieClient.makeHTTPCookies(records, origin: query.origin)
|
||||
guard !cookies.isEmpty else { continue }
|
||||
self.emit("Found \(cookies.count) cookies in \(source.label)", logger: logger)
|
||||
sessions.append(SessionInfo(cookies: cookies, sourceLabel: source.label, site: site))
|
||||
}
|
||||
} catch {
|
||||
BrowserCookieAccessGate.recordIfNeeded(error)
|
||||
self.emit(
|
||||
"\(browserSource.displayName) cookie import failed: \(error.localizedDescription)",
|
||||
logger: logger)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
guard !sessions.isEmpty else {
|
||||
throw QoderUsageError.missingCredentials
|
||||
}
|
||||
return sessions
|
||||
}
|
||||
|
||||
static func cookieQuery(for site: QoderWebSite) -> BrowserCookieQuery {
|
||||
BrowserCookieQuery(domains: site.cookieDomains, domainMatch: .exact)
|
||||
}
|
||||
|
||||
static func records(_ records: [BrowserCookieRecord], for site: QoderWebSite) -> [BrowserCookieRecord] {
|
||||
records.filter { record in
|
||||
self.cookieDomainMatchesSite(record.domain, site: site)
|
||||
}
|
||||
}
|
||||
|
||||
private static func cookieDomainMatchesSite(_ rawDomain: String, site: QoderWebSite) -> Bool {
|
||||
let domain = self.normalizedCookieDomain(rawDomain)
|
||||
return site.cookieDomains.contains { self.normalizedCookieDomain($0) == domain }
|
||||
}
|
||||
|
||||
private static func normalizedCookieDomain(_ rawDomain: String) -> String {
|
||||
let trimmed = rawDomain.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard trimmed.hasPrefix(".") else { return trimmed }
|
||||
return String(trimmed.dropFirst())
|
||||
}
|
||||
|
||||
private static func emit(_ message: String, logger: ((String) -> Void)?) {
|
||||
logger?("[qoder-cookie] \(message)")
|
||||
self.log.debug(message)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,871 @@
|
||||
import Foundation
|
||||
|
||||
public enum QoderProviderDescriptor {
|
||||
public static let descriptor: ProviderDescriptor = Self.makeDescriptor()
|
||||
|
||||
static func makeDescriptor() -> ProviderDescriptor {
|
||||
ProviderDescriptor(
|
||||
id: .qoder,
|
||||
metadata: ProviderMetadata(
|
||||
id: .qoder,
|
||||
displayName: "Qoder",
|
||||
sessionLabel: "Credits",
|
||||
weeklyLabel: "Balance",
|
||||
opusLabel: nil,
|
||||
supportsOpus: false,
|
||||
supportsCredits: false,
|
||||
creditsHint: "Big model credits from the Qoder usage dashboard.",
|
||||
toggleTitle: "Show Qoder usage",
|
||||
cliName: "qoder",
|
||||
defaultEnabled: false,
|
||||
isPrimaryProvider: false,
|
||||
usesAccountFallback: false,
|
||||
browserCookieOrder: ProviderBrowserCookieDefaults.qoderCookieImportOrder,
|
||||
dashboardURL: QoderWebSite.international.dashboardURL.absoluteString,
|
||||
statusPageURL: nil,
|
||||
statusLinkURL: nil),
|
||||
branding: ProviderBranding(
|
||||
iconStyle: .qoder,
|
||||
iconResourceName: "ProviderIcon-qoder",
|
||||
color: ProviderColor(red: 16 / 255, green: 185 / 255, blue: 129 / 255)),
|
||||
tokenCost: ProviderTokenCostConfig(
|
||||
supportsTokenCost: false,
|
||||
noDataMessage: { "Qoder cost summary is not supported." }),
|
||||
fetchPlan: ProviderFetchPlan(
|
||||
sourceModes: [.auto, .web],
|
||||
pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [QoderWebFetchStrategy()] })),
|
||||
cli: ProviderCLIConfig(
|
||||
name: "qoder",
|
||||
aliases: [],
|
||||
versionDetector: nil))
|
||||
}
|
||||
|
||||
public static func dashboardURL(
|
||||
settings: ProviderSettingsSnapshot.QoderProviderSettings?,
|
||||
sourceLabel: String?) -> URL
|
||||
{
|
||||
guard settings?.cookieSource == .manual else {
|
||||
return self.dashboardURL(forSourceLabel: sourceLabel)
|
||||
}
|
||||
guard let site = QoderWebFetchStrategy.site(forManualCookieHeader: settings?.manualCookieHeader) else {
|
||||
return QoderWebSite.international.dashboardURL
|
||||
}
|
||||
return site.dashboardURL
|
||||
}
|
||||
|
||||
public static func dashboardURL(forSourceLabel sourceLabel: String?) -> URL {
|
||||
guard let sourceLabel, !sourceLabel.isEmpty else {
|
||||
return QoderWebSite.international.dashboardURL
|
||||
}
|
||||
return QoderWebFetchStrategy.site(for: sourceLabel).dashboardURL
|
||||
}
|
||||
}
|
||||
|
||||
struct QoderWebFetchStrategy: ProviderFetchStrategy {
|
||||
typealias UsageLoader = @Sendable (String, QoderWebSite, TimeInterval) async throws -> QoderUsageSnapshot
|
||||
typealias CookieResolver = @Sendable (ProviderFetchContext, Bool, Set<String>) throws -> QoderResolvedCookie?
|
||||
|
||||
let id: String = "qoder.web"
|
||||
let kind: ProviderFetchKind = .web
|
||||
private let usageLoader: UsageLoader
|
||||
private let cookieResolver: CookieResolver
|
||||
|
||||
init(
|
||||
usageLoader: @escaping UsageLoader = { cookieHeader, site, timeout in
|
||||
try await QoderUsageFetcher.fetchUsage(
|
||||
cookieHeader: cookieHeader,
|
||||
site: site,
|
||||
timeout: timeout)
|
||||
},
|
||||
cookieResolver: @escaping CookieResolver = QoderWebFetchStrategy.resolveCookieHeader)
|
||||
{
|
||||
self.usageLoader = usageLoader
|
||||
self.cookieResolver = cookieResolver
|
||||
}
|
||||
|
||||
func isAvailable(_ context: ProviderFetchContext) async -> Bool {
|
||||
let cookieSource = context.settings?.qoder?.cookieSource ?? .auto
|
||||
guard cookieSource != .off else { return false }
|
||||
if cookieSource == .manual {
|
||||
return CookieHeaderNormalizer.normalize(context.settings?.qoder?.manualCookieHeader) != nil
|
||||
}
|
||||
#if os(macOS)
|
||||
return true
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}
|
||||
|
||||
func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
|
||||
let cookieSource = context.settings?.qoder?.cookieSource ?? .auto
|
||||
let shouldRetry = cookieSource != .manual || Self.shouldRetryManualCookieHeader(
|
||||
context.settings?.qoder?.manualCookieHeader)
|
||||
var skippedSourceLabels = Set<String>()
|
||||
var allowCached = true
|
||||
var sawInvalidCredentials = false
|
||||
var terminalNonAuthError: Error?
|
||||
|
||||
while true {
|
||||
let resolvedCookie: QoderResolvedCookie
|
||||
do {
|
||||
guard let candidate = try self.cookieResolver(
|
||||
context,
|
||||
allowCached,
|
||||
skippedSourceLabels)
|
||||
else {
|
||||
if let exhaustionError = Self.errorForExhaustion(
|
||||
terminalNonAuthError: terminalNonAuthError,
|
||||
sawInvalidCredentials: sawInvalidCredentials)
|
||||
{
|
||||
throw exhaustionError
|
||||
}
|
||||
throw QoderUsageError.missingCredentials
|
||||
}
|
||||
resolvedCookie = candidate
|
||||
} catch QoderUsageError.missingCredentials
|
||||
where terminalNonAuthError != nil || sawInvalidCredentials
|
||||
{
|
||||
if let exhaustionError = Self.errorForExhaustion(
|
||||
terminalNonAuthError: terminalNonAuthError,
|
||||
sawInvalidCredentials: sawInvalidCredentials)
|
||||
{
|
||||
throw exhaustionError
|
||||
}
|
||||
throw QoderUsageError.missingCredentials
|
||||
}
|
||||
|
||||
do {
|
||||
let snapshot = try await self.usageLoader(
|
||||
resolvedCookie.cookieHeader,
|
||||
Self.site(for: resolvedCookie.sourceLabel),
|
||||
context.webTimeout)
|
||||
return self.makeResult(
|
||||
usage: snapshot.toUsageSnapshot(),
|
||||
sourceLabel: resolvedCookie.sourceLabel)
|
||||
} catch is CancellationError {
|
||||
throw CancellationError()
|
||||
} catch let fetchError {
|
||||
guard shouldRetry else { throw fetchError }
|
||||
if case QoderUsageError.invalidCredentials = fetchError {
|
||||
CookieHeaderCache.clear(provider: .qoder)
|
||||
sawInvalidCredentials = true
|
||||
} else {
|
||||
terminalNonAuthError = fetchError
|
||||
}
|
||||
if !resolvedCookie.isFromCache {
|
||||
skippedSourceLabels.insert(resolvedCookie.sourceLabel)
|
||||
}
|
||||
allowCached = false
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func errorForExhaustion(
|
||||
terminalNonAuthError: Error?,
|
||||
sawInvalidCredentials: Bool) -> Error?
|
||||
{
|
||||
if let terminalNonAuthError { return terminalNonAuthError }
|
||||
if sawInvalidCredentials { return QoderUsageError.invalidCredentials }
|
||||
return nil
|
||||
}
|
||||
|
||||
static func resolveCookieHeader(
|
||||
context: ProviderFetchContext,
|
||||
allowCached: Bool,
|
||||
skippingSourceLabels: Set<String>) throws -> QoderResolvedCookie?
|
||||
{
|
||||
if context.settings?.qoder?.cookieSource == .manual {
|
||||
let rawHeader = context.settings?.qoder?.manualCookieHeader
|
||||
let sites = try Self.sites(forManualCookieHeader: rawHeader)
|
||||
guard let manual = CookieHeaderNormalizer.normalize(rawHeader) else {
|
||||
throw QoderUsageError.missingCredentials
|
||||
}
|
||||
for site in sites {
|
||||
let sourceLabel = Self.sourceLabel(browserLabel: "manual", site: site)
|
||||
guard Self.shouldUseSourceLabel(sourceLabel, skipping: skippingSourceLabels) else {
|
||||
continue
|
||||
}
|
||||
return QoderResolvedCookie(cookieHeader: manual, sourceLabel: sourceLabel)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
if allowCached,
|
||||
let cached = CookieHeaderCache.load(provider: .qoder),
|
||||
!cached.cookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||
shouldUseSourceLabel(cached.sourceLabel, skipping: skippingSourceLabels)
|
||||
{
|
||||
return QoderResolvedCookie(
|
||||
cookieHeader: cached.cookieHeader,
|
||||
sourceLabel: cached.sourceLabel,
|
||||
isFromCache: true)
|
||||
}
|
||||
|
||||
let sessions: [QoderCookieImporter.SessionInfo]
|
||||
do {
|
||||
sessions = try QoderCookieImporter.importSessions(browserDetection: context.browserDetection)
|
||||
} catch {
|
||||
throw QoderUsageError.missingCredentials
|
||||
}
|
||||
guard let session = sessions.first(where: { session in
|
||||
Self.shouldUseSourceLabel(
|
||||
Self.sourceLabel(browserLabel: session.sourceLabel, site: session.site),
|
||||
skipping: skippingSourceLabels)
|
||||
})
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
guard !session.cookies.isEmpty else {
|
||||
throw QoderUsageError.missingCredentials
|
||||
}
|
||||
let sourceLabel = Self.sourceLabel(browserLabel: session.sourceLabel, site: session.site)
|
||||
CookieHeaderCache.store(
|
||||
provider: .qoder,
|
||||
cookieHeader: session.cookieHeader,
|
||||
sourceLabel: sourceLabel)
|
||||
return QoderResolvedCookie(cookieHeader: session.cookieHeader, sourceLabel: sourceLabel)
|
||||
#else
|
||||
throw QoderUsageError.missingCredentials
|
||||
#endif
|
||||
}
|
||||
|
||||
static func site(forManualCookieHeader rawHeader: String?) -> QoderWebSite? {
|
||||
guard case let .site(site) = self.manualCookieRoute(rawHeader) else { return nil }
|
||||
return site
|
||||
}
|
||||
|
||||
private enum ManualCookieRoute {
|
||||
case site(QoderWebSite)
|
||||
case invalid
|
||||
}
|
||||
|
||||
private enum CurlHeaderHostInspection {
|
||||
case ignored
|
||||
case site(QoderWebSite)
|
||||
case invalid
|
||||
}
|
||||
|
||||
private static func sites(forManualCookieHeader rawHeader: String?) throws -> [QoderWebSite] {
|
||||
switch self.manualCookieRoute(rawHeader) {
|
||||
case let .site(site):
|
||||
return [site]
|
||||
case .invalid:
|
||||
throw QoderUsageError.invalidCredentials
|
||||
}
|
||||
}
|
||||
|
||||
private static func manualCookieRoute(_ rawHeader: String?) -> ManualCookieRoute {
|
||||
guard let rawHeader else { return .site(.international) }
|
||||
if let curlRoute = self.curlRequestRoute(rawHeader) {
|
||||
return curlRoute
|
||||
}
|
||||
if let httpRoute = self.httpRequestRoute(rawHeader) {
|
||||
return httpRoute
|
||||
}
|
||||
return self.plainCookieRoute(rawHeader)
|
||||
}
|
||||
|
||||
private static func plainCookieRoute(_ rawHeader: String) -> ManualCookieRoute {
|
||||
var routedSite: QoderWebSite?
|
||||
for part in rawHeader.split(separator: ";") {
|
||||
let pieces = part.split(separator: "=", maxSplits: 1)
|
||||
guard pieces.count == 2 else { continue }
|
||||
let name = pieces[0].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard name == "domain" else { continue }
|
||||
let value = pieces[1]
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: "\"'"))
|
||||
guard let site = self.site(forHost: value) else {
|
||||
return .invalid
|
||||
}
|
||||
if let routedSite, routedSite != site {
|
||||
return .invalid
|
||||
}
|
||||
routedSite = site
|
||||
}
|
||||
return .site(routedSite ?? .international)
|
||||
}
|
||||
|
||||
private static func httpRequestRoute(_ rawHeader: String) -> ManualCookieRoute? {
|
||||
let supportedMethods = ["get", "post", "put", "patch", "delete", "head", "options"]
|
||||
let unsupportedKnownMethods = ["trace", "connect"]
|
||||
var requestSite: QoderWebSite?
|
||||
var sawRequestLine = false
|
||||
|
||||
for line in rawHeader.split(whereSeparator: \.isNewline) {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let parts = trimmed.split(whereSeparator: { $0 == " " || $0 == "\t" })
|
||||
guard parts.count >= 2 else {
|
||||
continue
|
||||
}
|
||||
let method = String(parts[0]).lowercased()
|
||||
let isHTTPVersionedLine = parts.count >= 3 && parts[2].lowercased().hasPrefix("http/")
|
||||
if unsupportedKnownMethods.contains(method) ||
|
||||
(isHTTPVersionedLine && self.isHTTPRequestMethodToken(String(parts[0])))
|
||||
{
|
||||
guard supportedMethods.contains(method) else { return .invalid }
|
||||
} else if !supportedMethods.contains(method) {
|
||||
continue
|
||||
}
|
||||
|
||||
guard !sawRequestLine else { return .invalid }
|
||||
sawRequestLine = true
|
||||
|
||||
let target = String(parts[1])
|
||||
if target.hasPrefix("/") {
|
||||
requestSite = nil
|
||||
} else if let site = self.site(forURLText: target) {
|
||||
requestSite = site
|
||||
} else {
|
||||
return .invalid
|
||||
}
|
||||
}
|
||||
|
||||
guard sawRequestLine else { return nil }
|
||||
let hostHeaderSites = self.hostHeaderSites(rawHeader)
|
||||
guard hostHeaderSites.allSatisfy({ $0 != nil }) else { return .invalid }
|
||||
let concreteHostSites = hostHeaderSites.compactMap(\.self)
|
||||
guard concreteHostSites.dropFirst().allSatisfy({ $0 == concreteHostSites.first }) else {
|
||||
return .invalid
|
||||
}
|
||||
|
||||
if let requestSite {
|
||||
if let hostSite = concreteHostSites.first, hostSite != requestSite {
|
||||
return .invalid
|
||||
}
|
||||
return .site(requestSite)
|
||||
}
|
||||
guard let hostSite = concreteHostSites.first else { return .invalid }
|
||||
return .site(hostSite)
|
||||
}
|
||||
|
||||
private static func curlRequestRoute(_ rawHeader: String) -> ManualCookieRoute? {
|
||||
guard let preprocessedHeader = self.preprocessedCurlShellText(rawHeader) else {
|
||||
return self.containsCurlExecutableText(rawHeader) ? .invalid : nil
|
||||
}
|
||||
|
||||
let tokens = self.shellTokens(preprocessedHeader)
|
||||
guard let curlIndex = self.curlCommandIndex(tokens) else {
|
||||
if tokens.contains(where: self.isCurlExecutableToken) {
|
||||
return .invalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
guard tokens.allSatisfy(self.isCurlTokenTextSafe) else { return .invalid }
|
||||
|
||||
guard let explicitTargets = self.explicitCurlURLTargets(tokens, after: curlIndex),
|
||||
let urlTargets = self.urlTokenTargets(tokens, after: curlIndex)
|
||||
else {
|
||||
return .invalid
|
||||
}
|
||||
|
||||
let targetIndices = Set(explicitTargets.map(\.index)).union(urlTargets.map(\.index))
|
||||
guard targetIndices.count == 1,
|
||||
let targetIndex = targetIndices.first
|
||||
else {
|
||||
return .invalid
|
||||
}
|
||||
let trustedIndex = tokens.index(after: curlIndex)
|
||||
|
||||
let targetSite: QoderWebSite
|
||||
if let explicitTarget = explicitTargets.first(where: { $0.index == targetIndex }) {
|
||||
targetSite = explicitTarget.site
|
||||
} else if targetIndex == trustedIndex,
|
||||
let trustedTarget = urlTargets.first(where: { $0.index == trustedIndex })
|
||||
{
|
||||
targetSite = trustedTarget.site
|
||||
} else {
|
||||
return .invalid
|
||||
}
|
||||
|
||||
guard let headerSites = self.curlHeaderHostSites(tokens, after: curlIndex),
|
||||
headerSites.allSatisfy({ $0 == targetSite })
|
||||
else {
|
||||
return .invalid
|
||||
}
|
||||
return .site(targetSite)
|
||||
}
|
||||
|
||||
private static func curlCommandIndex(_ tokens: [String]) -> Array<String>.Index? {
|
||||
var index = tokens.startIndex
|
||||
while index < tokens.endIndex, self.isShellAssignment(tokens[index]) {
|
||||
index = tokens.index(after: index)
|
||||
}
|
||||
guard index < tokens.endIndex else { return nil }
|
||||
guard self.isCurlExecutableToken(tokens[index]) else { return nil }
|
||||
return index
|
||||
}
|
||||
|
||||
private static func isCurlExecutableToken(_ token: String) -> Bool {
|
||||
guard !token.contains("="),
|
||||
!token.contains("://")
|
||||
else {
|
||||
return false
|
||||
}
|
||||
let executable = token.split(separator: "/").last.map(String.init) ?? token
|
||||
return executable.lowercased() == "curl"
|
||||
}
|
||||
|
||||
private static func containsCurlExecutableText(_ text: String) -> Bool {
|
||||
if self.shellTokens(text).contains(where: self.isCurlExecutableToken) {
|
||||
return true
|
||||
}
|
||||
let pattern = #"(^|[\s;])(?:[^\s;=]+/)?curl($|[\s;])"#
|
||||
return text.range(of: pattern, options: [.regularExpression, .caseInsensitive]) != nil
|
||||
}
|
||||
|
||||
private static func isShellAssignment(_ token: String) -> Bool {
|
||||
guard !token.contains(";") else { return false }
|
||||
guard let equals = token.firstIndex(of: "="),
|
||||
equals != token.startIndex
|
||||
else {
|
||||
return false
|
||||
}
|
||||
let name = token[..<equals]
|
||||
guard let first = name.first,
|
||||
first == "_" || first.isLetter
|
||||
else {
|
||||
return false
|
||||
}
|
||||
return name.allSatisfy { character in
|
||||
character == "_" || character.isLetter || character.isNumber
|
||||
}
|
||||
}
|
||||
|
||||
private static func isHTTPRequestMethodToken(_ token: String) -> Bool {
|
||||
guard !token.isEmpty else { return false }
|
||||
return token.allSatisfy { character in
|
||||
character.isASCII && character.isLetter
|
||||
}
|
||||
}
|
||||
|
||||
private static func explicitCurlURLTargets(
|
||||
_ tokens: [String],
|
||||
after curlIndex: Array<String>.Index) -> [(index: Array<String>.Index, site: QoderWebSite)]?
|
||||
{
|
||||
var targets: [(index: Array<String>.Index, site: QoderWebSite)] = []
|
||||
var index = tokens.index(after: curlIndex)
|
||||
while index < tokens.endIndex {
|
||||
let token = tokens[index]
|
||||
let lowercased = token.lowercased()
|
||||
if lowercased == "--url" {
|
||||
let valueIndex = tokens.index(after: index)
|
||||
guard valueIndex < tokens.endIndex,
|
||||
let site = self.site(forURLText: tokens[valueIndex])
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
targets.append((valueIndex, site))
|
||||
index = tokens.index(after: valueIndex)
|
||||
continue
|
||||
}
|
||||
if lowercased.hasPrefix("--url=") {
|
||||
guard let site = self.site(forURLText: String(token.dropFirst("--url=".count))) else {
|
||||
return nil
|
||||
}
|
||||
targets.append((index, site))
|
||||
}
|
||||
index = tokens.index(after: index)
|
||||
}
|
||||
return targets
|
||||
}
|
||||
|
||||
private static func urlTokenTargets(
|
||||
_ tokens: [String],
|
||||
after curlIndex: Array<String>.Index) -> [(index: Array<String>.Index, site: QoderWebSite)]?
|
||||
{
|
||||
var targets: [(index: Array<String>.Index, site: QoderWebSite)] = []
|
||||
var index = tokens.index(after: curlIndex)
|
||||
while index < tokens.endIndex {
|
||||
if let site = self.site(forURLText: tokens[index]) {
|
||||
targets.append((index, site))
|
||||
} else if self.host(forURLText: tokens[index]) != nil {
|
||||
return nil
|
||||
}
|
||||
index = tokens.index(after: index)
|
||||
}
|
||||
return targets
|
||||
}
|
||||
|
||||
private static func curlHeaderHostSites(
|
||||
_ tokens: [String],
|
||||
after curlIndex: Array<String>.Index) -> [QoderWebSite]?
|
||||
{
|
||||
var sites: [QoderWebSite] = []
|
||||
var index = tokens.index(after: curlIndex)
|
||||
while index < tokens.endIndex {
|
||||
let token = tokens[index]
|
||||
let lowercased = token.lowercased()
|
||||
let headerValue: String?
|
||||
if lowercased == "--config" || lowercased.hasPrefix("--config=") ||
|
||||
lowercased.hasPrefix("--expand-") ||
|
||||
lowercased == "--location-trusted" ||
|
||||
self.shortCurlOptions(token, contain: "K")
|
||||
{
|
||||
return nil
|
||||
}
|
||||
if lowercased == "--header" {
|
||||
let valueIndex = tokens.index(after: index)
|
||||
guard valueIndex < tokens.endIndex else { return nil }
|
||||
headerValue = tokens[valueIndex]
|
||||
index = tokens.index(after: valueIndex)
|
||||
} else if lowercased.hasPrefix("--header=") {
|
||||
headerValue = String(token.dropFirst("--header=".count))
|
||||
index = tokens.index(after: index)
|
||||
} else if let shortHeaderValue = self.shortCurlHeaderValue(token) {
|
||||
switch shortHeaderValue {
|
||||
case let .attached(value):
|
||||
headerValue = value
|
||||
index = tokens.index(after: index)
|
||||
case .nextToken:
|
||||
let valueIndex = tokens.index(after: index)
|
||||
guard valueIndex < tokens.endIndex else { return nil }
|
||||
headerValue = tokens[valueIndex]
|
||||
index = tokens.index(after: valueIndex)
|
||||
case .invalid:
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
index = tokens.index(after: index)
|
||||
continue
|
||||
}
|
||||
|
||||
guard let headerValue else { continue }
|
||||
switch self.inspectCurlHeaderHost(headerValue) {
|
||||
case .ignored:
|
||||
continue
|
||||
case let .site(site):
|
||||
sites.append(site)
|
||||
case .invalid:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return sites
|
||||
}
|
||||
|
||||
private enum ShortCurlHeaderValue {
|
||||
case attached(String)
|
||||
case nextToken
|
||||
case invalid
|
||||
}
|
||||
|
||||
private static func shortCurlOptions(_ token: String, contain option: Character) -> Bool {
|
||||
guard token.hasPrefix("-"),
|
||||
!token.hasPrefix("--")
|
||||
else {
|
||||
return false
|
||||
}
|
||||
return token.dropFirst().contains(option)
|
||||
}
|
||||
|
||||
private static func shortCurlHeaderValue(_ token: String) -> ShortCurlHeaderValue? {
|
||||
guard token.hasPrefix("-"),
|
||||
!token.hasPrefix("--")
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let options = String(token.dropFirst())
|
||||
guard let headerOption = options.firstIndex(of: "H") else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let safeBundledFlags = Set("fsSL")
|
||||
let bundledFlags = options[..<headerOption]
|
||||
guard bundledFlags.allSatisfy({ safeBundledFlags.contains($0) }) else {
|
||||
return .invalid
|
||||
}
|
||||
|
||||
let attached = String(options[options.index(after: headerOption)...])
|
||||
return attached.isEmpty ? .nextToken : .attached(attached)
|
||||
}
|
||||
|
||||
private static func inspectCurlHeaderHost(_ headerValue: String) -> CurlHeaderHostInspection {
|
||||
let trimmed = headerValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty,
|
||||
!trimmed.hasPrefix("@")
|
||||
else {
|
||||
return .invalid
|
||||
}
|
||||
|
||||
let pieces = trimmed.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)
|
||||
guard pieces.count == 2 else {
|
||||
let lowercased = trimmed.lowercased()
|
||||
if lowercased == "host" || lowercased.hasPrefix("host ") || lowercased.hasPrefix("host\t") ||
|
||||
lowercased == "host;" || lowercased.hasPrefix("host;")
|
||||
{
|
||||
return .invalid
|
||||
}
|
||||
return .ignored
|
||||
}
|
||||
|
||||
let name = pieces[0].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard name == "host" else { return .ignored }
|
||||
guard let site = self.site(forHost: String(pieces[1])) else { return .invalid }
|
||||
return .site(site)
|
||||
}
|
||||
|
||||
private enum ShellQuote: Equatable {
|
||||
case single
|
||||
case double
|
||||
}
|
||||
|
||||
private static func preprocessedCurlShellText(_ text: String) -> String? {
|
||||
let scalars = Array(text.unicodeScalars)
|
||||
var output = String.UnicodeScalarView()
|
||||
var index = scalars.startIndex
|
||||
var quote: ShellQuote?
|
||||
|
||||
while index < scalars.endIndex {
|
||||
let scalar = scalars[index]
|
||||
let nextIndex = scalars.index(after: index)
|
||||
|
||||
if scalar == "\\" {
|
||||
if quote == nil {
|
||||
if nextIndex < scalars.endIndex, scalars[nextIndex] == "\n" {
|
||||
index = scalars.index(after: nextIndex)
|
||||
continue
|
||||
}
|
||||
if nextIndex < scalars.endIndex, scalars[nextIndex] == "\r" {
|
||||
let afterReturn = scalars.index(after: nextIndex)
|
||||
if afterReturn < scalars.endIndex, scalars[afterReturn] == "\n" {
|
||||
index = scalars.index(after: afterReturn)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
if quote != .single,
|
||||
nextIndex < scalars.endIndex,
|
||||
self.isSupportedEscapedShellLiteral(scalars[nextIndex])
|
||||
{
|
||||
output.append(scalar)
|
||||
output.append(scalars[nextIndex])
|
||||
index = scalars.index(after: nextIndex)
|
||||
continue
|
||||
}
|
||||
if quote == .double {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
guard self.isShellScalarTextSafe(scalar) else { return nil }
|
||||
|
||||
switch quote {
|
||||
case .single:
|
||||
if scalar == "'" {
|
||||
quote = nil
|
||||
}
|
||||
case .double:
|
||||
if scalar == "\"" {
|
||||
quote = nil
|
||||
} else if scalar == "`" || self.isUnsupportedShellDollarExpansion(in: scalars, at: index) {
|
||||
return nil
|
||||
}
|
||||
case nil:
|
||||
if self.isUnsupportedShellControlOperator(scalar) {
|
||||
return nil
|
||||
} else if scalar == "'" {
|
||||
quote = .single
|
||||
} else if scalar == "\"" {
|
||||
quote = .double
|
||||
} else if scalar == "`" ||
|
||||
self.isUnsupportedShellDollarExpansion(in: scalars, at: index) ||
|
||||
self.isProcessSubstitution(in: scalars, at: index)
|
||||
{
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
output.append(scalar)
|
||||
index = nextIndex
|
||||
}
|
||||
|
||||
return quote == nil ? String(output) : nil
|
||||
}
|
||||
|
||||
private static func isUnsupportedShellControlOperator(_ scalar: UnicodeScalar) -> Bool {
|
||||
";|&<>".unicodeScalars.contains(scalar)
|
||||
}
|
||||
|
||||
private static func isSupportedEscapedShellLiteral(_ scalar: UnicodeScalar) -> Bool {
|
||||
scalar == "'" || scalar == "\"" || scalar == "\\"
|
||||
}
|
||||
|
||||
private static func isShellScalarTextSafe(_ scalar: UnicodeScalar) -> Bool {
|
||||
scalar.value >= 0x20 && scalar.value != 0x7F
|
||||
}
|
||||
|
||||
private static func isUnsupportedShellDollarExpansion(in scalars: [UnicodeScalar], at index: Int) -> Bool {
|
||||
guard scalars[index] == "$" else { return false }
|
||||
let nextIndex = scalars.index(after: index)
|
||||
guard nextIndex < scalars.endIndex else { return false }
|
||||
|
||||
let next = scalars[nextIndex]
|
||||
if next == "'" || next == "\"" || next == "(" || next == "{" || next == "[" {
|
||||
return true
|
||||
}
|
||||
if next == "_" || next.properties.isAlphabetic || (48...57).contains(Int(next.value)) {
|
||||
return true
|
||||
}
|
||||
return "*@#?$!-".unicodeScalars.contains(next)
|
||||
}
|
||||
|
||||
private static func isProcessSubstitution(in scalars: [UnicodeScalar], at index: Int) -> Bool {
|
||||
guard scalars[index] == "<" || scalars[index] == ">" else { return false }
|
||||
let nextIndex = scalars.index(after: index)
|
||||
return nextIndex < scalars.endIndex && scalars[nextIndex] == "("
|
||||
}
|
||||
|
||||
private static func isCurlTokenTextSafe(_ token: String) -> Bool {
|
||||
token.unicodeScalars.allSatisfy { scalar in
|
||||
scalar.value >= 0x20 && scalar.value != 0x7F
|
||||
}
|
||||
}
|
||||
|
||||
private static func hostHeaderSites(_ rawHeader: String) -> [QoderWebSite?] {
|
||||
var sites: [QoderWebSite?] = []
|
||||
for line in rawHeader.split(whereSeparator: \.isNewline) {
|
||||
let pieces = line.split(separator: ":", maxSplits: 1)
|
||||
guard pieces.count == 2 else { continue }
|
||||
let name = pieces[0].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard name == "host" else { continue }
|
||||
sites.append(self.site(forHost: String(pieces[1])))
|
||||
}
|
||||
return sites
|
||||
}
|
||||
|
||||
private static func host(forURLText text: String) -> String? {
|
||||
let trimmed = text
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: "\"'"))
|
||||
let lowercased = trimmed.lowercased()
|
||||
guard lowercased.hasPrefix("https://") || lowercased.hasPrefix("http://") else { return nil }
|
||||
return URL(string: trimmed)?.host(percentEncoded: false)?.lowercased()
|
||||
}
|
||||
|
||||
private static func site(forURLText text: String) -> QoderWebSite? {
|
||||
guard let host = self.host(forURLText: text) else { return nil }
|
||||
return self.site(forHost: host)
|
||||
}
|
||||
|
||||
private static func site(forHost host: String) -> QoderWebSite? {
|
||||
var normalized = String(host
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: "\"'"))
|
||||
.lowercased()
|
||||
.trimmingPrefix("."))
|
||||
if let portSeparator = normalized.lastIndex(of: ":") {
|
||||
let port = normalized[normalized.index(after: portSeparator)...]
|
||||
let hostname = normalized[..<portSeparator]
|
||||
guard !hostname.contains(":"),
|
||||
!port.isEmpty,
|
||||
port.allSatisfy({ $0.isASCII && $0.isNumber }),
|
||||
let portNumber = Int(port),
|
||||
(1...65535).contains(portNumber)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
normalized = String(hostname)
|
||||
}
|
||||
switch normalized {
|
||||
case "qoder.com", "www.qoder.com":
|
||||
return .international
|
||||
case "qoder.com.cn", "www.qoder.com.cn":
|
||||
return .china
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func shellTokens(_ text: String) -> [String] {
|
||||
var tokens: [String] = []
|
||||
var current = ""
|
||||
var quote: Character?
|
||||
var isEscaped = false
|
||||
|
||||
for character in text {
|
||||
if isEscaped {
|
||||
current.append(character)
|
||||
isEscaped = false
|
||||
continue
|
||||
}
|
||||
if character == "\\" {
|
||||
isEscaped = true
|
||||
continue
|
||||
}
|
||||
if let activeQuote = quote {
|
||||
if character == activeQuote {
|
||||
quote = nil
|
||||
} else {
|
||||
current.append(character)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if character == "'" || character == "\"" {
|
||||
quote = character
|
||||
continue
|
||||
}
|
||||
if character.isWhitespace {
|
||||
if !current.isEmpty {
|
||||
tokens.append(current)
|
||||
current = ""
|
||||
}
|
||||
continue
|
||||
}
|
||||
current.append(character)
|
||||
}
|
||||
|
||||
if isEscaped {
|
||||
current.append("\\")
|
||||
}
|
||||
if !current.isEmpty {
|
||||
tokens.append(current)
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
private static func shouldRetryManualCookieHeader(_ rawHeader: String?) -> Bool {
|
||||
((try? self.sites(forManualCookieHeader: rawHeader).count) ?? 0) > 1
|
||||
}
|
||||
|
||||
private static func sourceLabel(browserLabel: String, site: QoderWebSite) -> String {
|
||||
switch site {
|
||||
case .international:
|
||||
"\(browserLabel) / qoder.com"
|
||||
case .china:
|
||||
"\(browserLabel) / qoder.com.cn"
|
||||
}
|
||||
}
|
||||
|
||||
static func site(for sourceLabel: String) -> QoderWebSite {
|
||||
if sourceLabel == "qoder.com.cn" || sourceLabel.hasSuffix(" / qoder.com.cn") {
|
||||
return .china
|
||||
}
|
||||
if sourceLabel == "qoder.com" || sourceLabel.hasSuffix(" / qoder.com") {
|
||||
return .international
|
||||
}
|
||||
return .international
|
||||
}
|
||||
|
||||
private static func shouldUseSourceLabel(_ sourceLabel: String, skipping skippedLabels: Set<String>) -> Bool {
|
||||
!skippedLabels.contains(sourceLabel)
|
||||
}
|
||||
|
||||
func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
struct QoderResolvedCookie {
|
||||
let cookieHeader: String
|
||||
let sourceLabel: String
|
||||
let isFromCache: Bool
|
||||
|
||||
init(cookieHeader: String, sourceLabel: String, isFromCache: Bool = false) {
|
||||
self.cookieHeader = cookieHeader
|
||||
self.sourceLabel = sourceLabel
|
||||
self.isFromCache = isFromCache
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import Foundation
|
||||
|
||||
public enum QoderUsageError: LocalizedError, Sendable, Equatable {
|
||||
case missingCredentials
|
||||
case invalidCredentials
|
||||
case apiError(Int)
|
||||
case parseFailed(String)
|
||||
case networkError(String)
|
||||
|
||||
public var isAuthRelated: Bool {
|
||||
switch self {
|
||||
case .missingCredentials, .invalidCredentials: true
|
||||
case .apiError, .parseFailed, .networkError: false
|
||||
}
|
||||
}
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .missingCredentials:
|
||||
"Qoder session cookie not found. Sign in to qoder.com or qoder.com.cn in Chrome, or paste a Cookie header."
|
||||
case .invalidCredentials:
|
||||
"Qoder session is invalid or expired. Please sign in to Qoder again."
|
||||
case let .apiError(status):
|
||||
"Qoder API returned HTTP \(status)."
|
||||
case let .parseFailed(message):
|
||||
"Could not parse Qoder usage: \(message)"
|
||||
case let .networkError(message):
|
||||
"Qoder API error: \(message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import Foundation
|
||||
|
||||
public struct QoderUsageSnapshot: Sendable {
|
||||
public let usedCredits: Double
|
||||
public let totalCredits: Double
|
||||
public let remainingCredits: Double
|
||||
public let usagePercentage: Double
|
||||
public let unit: String?
|
||||
public let resetsAt: Date?
|
||||
public let updatedAt: Date
|
||||
|
||||
public init(
|
||||
usedCredits: Double,
|
||||
totalCredits: Double,
|
||||
remainingCredits: Double,
|
||||
usagePercentage: Double,
|
||||
unit: String?,
|
||||
resetsAt: Date? = nil,
|
||||
updatedAt: Date = Date())
|
||||
{
|
||||
self.usedCredits = usedCredits
|
||||
self.totalCredits = totalCredits
|
||||
self.remainingCredits = remainingCredits
|
||||
self.usagePercentage = usagePercentage
|
||||
self.unit = unit
|
||||
self.resetsAt = resetsAt
|
||||
self.updatedAt = updatedAt
|
||||
}
|
||||
|
||||
public func toUsageSnapshot() -> UsageSnapshot {
|
||||
let creditSummary = "\(Self.formatCredits(self.usedCredits)) / \(Self.formatCredits(self.totalCredits)) credits"
|
||||
let primary = RateWindow(
|
||||
usedPercent: min(100, max(0, self.usagePercentage)),
|
||||
windowMinutes: nil,
|
||||
resetsAt: self.resetsAt,
|
||||
resetDescription: creditSummary)
|
||||
|
||||
let identity = ProviderIdentitySnapshot(
|
||||
providerID: .qoder,
|
||||
accountEmail: nil,
|
||||
accountOrganization: nil,
|
||||
loginMethod: nil)
|
||||
|
||||
return UsageSnapshot(
|
||||
primary: primary,
|
||||
secondary: nil,
|
||||
tertiary: nil,
|
||||
providerCost: nil,
|
||||
updatedAt: self.updatedAt,
|
||||
identity: identity)
|
||||
}
|
||||
|
||||
private static func formatCredits(_ value: Double) -> String {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .decimal
|
||||
formatter.locale = Locale(identifier: "en_US")
|
||||
formatter.maximumFractionDigits = value.rounded() == value ? 0 : 2
|
||||
return formatter.string(from: NSNumber(value: value)) ?? "\(value)"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user