336 lines · 13.0 KB · Swift
RawDownload
import Foundation #if canImport(FoundationNetworking)import FoundationNetworking#endif #if os(macOS)import SweetCookieKit // MARK: - Abacus Usage Fetcher public enum AbacusUsageFetcher {    private struct BrowserFetchRequest {        let browserDetection: BrowserDetection        let preferredBrowsers: [Browser]        let label: String        let timeout: TimeInterval        let logger: ((String) -> Void)?    }     /// Parsed JSON dictionaries are treated as immutable snapshots here and are    /// only moved between sibling fetch tasks before being consumed locally.    private struct JSONDictionaryBox: @unchecked Sendable {        let value: [String: Any]    }     private static let log = CodexBarLog.logger(LogCategories.abacusUsage)    private static let computePointsURL =        URL(string: "https://apps.abacus.ai/api/_getOrganizationComputePoints")!    private static let billingInfoURL =        URL(string: "https://apps.abacus.ai/api/_getBillingInfo")!     public static func fetchUsage(        cookieHeaderOverride: String? = nil,        browserDetection: BrowserDetection = BrowserDetection(),        timeout: TimeInterval = 15.0,        logger: ((String) -> Void)? = nil) async throws -> AbacusUsageSnapshot    {        // Manual cookie header — no fallback, errors propagate directly        if let override = CookieHeaderNormalizer.normalize(cookieHeaderOverride) {            self.emit("Using manual cookie header", logger: logger)            return try await self.fetchWithCookieHeader(override, timeout: timeout, logger: logger)        }         // Cached cookie header — fall back to a fresh browser import when the        // cached session is rejected or looks stale.        if let cached = CookieHeaderCache.load(provider: .abacus),           !cached.cookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty        {            self.emit("Using cached cookie header from \(cached.sourceLabel)", logger: logger)            do {                return try await self.fetchWithCookieHeader(                    cached.cookieHeader, timeout: timeout, logger: logger)            } catch let error as AbacusUsageError where error.shouldTryNextImportedSession {                if error.shouldClearCachedCookie {                    CookieHeaderCache.clear(provider: .abacus)                    self.emit(                        "Cached cookie failed (\(error.localizedDescription)); cleared, trying fresh import",                        logger: logger)                } else {                    self.emit(                        "Cached cookie failed (\(error.localizedDescription)); trying fresh import",                        logger: logger)                }            }        }         // Fresh browser import — try Chrome first (AGENTS.md default), then broaden        // to all browsers if Chrome has no sessions OR if every imported Chrome        // session is exhausted without a successful fetch.        var lastError: AbacusUsageError = .noSessionCookie        if let snapshot = try await self.tryFetchFromBrowsers(            BrowserFetchRequest(                browserDetection: browserDetection,                preferredBrowsers: [.chrome],                label: "Chrome",                timeout: timeout,                logger: logger),            lastError: &lastError)        {            return snapshot        }         self.emit("Chrome sessions exhausted; falling back to all browsers", logger: logger)        if let snapshot = try await self.tryFetchFromBrowsers(            BrowserFetchRequest(                browserDetection: browserDetection,                preferredBrowsers: [],                label: "all browsers",                timeout: timeout,                logger: logger),            lastError: &lastError)        {            return snapshot        }         throw lastError    }     /// Tries to import sessions from `preferredBrowsers` and fetch usage. Returns    /// the snapshot on success, nil if no sessions were available or every    /// imported session was exhausted without success.    private static func tryFetchFromBrowsers(        _ request: BrowserFetchRequest,        lastError: inout AbacusUsageError) async throws -> AbacusUsageSnapshot?    {        let sessions: [AbacusCookieImporter.SessionInfo]        do {            sessions = try AbacusCookieImporter.importSessions(                browserDetection: request.browserDetection,                preferredBrowsers: request.preferredBrowsers,                logger: request.logger)        } catch {            BrowserCookieAccessGate.recordIfNeeded(error)            self.emit(                "\(request.label) cookie import failed: \(error.localizedDescription)",                logger: request.logger)            return nil        }         for session in sessions {            self.emit("Trying cookies from \(session.sourceLabel)", logger: request.logger)            do {                let snapshot = try await self.fetchWithCookieHeader(                    session.cookieHeader,                    timeout: request.timeout,                    logger: request.logger)                CookieHeaderCache.store(                    provider: .abacus,                    cookieHeader: session.cookieHeader,                    sourceLabel: session.sourceLabel)                return snapshot            } catch let error as AbacusUsageError where error.shouldTryNextImportedSession {                self.emit(                    "\(session.sourceLabel): \(error.localizedDescription), trying next source",                    logger: request.logger)                lastError = error                continue            } catch {                self.emit(                    "\(session.sourceLabel): \(error.localizedDescription), trying next source",                    logger: request.logger)                lastError = .networkError(error.localizedDescription)                continue            }        }        return nil    }     // MARK: - API Requests     private static func fetchWithCookieHeader(        _ cookieHeader: String,        timeout: TimeInterval,        logger: ((String) -> Void)? = nil) async throws -> AbacusUsageSnapshot    {        enum FetchPart: Sendable {            case computePoints(JSONDictionaryBox)            case billingInfoSuccess(JSONDictionaryBox)            case billingInfoFailure(String)        }         // Fetch compute points (required, full timeout) and billing info        // (optional, shorter budget) concurrently. Billing is bounded so a        // slow/flaky billing endpoint can't delay credit rendering.        let billingBudget = min(timeout, 5.0)         var computePointsResult: [String: Any]?        var billingInfoResult: [String: Any] = [:]         try await withThrowingTaskGroup(of: FetchPart.self) { group in            group.addTask {                let result = try await self.fetchJSON(                    url: self.computePointsURL,                    method: "GET",                    cookieHeader: cookieHeader,                    timeout: timeout)                return .computePoints(JSONDictionaryBox(value: result))            }            group.addTask {                do {                    let result = try await self.fetchJSON(                        url: self.billingInfoURL,                        method: "POST",                        cookieHeader: cookieHeader,                        timeout: billingBudget)                    return .billingInfoSuccess(JSONDictionaryBox(value: result))                } catch {                    return .billingInfoFailure(error.localizedDescription)                }            }             while let result = try await group.next() {                switch result {                case let .computePoints(value):                    computePointsResult = value.value                case let .billingInfoSuccess(value):                    billingInfoResult = value.value                case let .billingInfoFailure(message):                    self.emit(                        "Billing info fetch failed: \(message); credits shown without plan/reset",                        logger: logger)                }            }        }         guard let computePointsResult else {            throw AbacusUsageError.networkError("Abacus compute points fetch did not complete")        }         return try self.parseResults(computePoints: computePointsResult, billingInfo: billingInfoResult)    }     private static func fetchJSON(        url: URL, method: String, cookieHeader: String, timeout: TimeInterval) async throws -> [String: Any]    {        var request = URLRequest(url: url)        request.httpMethod = method        request.timeoutInterval = timeout        request.setValue("application/json", forHTTPHeaderField: "Content-Type")        request.setValue("application/json", forHTTPHeaderField: "Accept")        request.setValue(cookieHeader, forHTTPHeaderField: "Cookie")        if method == "POST" {            request.httpBody = Data("{}".utf8)        }         let response = try await ProviderHTTPClient.shared.response(for: request)        let data = response.data        let statusCode = response.statusCode         if statusCode == 401 || statusCode == 403 {            throw AbacusUsageError.unauthorized        }         guard statusCode == 200 else {            let body = String(data: data.prefix(200), encoding: .utf8) ?? ""            throw AbacusUsageError.networkError("HTTP \(statusCode): \(body)")        }         let parsed: Any        do {            parsed = try JSONSerialization.jsonObject(with: data)        } catch {            let preview = String(data: data.prefix(200), encoding: .utf8) ?? "<non-UTF8>"            throw AbacusUsageError.parseFailed(                "\(url.lastPathComponent): \(error.localizedDescription) — preview: \(preview)")        }         guard let root = parsed as? [String: Any] else {            throw AbacusUsageError.parseFailed("\(url.lastPathComponent): top-level JSON is not a dictionary")        }         guard root["success"] as? Bool == true,              let result = root["result"] as? [String: Any]        else {            let errorMsg = (root["error"] as? String ?? "Unknown error").lowercased()            if errorMsg.contains("expired") || errorMsg.contains("session")                || errorMsg.contains("login") || errorMsg.contains("authenticate")                || errorMsg.contains("unauthorized") || errorMsg.contains("unauthenticated")                || errorMsg.contains("forbidden")            {                throw AbacusUsageError.unauthorized            }            throw AbacusUsageError.parseFailed("\(url.lastPathComponent): \(errorMsg)")        }         return result    }     // MARK: - Parsing     private static func parseResults(        computePoints: [String: Any], billingInfo: [String: Any]) throws -> AbacusUsageSnapshot    {        let totalCredits = self.double(from: computePoints["totalComputePoints"])        let creditsLeft = self.double(from: computePoints["computePointsLeft"])         guard let totalCredits, let creditsLeft else {            let keys = computePoints.keys.sorted().joined(separator: ", ")            throw AbacusUsageError.parseFailed(                "Missing credit fields in compute points response. Keys: [\(keys)]")        }         let creditsUsed = totalCredits - creditsLeft         let nextBillingDate = billingInfo["nextBillingDate"] as? String        let currentTier = billingInfo["currentTier"] as? String        let resetsAt = self.parseDate(nextBillingDate)         return AbacusUsageSnapshot(            creditsUsed: creditsUsed,            creditsTotal: totalCredits,            resetsAt: resetsAt,            planName: currentTier)    }     private static func double(from value: Any?) -> Double? {        if let d = value as? Double { return d }        if let i = value as? Int { return Double(i) }        if let n = value as? NSNumber { return n.doubleValue }        return nil    }     private static func parseDate(_ isoString: String?) -> Date? {        guard let isoString else { return nil }        let formatter = ISO8601DateFormatter()        formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]        if let date = formatter.date(from: isoString) { return date }        formatter.formatOptions = [.withInternetDateTime]        return formatter.date(from: isoString)    }     // MARK: - Logging     private static func emit(_ message: String, logger: ((String) -> Void)?) {        logger?("[abacus] \(message)")        self.log.debug(message)    }} #else // MARK: - Abacus (Unsupported) public enum AbacusUsageFetcher {    public static func fetchUsage(        cookieHeaderOverride _: String? = nil,        browserDetection _: BrowserDetection = BrowserDetection(),        timeout _: TimeInterval = 15.0,        logger _: ((String) -> Void)? = nil) async throws -> AbacusUsageSnapshot    {        throw AbacusUsageError.notSupported    }} #endif