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