337 lines
13 KiB
Swift
337 lines
13 KiB
Swift
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
|