Files
2026-07-13 12:22:33 +08:00

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