From 9ac791bac2e0a549617da2678f8027250e751171 Mon Sep 17 00:00:00 2001 From: "Christian C. Berclaz" Date: Wed, 4 Mar 2026 13:39:09 +0100 Subject: [PATCH 1/9] feat(abacus): add Abacus AI provider with cookie-based usage fetching Add support for Abacus AI (ChatLLM/RouteLLM) as a new provider. Uses browser cookie authentication against the describeUser API endpoint to fetch compute point usage. Values are in centi-credits (divided by 100 for display). Primary window shows monthly credit usage as percentage, secondary window shows 7-day usage. Reset date derived from lastBilledAt + 1 month. --- .../Abacus/AbacusProviderImplementation.swift | 77 +++++ .../Abacus/AbacusSettingsStore.swift | 61 ++++ .../ProviderImplementationRegistry.swift | 1 + .../Resources/ProviderIcon-abacus.svg | 18 + Sources/CodexBar/UsageStore.swift | 2 + Sources/CodexBarCLI/TokenAccountCLI.swift | 11 +- .../CodexBarCore/Logging/LogCategories.swift | 1 + .../Abacus/AbacusProviderDescriptor.swift | 79 +++++ .../Providers/Abacus/AbacusUsageFetcher.swift | 327 ++++++++++++++++++ .../Providers/ProviderDescriptor.swift | 1 + .../Providers/ProviderSettingsSnapshot.swift | 27 +- .../CodexBarCore/Providers/Providers.swift | 2 + .../Vendored/CostUsage/CostUsageScanner.swift | 2 +- .../CodexBarWidgetProvider.swift | 1 + .../CodexBarWidget/CodexBarWidgetViews.swift | 3 + 15 files changed, 606 insertions(+), 7 deletions(-) create mode 100644 Sources/CodexBar/Providers/Abacus/AbacusProviderImplementation.swift create mode 100644 Sources/CodexBar/Providers/Abacus/AbacusSettingsStore.swift create mode 100644 Sources/CodexBar/Resources/ProviderIcon-abacus.svg create mode 100644 Sources/CodexBarCore/Providers/Abacus/AbacusProviderDescriptor.swift create mode 100644 Sources/CodexBarCore/Providers/Abacus/AbacusUsageFetcher.swift diff --git a/Sources/CodexBar/Providers/Abacus/AbacusProviderImplementation.swift b/Sources/CodexBar/Providers/Abacus/AbacusProviderImplementation.swift new file mode 100644 index 000000000..2c3809118 --- /dev/null +++ b/Sources/CodexBar/Providers/Abacus/AbacusProviderImplementation.swift @@ -0,0 +1,77 @@ +import CodexBarCore +import CodexBarMacroSupport +import Foundation +import SwiftUI + +@ProviderImplementationRegistration +struct AbacusProviderImplementation: ProviderImplementation { + let id: UsageProvider = .abacus + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.abacusCookieSource + _ = settings.abacusCookieHeader + } + + @MainActor + func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { + .abacus(context.settings.abacusSettingsSnapshot(tokenOverride: context.tokenOverride)) + } + + @MainActor + func tokenAccountsVisibility(context: ProviderSettingsContext, support: TokenAccountSupport) -> Bool { + guard support.requiresManualCookieSource else { return true } + if !context.settings.tokenAccounts(for: context.provider).isEmpty { return true } + return context.settings.abacusCookieSource == .manual + } + + @MainActor + func applyTokenAccountCookieSource(settings: SettingsStore) { + if settings.abacusCookieSource != .manual { + settings.abacusCookieSource = .manual + } + } + + @MainActor + func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + let cookieBinding = Binding( + get: { context.settings.abacusCookieSource.rawValue }, + set: { raw in + context.settings.abacusCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto + }) + let cookieOptions = ProviderCookieSourceUI.options( + allowsOff: false, + keychainDisabled: context.settings.debugDisableKeychainAccess) + + let cookieSubtitle: () -> String? = { + ProviderCookieSourceUI.subtitle( + source: context.settings.abacusCookieSource, + keychainDisabled: context.settings.debugDisableKeychainAccess, + auto: "Automatic imports browser cookies.", + manual: "Paste a Cookie header or cURL capture from the Abacus AI dashboard.", + off: "Abacus AI cookies are disabled.") + } + + return [ + ProviderSettingsPickerDescriptor( + id: "abacus-cookie-source", + title: "Cookie source", + subtitle: "Automatic imports browser cookies.", + dynamicSubtitle: cookieSubtitle, + binding: cookieBinding, + options: cookieOptions, + isVisible: nil, + onChange: nil, + trailingText: { + guard let entry = CookieHeaderCache.load(provider: .abacus) else { return nil } + let when = entry.storedAt.relativeDescription() + return "Cached: \(entry.sourceLabel) • \(when)" + }), + ] + } + + @MainActor + func settingsFields(context _: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [] + } +} diff --git a/Sources/CodexBar/Providers/Abacus/AbacusSettingsStore.swift b/Sources/CodexBar/Providers/Abacus/AbacusSettingsStore.swift new file mode 100644 index 000000000..d5e5c3e30 --- /dev/null +++ b/Sources/CodexBar/Providers/Abacus/AbacusSettingsStore.swift @@ -0,0 +1,61 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var abacusCookieHeader: String { + get { self.configSnapshot.providerConfig(for: .abacus)?.sanitizedCookieHeader ?? "" } + set { + self.updateProviderConfig(provider: .abacus) { entry in + entry.cookieHeader = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .abacus, field: "cookieHeader", value: newValue) + } + } + + var abacusCookieSource: ProviderCookieSource { + get { self.resolvedCookieSource(provider: .abacus, fallback: .auto) } + set { + self.updateProviderConfig(provider: .abacus) { entry in + entry.cookieSource = newValue + } + self.logProviderModeChange(provider: .abacus, field: "cookieSource", value: newValue.rawValue) + } + } +} + +extension SettingsStore { + func abacusSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot + .AbacusProviderSettings { + ProviderSettingsSnapshot.AbacusProviderSettings( + cookieSource: self.abacusSnapshotCookieSource(tokenOverride: tokenOverride), + manualCookieHeader: self.abacusSnapshotCookieHeader(tokenOverride: tokenOverride)) + } + + private func abacusSnapshotCookieHeader(tokenOverride: TokenAccountOverride?) -> String { + let fallback = self.abacusCookieHeader + guard let support = TokenAccountSupportCatalog.support(for: .abacus), + case .cookieHeader = support.injection + else { + return fallback + } + guard let account = ProviderTokenAccountSelection.selectedAccount( + provider: .abacus, + settings: self, + override: tokenOverride) + else { + return fallback + } + return TokenAccountSupportCatalog.normalizedCookieHeader(account.token, support: support) + } + + private func abacusSnapshotCookieSource(tokenOverride _: TokenAccountOverride?) -> ProviderCookieSource { + let fallback = self.abacusCookieSource + guard let support = TokenAccountSupportCatalog.support(for: .abacus), + support.requiresManualCookieSource + else { + return fallback + } + if self.tokenAccounts(for: .abacus).isEmpty { return fallback } + return .manual + } +} diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift index 7938b3d49..a42732a21 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift @@ -35,6 +35,7 @@ enum ProviderImplementationRegistry { case .synthetic: SyntheticProviderImplementation() case .openrouter: OpenRouterProviderImplementation() case .warp: WarpProviderImplementation() + case .abacus: AbacusProviderImplementation() } } diff --git a/Sources/CodexBar/Resources/ProviderIcon-abacus.svg b/Sources/CodexBar/Resources/ProviderIcon-abacus.svg new file mode 100644 index 000000000..468bb3dfe --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-abacus.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 5876fc351..113b9ef21 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1261,6 +1261,8 @@ extension UsageStore { let hasAny = resolution != nil let source = resolution?.source.rawValue ?? "none" text = "WARP_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" + case .abacus: + text = "Abacus AI debug log not yet implemented" case .gemini, .antigravity, .opencode, .factory, .copilot, .vertexai, .kilo, .kiro, .kimi, .kimik2, .jetbrains: text = unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented" diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index 7324fa837..2645cb3a6 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -157,6 +157,11 @@ struct TokenAccountCLIContext { return self.makeSnapshot( jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings( ideBasePath: nil)) + case .abacus: + return self.makeSnapshot( + abacus: ProviderSettingsSnapshot.AbacusProviderSettings( + cookieSource: cookieSource, + manualCookieHeader: cookieHeader)) case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic, .openrouter, .warp: return nil } @@ -175,7 +180,8 @@ struct TokenAccountCLIContext { augment: ProviderSettingsSnapshot.AugmentProviderSettings? = nil, amp: ProviderSettingsSnapshot.AmpProviderSettings? = nil, ollama: ProviderSettingsSnapshot.OllamaProviderSettings? = nil, - jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings? = nil) -> ProviderSettingsSnapshot + jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings? = nil, + abacus: ProviderSettingsSnapshot.AbacusProviderSettings? = nil) -> ProviderSettingsSnapshot { ProviderSettingsSnapshot.make( codex: codex, @@ -190,7 +196,8 @@ struct TokenAccountCLIContext { augment: augment, amp: amp, ollama: ollama, - jetbrains: jetbrains) + jetbrains: jetbrains, + abacus: abacus) } func environment( diff --git a/Sources/CodexBarCore/Logging/LogCategories.swift b/Sources/CodexBarCore/Logging/LogCategories.swift index 37a7726ef..b231ca235 100644 --- a/Sources/CodexBarCore/Logging/LogCategories.swift +++ b/Sources/CodexBarCore/Logging/LogCategories.swift @@ -1,4 +1,5 @@ public enum LogCategories { + public static let abacusUsage = "abacus-usage" public static let amp = "amp" public static let antigravity = "antigravity" public static let app = "app" diff --git a/Sources/CodexBarCore/Providers/Abacus/AbacusProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Abacus/AbacusProviderDescriptor.swift new file mode 100644 index 000000000..a8d58db8e --- /dev/null +++ b/Sources/CodexBarCore/Providers/Abacus/AbacusProviderDescriptor.swift @@ -0,0 +1,79 @@ +import CodexBarMacroSupport +import Foundation + +#if os(macOS) +import SweetCookieKit +#endif + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum AbacusProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .abacus, + metadata: ProviderMetadata( + id: .abacus, + displayName: "Abacus AI", + sessionLabel: "Credits", + weeklyLabel: "Weekly", + opusLabel: nil, + supportsOpus: false, + supportsCredits: true, + creditsHint: "Abacus AI compute credits for ChatLLM/RouteLLM usage.", + toggleTitle: "Show Abacus AI usage", + cliName: "abacusai", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: ProviderBrowserCookieDefaults.defaultImportOrder, + dashboardURL: "https://apps.abacus.ai/chatllm/admin/compute-points-usage", + statusPageURL: "https://status.abacus.ai", + statusLinkURL: nil), + branding: ProviderBranding( + iconStyle: .abacus, + iconResourceName: "ProviderIcon-abacus", + color: ProviderColor(red: 56 / 255, green: 189 / 255, blue: 248 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Abacus AI cost summary is not supported." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .web], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in + [AbacusWebFetchStrategy()] + })), + cli: ProviderCLIConfig( + name: "abacusai", + aliases: ["abacus-ai"], + versionDetector: nil)) + } +} + +struct AbacusWebFetchStrategy: ProviderFetchStrategy { + let id: String = "abacus.web" + let kind: ProviderFetchKind = .web + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + guard context.settings?.abacus?.cookieSource != .off else { return false } + return true + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let manual = Self.manualCookieHeader(from: context) + let logger: ((String) -> Void)? = context.verbose + ? { msg in CodexBarLog.logger(LogCategories.abacusUsage).verbose(msg) } + : nil + let snap = try await AbacusUsageFetcher.fetchUsage(cookieHeaderOverride: manual, logger: logger) + return self.makeResult( + usage: snap.toUsageSnapshot(), + sourceLabel: "web") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } + + private static func manualCookieHeader(from context: ProviderFetchContext) -> String? { + guard context.settings?.abacus?.cookieSource == .manual else { return nil } + return CookieHeaderNormalizer.normalize(context.settings?.abacus?.manualCookieHeader) + } +} diff --git a/Sources/CodexBarCore/Providers/Abacus/AbacusUsageFetcher.swift b/Sources/CodexBarCore/Providers/Abacus/AbacusUsageFetcher.swift new file mode 100644 index 000000000..3db83e683 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Abacus/AbacusUsageFetcher.swift @@ -0,0 +1,327 @@ +import Foundation + +#if os(macOS) +import SweetCookieKit + +private let abacusCookieImportOrder: BrowserCookieImportOrder = + ProviderDefaults.metadata[.abacus]?.browserCookieOrder ?? Browser.defaultImportOrder + +// MARK: - Abacus Cookie Importer + +public enum AbacusCookieImporter { + private static let cookieClient = BrowserCookieClient() + private static let cookieDomains = ["abacus.ai", "apps.abacus.ai"] + + public struct SessionInfo: Sendable { + public let cookies: [HTTPCookie] + public let sourceLabel: String + + public var cookieHeader: String { + self.cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ") + } + } + + public static func importSession(logger: ((String) -> Void)? = nil) throws -> SessionInfo { + let log: (String) -> Void = { msg in logger?("[abacus-cookie] \(msg)") } + + for browserSource in abacusCookieImportOrder { + do { + let query = BrowserCookieQuery(domains: cookieDomains) + let sources = try Self.cookieClient.records( + matching: query, + in: browserSource, + logger: log) + for source in sources where !source.records.isEmpty { + let httpCookies = BrowserCookieClient.makeHTTPCookies(source.records, origin: query.origin) + if !httpCookies.isEmpty { + let cookieNames = httpCookies.map(\.name).joined(separator: ", ") + log("Found \(httpCookies.count) cookies in \(source.label): \(cookieNames)") + return SessionInfo(cookies: httpCookies, sourceLabel: source.label) + } + } + } catch { + BrowserCookieAccessGate.recordIfNeeded(error) + log("\(browserSource.displayName) cookie import failed: \(error.localizedDescription)") + } + } + + throw AbacusUsageError.noSessionCookie + } +} + +// MARK: - Abacus Usage Snapshot + +public struct AbacusUsageSnapshot: Sendable { + public let creditsUsed: Double? + public let creditsTotal: Double? + public let last24HoursUsage: Double? + public let last7DaysUsage: Double? + public let resetsAt: Date? + public let planName: String? + public let accountEmail: String? + public let accountOrganization: String? + + public func toUsageSnapshot() -> UsageSnapshot { + let percentUsed: Double = if let used = self.creditsUsed, let total = self.creditsTotal, total > 0 { + (used / total) * 100.0 + } else { + 0 + } + + let resetDesc: String? = if let used = self.creditsUsed, let total = self.creditsTotal { + "\(Self.formatCredits(used)) / \(Self.formatCredits(total)) credits" + } else { + nil + } + + let primary = RateWindow( + usedPercent: percentUsed, + windowMinutes: nil, + resetsAt: self.resetsAt, + resetDescription: resetDesc) + + let secondary: RateWindow? = if let weekly = self.last7DaysUsage { + RateWindow( + usedPercent: 0, + windowMinutes: 7 * 24 * 60, + resetsAt: nil, + resetDescription: "\(Self.formatCredits(weekly)) credits (7 days)") + } else { + nil + } + + let identity = ProviderIdentitySnapshot( + providerID: .abacus, + accountEmail: self.accountEmail, + accountOrganization: self.accountOrganization, + loginMethod: self.planName) + + return UsageSnapshot( + primary: primary, + secondary: secondary, + tertiary: nil, + providerCost: nil, + updatedAt: Date(), + identity: identity) + } + + private static func formatCredits(_ value: Double) -> String { + if value >= 1000 { + let formatted = String(format: "%,.0f", value) + return formatted + } + return String(format: "%.1f", value) + } +} + +// MARK: - Abacus Usage Error + +public enum AbacusUsageError: LocalizedError, Sendable { + case noSessionCookie + case sessionExpired + case networkError(String) + case parseFailed(String) + case unauthorized + + public var errorDescription: String? { + switch self { + case .noSessionCookie: + "No Abacus AI session found. Please log in to apps.abacus.ai in \(abacusCookieImportOrder.loginHint)." + case .sessionExpired: + "Abacus AI session expired. Please log in again." + case let .networkError(msg): + "Abacus AI API error: \(msg)" + case let .parseFailed(msg): + "Could not parse Abacus AI usage: \(msg)" + case .unauthorized: + "Unauthorized. Please log in to Abacus AI." + } + } +} + +// MARK: - Abacus Usage Fetcher + +public enum AbacusUsageFetcher { + private static let apiURL = URL(string: "https://apps.abacus.ai/api/v0/describeUser")! + + public static func fetchUsage( + cookieHeaderOverride: String? = nil, + timeout: TimeInterval = 15.0, + logger: ((String) -> Void)? = nil) async throws -> AbacusUsageSnapshot + { + let log: (String) -> Void = { msg in logger?("[abacus] \(msg)") } + + if let override = CookieHeaderNormalizer.normalize(cookieHeaderOverride) { + log("Using manual cookie header") + return try await Self.fetchWithCookieHeader(override, timeout: timeout) + } + + if let cached = CookieHeaderCache.load(provider: .abacus), + !cached.cookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + log("Using cached cookie header from \(cached.sourceLabel)") + do { + return try await Self.fetchWithCookieHeader(cached.cookieHeader, timeout: timeout) + } catch let error as AbacusUsageError { + switch error { + case .unauthorized, .sessionExpired: + CookieHeaderCache.clear(provider: .abacus) + default: + throw error + } + } + } + + do { + let session = try AbacusCookieImporter.importSession(logger: log) + log("Using cookies from \(session.sourceLabel)") + let snapshot = try await Self.fetchWithCookieHeader(session.cookieHeader, timeout: timeout) + CookieHeaderCache.store( + provider: .abacus, + cookieHeader: session.cookieHeader, + sourceLabel: session.sourceLabel) + return snapshot + } catch { + BrowserCookieAccessGate.recordIfNeeded(error) + log("Browser cookie import failed: \(error.localizedDescription)") + } + + throw AbacusUsageError.noSessionCookie + } + + private static func fetchWithCookieHeader( + _ cookieHeader: String, + timeout: TimeInterval) async throws -> AbacusUsageSnapshot + { + var request = URLRequest(url: apiURL) + request.httpMethod = "POST" + request.timeoutInterval = timeout + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + request.httpBody = "{}".data(using: .utf8) + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw AbacusUsageError.networkError("Invalid response") + } + + if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { + throw AbacusUsageError.unauthorized + } + + guard httpResponse.statusCode == 200 else { + let body = String(data: data, encoding: .utf8) ?? "" + throw AbacusUsageError.networkError("HTTP \(httpResponse.statusCode): \(body)") + } + + return try Self.parseResponse(data) + } + + // MARK: - Manual JSON Parsing (resilient, no Codable) + + private static func parseResponse(_ data: Data) throws -> AbacusUsageSnapshot { + guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw AbacusUsageError.parseFailed("Invalid JSON") + } + + guard root["success"] as? Bool == true else { + let errorMsg = root["error"] as? String ?? "Unknown error" + throw AbacusUsageError.parseFailed("API returned error: \(errorMsg)") + } + + guard let result = root["result"] as? [String: Any] else { + throw AbacusUsageError.parseFailed("Missing 'result' object") + } + + let email = result["email"] as? String + let organization = (result["organization"] as? [String: Any]) + let orgName = organization?["name"] as? String + let subscriptionTier = organization?["subscriptionTier"] as? String + let lastBilledAt = organization?["lastBilledAt"] as? String + + let computePointInfo = organization?["computePointInfo"] as? [String: Any] + let currMonthAvailPoints = Self.double(from: computePointInfo?["currMonthAvailPoints"]) + let currMonthUsage = Self.double(from: computePointInfo?["currMonthUsage"]) + let last24HoursUsage = Self.double(from: computePointInfo?["last24HoursUsage"]) + let last7DaysUsage = Self.double(from: computePointInfo?["last7DaysUsage"]) + + // Divide all point values by 100 (centi-credits) + let creditsUsed = currMonthUsage.map { $0 / 100.0 } + let creditsTotal = currMonthAvailPoints.map { $0 / 100.0 } + let daily = last24HoursUsage.map { $0 / 100.0 } + let weekly = last7DaysUsage.map { $0 / 100.0 } + + // Compute reset date: lastBilledAt + 1 calendar month + let resetsAt: Date? = Self.computeResetDate(from: lastBilledAt) + + return AbacusUsageSnapshot( + creditsUsed: creditsUsed, + creditsTotal: creditsTotal, + last24HoursUsage: daily, + last7DaysUsage: weekly, + resetsAt: resetsAt, + planName: subscriptionTier, + accountEmail: email, + accountOrganization: orgName) + } + + 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 computeResetDate(from isoString: String?) -> Date? { + guard let isoString else { return nil } + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + var date = formatter.date(from: isoString) + if date == nil { + formatter.formatOptions = [.withInternetDateTime] + date = formatter.date(from: isoString) + } + guard let billedAt = date else { return nil } + return Calendar.current.date(byAdding: .month, value: 1, to: billedAt) + } +} + +#else + +// MARK: - Abacus (Unsupported) + +public enum AbacusUsageError: LocalizedError, Sendable { + case notSupported + + public var errorDescription: String? { + "Abacus AI is only supported on macOS." + } +} + +public struct AbacusUsageSnapshot: Sendable { + public init() {} + + public func toUsageSnapshot() -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow(usedPercent: 0, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + tertiary: nil, + providerCost: nil, + updatedAt: Date(), + identity: nil) + } +} + +public enum AbacusUsageFetcher { + public static func fetchUsage( + cookieHeaderOverride _: String? = nil, + timeout _: TimeInterval = 15.0, + logger _: ((String) -> Void)? = nil) async throws -> AbacusUsageSnapshot + { + throw AbacusUsageError.notSupported + } +} + +#endif diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift index d7a3669d4..522430f5e 100644 --- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift @@ -75,6 +75,7 @@ public enum ProviderDescriptorRegistry { .synthetic: SyntheticProviderDescriptor.descriptor, .openrouter: OpenRouterProviderDescriptor.descriptor, .warp: WarpProviderDescriptor.descriptor, + .abacus: AbacusProviderDescriptor.descriptor, ] private static let bootstrap: Void = { for provider in UsageProvider.allCases { diff --git a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift index e9bf84f9e..c74bee1e5 100644 --- a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift +++ b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift @@ -17,7 +17,8 @@ public struct ProviderSettingsSnapshot: Sendable { augment: AugmentProviderSettings? = nil, amp: AmpProviderSettings? = nil, ollama: OllamaProviderSettings? = nil, - jetbrains: JetBrainsProviderSettings? = nil) -> ProviderSettingsSnapshot + jetbrains: JetBrainsProviderSettings? = nil, + abacus: AbacusProviderSettings? = nil) -> ProviderSettingsSnapshot { ProviderSettingsSnapshot( debugMenuEnabled: debugMenuEnabled, @@ -35,7 +36,8 @@ public struct ProviderSettingsSnapshot: Sendable { augment: augment, amp: amp, ollama: ollama, - jetbrains: jetbrains) + jetbrains: jetbrains, + abacus: abacus) } public struct CodexProviderSettings: Sendable { @@ -191,6 +193,16 @@ public struct ProviderSettingsSnapshot: Sendable { } } + public struct AbacusProviderSettings: Sendable { + public let cookieSource: ProviderCookieSource + public let manualCookieHeader: String? + + public init(cookieSource: ProviderCookieSource, manualCookieHeader: String?) { + self.cookieSource = cookieSource + self.manualCookieHeader = manualCookieHeader + } + } + public let debugMenuEnabled: Bool public let debugKeepCLISessionsAlive: Bool public let codex: CodexProviderSettings? @@ -207,6 +219,7 @@ public struct ProviderSettingsSnapshot: Sendable { public let amp: AmpProviderSettings? public let ollama: OllamaProviderSettings? public let jetbrains: JetBrainsProviderSettings? + public let abacus: AbacusProviderSettings? public var jetbrainsIDEBasePath: String? { self.jetbrains?.ideBasePath @@ -228,7 +241,8 @@ public struct ProviderSettingsSnapshot: Sendable { augment: AugmentProviderSettings?, amp: AmpProviderSettings?, ollama: OllamaProviderSettings?, - jetbrains: JetBrainsProviderSettings? = nil) + jetbrains: JetBrainsProviderSettings? = nil, + abacus: AbacusProviderSettings? = nil) { self.debugMenuEnabled = debugMenuEnabled self.debugKeepCLISessionsAlive = debugKeepCLISessionsAlive @@ -246,6 +260,7 @@ public struct ProviderSettingsSnapshot: Sendable { self.amp = amp self.ollama = ollama self.jetbrains = jetbrains + self.abacus = abacus } } @@ -264,6 +279,7 @@ public enum ProviderSettingsSnapshotContribution: Sendable { case amp(ProviderSettingsSnapshot.AmpProviderSettings) case ollama(ProviderSettingsSnapshot.OllamaProviderSettings) case jetbrains(ProviderSettingsSnapshot.JetBrainsProviderSettings) + case abacus(ProviderSettingsSnapshot.AbacusProviderSettings) } public struct ProviderSettingsSnapshotBuilder: Sendable { @@ -283,6 +299,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { public var amp: ProviderSettingsSnapshot.AmpProviderSettings? public var ollama: ProviderSettingsSnapshot.OllamaProviderSettings? public var jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings? + public var abacus: ProviderSettingsSnapshot.AbacusProviderSettings? public init(debugMenuEnabled: Bool = false, debugKeepCLISessionsAlive: Bool = false) { self.debugMenuEnabled = debugMenuEnabled @@ -305,6 +322,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { case let .amp(value): self.amp = value case let .ollama(value): self.ollama = value case let .jetbrains(value): self.jetbrains = value + case let .abacus(value): self.abacus = value } } @@ -325,6 +343,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { augment: self.augment, amp: self.amp, ollama: self.ollama, - jetbrains: self.jetbrains) + jetbrains: self.jetbrains, + abacus: self.abacus) } } diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index f48eefe43..c8192d1d9 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -25,6 +25,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { case synthetic case warp case openrouter + case abacus } // swiftformat:enable sortDeclarations @@ -52,6 +53,7 @@ public enum IconStyle: Sendable, CaseIterable { case synthetic case warp case openrouter + case abacus case combined } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index a5ef942b5..e0df5ebde 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -71,7 +71,7 @@ enum CostUsageScanner { } return self.loadClaudeDaily(provider: .vertexai, range: range, now: now, options: filtered) case .zai, .gemini, .antigravity, .cursor, .opencode, .factory, .copilot, .minimax, .kilo, .kiro, .kimi, - .kimik2, .augment, .jetbrains, .amp, .ollama, .synthetic, .openrouter, .warp: + .kimik2, .augment, .jetbrains, .amp, .ollama, .synthetic, .openrouter, .warp, .abacus: return emptyReport } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index eb0d00574..7d8539277 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -67,6 +67,7 @@ enum ProviderChoice: String, AppEnum { case .synthetic: return nil // Synthetic not yet supported in widgets case .openrouter: return nil // OpenRouter not yet supported in widgets case .warp: return nil // Warp not yet supported in widgets + case .abacus: return nil // Abacus AI not yet supported in widgets } } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index fbb8c5d9c..04faaf1d0 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -279,6 +279,7 @@ private struct ProviderSwitchChip: View { case .synthetic: "Synthetic" case .openrouter: "OpenRouter" case .warp: "Warp" + case .abacus: "Abacus" } } } @@ -618,6 +619,8 @@ enum WidgetColors { Color(red: 111 / 255, green: 66 / 255, blue: 193 / 255) // OpenRouter purple case .warp: Color(red: 147 / 255, green: 139 / 255, blue: 180 / 255) + case .abacus: + Color(red: 56 / 255, green: 189 / 255, blue: 248 / 255) } } } From 9552ffe6a76ade02df15264ac44b6a5d5cd791cf Mon Sep 17 00:00:00 2001 From: "Christian C. Berclaz" Date: Wed, 4 Mar 2026 14:20:41 +0100 Subject: [PATCH 2/9] fix(abacus): fix usage display formatting and match Claude pattern - Fix credits format string (Swift String(format:) has no comma flag; use NumberFormatter for thousands separators) - Remove secondary weekly window (Abacus has monthly billing only) - Show credits detail below gauge (follow Warp/Kilo pattern for resetDescription rendering) - Add pace/reserve/deficit estimate on primary monthly window - Remove inactive status page URL (abacus.statuspage.io is inactive) - Hide account email/org from menu (not relevant for display) - Set windowMinutes to 30 days so pace calculation works correctly --- Sources/CodexBar/MenuDescriptor.swift | 13 +++++--- Sources/CodexBar/UsagePaceText.swift | 2 +- .../Abacus/AbacusProviderDescriptor.swift | 2 +- .../Providers/Abacus/AbacusUsageFetcher.swift | 32 ++++++++----------- 4 files changed, 24 insertions(+), 25 deletions(-) diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 8e7972a20..0aab55da7 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -118,9 +118,9 @@ struct MenuDescriptor { if let snap = store.snapshot(for: provider) { let resetStyle = settings.resetTimeDisplayStyle if let primary = snap.primary { - let primaryWindow = if provider == .warp || provider == .kilo { - // Warp/Kilo primary uses resetDescription for non-reset detail (e.g., "Unlimited", "X/Y credits"). - // Avoid rendering it as a "Resets ..." line. + let primaryWindow = if provider == .warp || provider == .kilo || provider == .abacus { + // Warp/Kilo/Abacus primary uses resetDescription for non-reset detail + // (e.g., "Unlimited", "X/Y credits"). Avoid rendering it as a "Resets ..." line. RateWindow( usedPercent: primary.usedPercent, windowMinutes: primary.windowMinutes, @@ -135,12 +135,17 @@ struct MenuDescriptor { window: primaryWindow, resetStyle: resetStyle, showUsed: settings.usageBarsShowUsed) - if provider == .warp || provider == .kilo, + if provider == .warp || provider == .kilo || provider == .abacus, let detail = primary.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines), !detail.isEmpty { entries.append(.text(detail, .secondary)) } + if provider == .abacus, + let paceSummary = UsagePaceText.weeklySummary(provider: provider, window: primary) + { + entries.append(.text(paceSummary, .secondary)) + } } if let weekly = snap.secondary { let weeklyResetOverride: String? = { diff --git a/Sources/CodexBar/UsagePaceText.swift b/Sources/CodexBar/UsagePaceText.swift index 920e38ef9..135a37c3e 100644 --- a/Sources/CodexBar/UsagePaceText.swift +++ b/Sources/CodexBar/UsagePaceText.swift @@ -57,7 +57,7 @@ enum UsagePaceText { } static func weeklyPace(provider: UsageProvider, window: RateWindow, now: Date) -> UsagePace? { - guard provider == .codex || provider == .claude else { return nil } + guard provider == .codex || provider == .claude || provider == .abacus else { return nil } guard window.remainingPercent > 0 else { return nil } guard let pace = UsagePace.weekly(window: window, now: now, defaultWindowMinutes: 10080) else { return nil } guard pace.expectedUsedPercent >= Self.minimumExpectedPercent else { return nil } diff --git a/Sources/CodexBarCore/Providers/Abacus/AbacusProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Abacus/AbacusProviderDescriptor.swift index a8d58db8e..7876b3e10 100644 --- a/Sources/CodexBarCore/Providers/Abacus/AbacusProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Abacus/AbacusProviderDescriptor.swift @@ -27,7 +27,7 @@ public enum AbacusProviderDescriptor { usesAccountFallback: false, browserCookieOrder: ProviderBrowserCookieDefaults.defaultImportOrder, dashboardURL: "https://apps.abacus.ai/chatllm/admin/compute-points-usage", - statusPageURL: "https://status.abacus.ai", + statusPageURL: nil, statusLinkURL: nil), branding: ProviderBranding( iconStyle: .abacus, diff --git a/Sources/CodexBarCore/Providers/Abacus/AbacusUsageFetcher.swift b/Sources/CodexBarCore/Providers/Abacus/AbacusUsageFetcher.swift index 3db83e683..2cdaff21a 100644 --- a/Sources/CodexBarCore/Providers/Abacus/AbacusUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Abacus/AbacusUsageFetcher.swift @@ -74,31 +74,25 @@ public struct AbacusUsageSnapshot: Sendable { nil } + // Use windowMinutes matching the monthly billing cycle so pace calculation works. + // Approximate 1 month as 30 days. + let windowMinutes = 30 * 24 * 60 + let primary = RateWindow( usedPercent: percentUsed, - windowMinutes: nil, + windowMinutes: windowMinutes, resetsAt: self.resetsAt, resetDescription: resetDesc) - let secondary: RateWindow? = if let weekly = self.last7DaysUsage { - RateWindow( - usedPercent: 0, - windowMinutes: 7 * 24 * 60, - resetsAt: nil, - resetDescription: "\(Self.formatCredits(weekly)) credits (7 days)") - } else { - nil - } - let identity = ProviderIdentitySnapshot( providerID: .abacus, - accountEmail: self.accountEmail, - accountOrganization: self.accountOrganization, + accountEmail: nil, + accountOrganization: nil, loginMethod: self.planName) return UsageSnapshot( primary: primary, - secondary: secondary, + secondary: nil, tertiary: nil, providerCost: nil, updatedAt: Date(), @@ -106,11 +100,11 @@ public struct AbacusUsageSnapshot: Sendable { } private static func formatCredits(_ value: Double) -> String { - if value >= 1000 { - let formatted = String(format: "%,.0f", value) - return formatted - } - return String(format: "%.1f", value) + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.maximumFractionDigits = value >= 1000 ? 0 : 1 + formatter.groupingSeparator = "," + return formatter.string(from: NSNumber(value: value)) ?? String(format: "%.0f", value) } } From 957ed3de01d690ffa36a762bc1cb283501e576a5 Mon Sep 17 00:00:00 2001 From: "Christian C. Berclaz" Date: Wed, 4 Mar 2026 15:29:58 +0100 Subject: [PATCH 3/9] feat(abacus): add pace tick and detail lines to card view - Show pace indicator tick (green/red) on the primary gauge bar - Add reserve/deficit line below gauge with pace right label - Show credits used/total as detail text below the gauge - Restore account identity (email, org, plan tier) in card header --- Sources/CodexBar/MenuCardView.swift | 31 ++++++++++++++++--- .../Providers/Abacus/AbacusUsageFetcher.swift | 4 +-- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index e0c54f639..00cbf4774 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -918,6 +918,29 @@ extension UsageMenuCardView.Model { if input.provider == .warp || input.provider == .kilo, primary.resetsAt == nil { primaryResetText = nil } + // Abacus: show credits as detail, compute pace on the primary monthly window + var primaryDetailLeft: String? + var primaryDetailRight: String? + var primaryPacePercent: Double? + var primaryPaceOnTop = true + if input.provider == .abacus { + if let detail = primary.resetDescription, + !detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + primaryDetailText = detail + } + if let paceDetail = Self.weeklyPaceDetail( + provider: input.provider, + window: primary, + now: input.now, + showUsed: input.usageBarsShowUsed) + { + primaryDetailLeft = paceDetail.leftLabel + primaryDetailRight = paceDetail.rightLabel + primaryPacePercent = paceDetail.pacePercent + primaryPaceOnTop = paceDetail.paceOnTop + } + } metrics.append(Metric( id: "primary", title: input.metadata.sessionLabel, @@ -926,10 +949,10 @@ extension UsageMenuCardView.Model { percentStyle: percentStyle, resetText: primaryResetText, detailText: primaryDetailText, - detailLeftText: nil, - detailRightText: nil, - pacePercent: nil, - paceOnTop: true)) + detailLeftText: primaryDetailLeft, + detailRightText: primaryDetailRight, + pacePercent: primaryPacePercent, + paceOnTop: primaryPaceOnTop)) } if let weekly = snapshot.secondary { let paceDetail = Self.weeklyPaceDetail( diff --git a/Sources/CodexBarCore/Providers/Abacus/AbacusUsageFetcher.swift b/Sources/CodexBarCore/Providers/Abacus/AbacusUsageFetcher.swift index 2cdaff21a..fcafe1220 100644 --- a/Sources/CodexBarCore/Providers/Abacus/AbacusUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Abacus/AbacusUsageFetcher.swift @@ -86,8 +86,8 @@ public struct AbacusUsageSnapshot: Sendable { let identity = ProviderIdentitySnapshot( providerID: .abacus, - accountEmail: nil, - accountOrganization: nil, + accountEmail: self.accountEmail, + accountOrganization: self.accountOrganization, loginMethod: self.planName) return UsageSnapshot( From e316c9d4c7b1c872b493f64ae9af6dc7b453113d Mon Sep 17 00:00:00 2001 From: "Christian C. Berclaz" Date: Wed, 4 Mar 2026 21:09:33 +0100 Subject: [PATCH 4/9] fix(abacus): use correct API endpoints for credits and billing date Switch from describeUser (stale centi-credit values, no billing date) to _getOrganizationComputePoints (accurate credits in real units) and _getBillingInfo (exact nextBillingDate and subscription tier). Both endpoints are fetched concurrently. --- .../Providers/Abacus/AbacusUsageFetcher.swift | 110 +++++++++--------- 1 file changed, 56 insertions(+), 54 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Abacus/AbacusUsageFetcher.swift b/Sources/CodexBarCore/Providers/Abacus/AbacusUsageFetcher.swift index fcafe1220..38b3f1294 100644 --- a/Sources/CodexBarCore/Providers/Abacus/AbacusUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Abacus/AbacusUsageFetcher.swift @@ -54,12 +54,8 @@ public enum AbacusCookieImporter { public struct AbacusUsageSnapshot: Sendable { public let creditsUsed: Double? public let creditsTotal: Double? - public let last24HoursUsage: Double? - public let last7DaysUsage: Double? public let resetsAt: Date? public let planName: String? - public let accountEmail: String? - public let accountOrganization: String? public func toUsageSnapshot() -> UsageSnapshot { let percentUsed: Double = if let used = self.creditsUsed, let total = self.creditsTotal, total > 0 { @@ -86,8 +82,8 @@ public struct AbacusUsageSnapshot: Sendable { let identity = ProviderIdentitySnapshot( providerID: .abacus, - accountEmail: self.accountEmail, - accountOrganization: self.accountOrganization, + accountEmail: nil, + accountOrganization: nil, loginMethod: self.planName) return UsageSnapshot( @@ -136,7 +132,10 @@ public enum AbacusUsageError: LocalizedError, Sendable { // MARK: - Abacus Usage Fetcher public enum AbacusUsageFetcher { - private static let apiURL = URL(string: "https://apps.abacus.ai/api/v0/describeUser")! + 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, @@ -187,18 +186,35 @@ public enum AbacusUsageFetcher { _ cookieHeader: String, timeout: TimeInterval) async throws -> AbacusUsageSnapshot { - var request = URLRequest(url: apiURL) - request.httpMethod = "POST" + // Fetch compute points (GET) and billing info (POST) concurrently + async let computePoints = Self.fetchJSON( + url: computePointsURL, method: "GET", cookieHeader: cookieHeader, timeout: timeout) + async let billingInfo = Self.fetchJSON( + url: billingInfoURL, method: "POST", cookieHeader: cookieHeader, timeout: timeout) + + let cpResult = try await computePoints + let biResult = (try? await billingInfo) ?? [:] + + return Self.parseResults(computePoints: cpResult, billingInfo: biResult) + } + + 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") - request.httpBody = "{}".data(using: .utf8) + if method == "POST" { + request.httpBody = "{}".data(using: .utf8) + } let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { - throw AbacusUsageError.networkError("Invalid response") + throw AbacusUsageError.networkError("Invalid response from \(url.lastPathComponent)") } if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { @@ -210,55 +226,45 @@ public enum AbacusUsageFetcher { throw AbacusUsageError.networkError("HTTP \(httpResponse.statusCode): \(body)") } - return try Self.parseResponse(data) - } - - // MARK: - Manual JSON Parsing (resilient, no Codable) - - private static func parseResponse(_ data: Data) throws -> AbacusUsageSnapshot { guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - throw AbacusUsageError.parseFailed("Invalid JSON") + throw AbacusUsageError.parseFailed("Invalid JSON from \(url.lastPathComponent)") } - guard root["success"] as? Bool == true else { + guard root["success"] as? Bool == true, + let result = root["result"] as? [String: Any] + else { let errorMsg = root["error"] as? String ?? "Unknown error" - throw AbacusUsageError.parseFailed("API returned error: \(errorMsg)") + throw AbacusUsageError.parseFailed("\(url.lastPathComponent): \(errorMsg)") } - guard let result = root["result"] as? [String: Any] else { - throw AbacusUsageError.parseFailed("Missing 'result' object") - } + return result + } - let email = result["email"] as? String - let organization = (result["organization"] as? [String: Any]) - let orgName = organization?["name"] as? String - let subscriptionTier = organization?["subscriptionTier"] as? String - let lastBilledAt = organization?["lastBilledAt"] as? String + // MARK: - Parsing - let computePointInfo = organization?["computePointInfo"] as? [String: Any] - let currMonthAvailPoints = Self.double(from: computePointInfo?["currMonthAvailPoints"]) - let currMonthUsage = Self.double(from: computePointInfo?["currMonthUsage"]) - let last24HoursUsage = Self.double(from: computePointInfo?["last24HoursUsage"]) - let last7DaysUsage = Self.double(from: computePointInfo?["last7DaysUsage"]) + private static func parseResults( + computePoints: [String: Any], billingInfo: [String: Any] + ) -> AbacusUsageSnapshot { + // _getOrganizationComputePoints returns values already in credits (no division needed) + let totalCredits = Self.double(from: computePoints["totalComputePoints"]) + let creditsLeft = Self.double(from: computePoints["computePointsLeft"]) + let creditsUsed: Double? = if let total = totalCredits, let left = creditsLeft { + total - left + } else { + nil + } - // Divide all point values by 100 (centi-credits) - let creditsUsed = currMonthUsage.map { $0 / 100.0 } - let creditsTotal = currMonthAvailPoints.map { $0 / 100.0 } - let daily = last24HoursUsage.map { $0 / 100.0 } - let weekly = last7DaysUsage.map { $0 / 100.0 } + // _getBillingInfo returns the exact next billing date and plan tier + let nextBillingDate = billingInfo["nextBillingDate"] as? String + let currentTier = billingInfo["currentTier"] as? String - // Compute reset date: lastBilledAt + 1 calendar month - let resetsAt: Date? = Self.computeResetDate(from: lastBilledAt) + let resetsAt = Self.parseDate(nextBillingDate) return AbacusUsageSnapshot( creditsUsed: creditsUsed, - creditsTotal: creditsTotal, - last24HoursUsage: daily, - last7DaysUsage: weekly, + creditsTotal: totalCredits, resetsAt: resetsAt, - planName: subscriptionTier, - accountEmail: email, - accountOrganization: orgName) + planName: currentTier) } private static func double(from value: Any?) -> Double? { @@ -268,17 +274,13 @@ public enum AbacusUsageFetcher { return nil } - private static func computeResetDate(from isoString: String?) -> Date? { + private static func parseDate(_ isoString: String?) -> Date? { guard let isoString else { return nil } let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - var date = formatter.date(from: isoString) - if date == nil { - formatter.formatOptions = [.withInternetDateTime] - date = formatter.date(from: isoString) - } - guard let billedAt = date else { return nil } - return Calendar.current.date(byAdding: .month, value: 1, to: billedAt) + if let date = formatter.date(from: isoString) { return date } + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: isoString) } } From 74fa5de65b0c678a17b6339b6ff05a0027d3f089 Mon Sep 17 00:00:00 2001 From: "Christian C. Berclaz" Date: Wed, 4 Mar 2026 21:24:09 +0100 Subject: [PATCH 5/9] fix(abacus): validate session cookies and preserve API errors Skip browser cookie sets that only contain anonymous/marketing cookies by checking for session/auth cookie names before accepting a set. This prevents using invalid cookies when a valid session exists in a later browser profile. Separate the cookie import and API fetch try blocks so that network, parse, or auth errors from the API are not misreported as "Browser cookie import failed" and incorrectly replaced with noSessionCookie. --- .../Providers/Abacus/AbacusUsageFetcher.swift | 43 ++++++++++++++----- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Abacus/AbacusUsageFetcher.swift b/Sources/CodexBarCore/Providers/Abacus/AbacusUsageFetcher.swift index 38b3f1294..56b7353c7 100644 --- a/Sources/CodexBarCore/Providers/Abacus/AbacusUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Abacus/AbacusUsageFetcher.swift @@ -12,6 +12,9 @@ public enum AbacusCookieImporter { private static let cookieClient = BrowserCookieClient() private static let cookieDomains = ["abacus.ai", "apps.abacus.ai"] + /// Cookie name prefixes/substrings that indicate a session or auth cookie. + private static let sessionCookiePatterns = ["session", "sess", "auth", "token", "sid", "jwt", "id"] + public struct SessionInfo: Sendable { public let cookies: [HTTPCookie] public let sourceLabel: String @@ -33,11 +36,18 @@ public enum AbacusCookieImporter { logger: log) for source in sources where !source.records.isEmpty { let httpCookies = BrowserCookieClient.makeHTTPCookies(source.records, origin: query.origin) - if !httpCookies.isEmpty { + guard !httpCookies.isEmpty else { continue } + + // Only accept cookie sets that contain at least one session/auth cookie + guard Self.containsSessionCookie(httpCookies) else { let cookieNames = httpCookies.map(\.name).joined(separator: ", ") - log("Found \(httpCookies.count) cookies in \(source.label): \(cookieNames)") - return SessionInfo(cookies: httpCookies, sourceLabel: source.label) + log("Skipping \(source.label): no session cookie found among [\(cookieNames)]") + continue } + + let cookieNames = httpCookies.map(\.name).joined(separator: ", ") + log("Found \(httpCookies.count) cookies in \(source.label): \(cookieNames)") + return SessionInfo(cookies: httpCookies, sourceLabel: source.label) } } catch { BrowserCookieAccessGate.recordIfNeeded(error) @@ -47,6 +57,15 @@ public enum AbacusCookieImporter { throw AbacusUsageError.noSessionCookie } + + /// Returns `true` if the cookie set contains at least one cookie whose name + /// suggests it carries session or authentication state. + private static func containsSessionCookie(_ cookies: [HTTPCookie]) -> Bool { + cookies.contains { cookie in + let lower = cookie.name.lowercased() + return sessionCookiePatterns.contains { lower.contains($0) } + } + } } // MARK: - Abacus Usage Snapshot @@ -165,21 +184,23 @@ public enum AbacusUsageFetcher { } } + let session: AbacusCookieImporter.SessionInfo do { - let session = try AbacusCookieImporter.importSession(logger: log) + session = try AbacusCookieImporter.importSession(logger: log) log("Using cookies from \(session.sourceLabel)") - let snapshot = try await Self.fetchWithCookieHeader(session.cookieHeader, timeout: timeout) - CookieHeaderCache.store( - provider: .abacus, - cookieHeader: session.cookieHeader, - sourceLabel: session.sourceLabel) - return snapshot } catch { BrowserCookieAccessGate.recordIfNeeded(error) log("Browser cookie import failed: \(error.localizedDescription)") + throw AbacusUsageError.noSessionCookie } - throw AbacusUsageError.noSessionCookie + // API errors after a successful cookie import must propagate directly + let snapshot = try await Self.fetchWithCookieHeader(session.cookieHeader, timeout: timeout) + CookieHeaderCache.store( + provider: .abacus, + cookieHeader: session.cookieHeader, + sourceLabel: session.sourceLabel) + return snapshot } private static func fetchWithCookieHeader( From d1462c02f1ca941dffee3ca878a21ac964f41bba Mon Sep 17 00:00:00 2001 From: "Christian C. Berclaz" Date: Mon, 9 Mar 2026 12:59:56 +0100 Subject: [PATCH 6/9] fix(abacus): fix compilation after UsagePaceText API refactor Update three call sites that still used the removed UsagePaceText.weeklySummary(provider:window:) and weeklyPaceDetail(provider:window:now:showUsed:) signatures. MenuDescriptor and MenuCardView now use the store.weeklyPace()+ UsagePaceText.weeklySummary(pace:) pattern. StatusItemController computes weeklyPace from primary for Abacus (no secondary window). --- Sources/CodexBar/MenuCardView.swift | 2 +- Sources/CodexBar/MenuDescriptor.swift | 3 ++- Sources/CodexBar/StatusItemController+Menu.swift | 4 +++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index d687f76b1..f86d24d21 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -933,9 +933,9 @@ extension UsageMenuCardView.Model { primaryDetailText = detail } if let paceDetail = Self.weeklyPaceDetail( - provider: input.provider, window: primary, now: input.now, + pace: input.weeklyPace, showUsed: input.usageBarsShowUsed) { primaryDetailLeft = paceDetail.leftLabel diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index fadc33441..04e27541d 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -142,8 +142,9 @@ struct MenuDescriptor { entries.append(.text(detail, .secondary)) } if provider == .abacus, - let paceSummary = UsagePaceText.weeklySummary(provider: provider, window: primary) + let pace = store.weeklyPace(provider: provider, window: primary) { + let paceSummary = UsagePaceText.weeklySummary(pace: pace) entries.append(.text(paceSummary, .secondary)) } } diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 5d9173624..7741fbe94 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -1396,7 +1396,9 @@ extension StatusItemController { let sourceLabel = snapshotOverride == nil ? self.store.sourceLabel(for: target) : nil let kiloAutoMode = target == .kilo && self.settings.kiloUsageDataSource == .auto let now = Date() - let weeklyPace = snapshot?.secondary.flatMap { window in + // Abacus uses primary for monthly credits (no secondary window) + let paceWindow = target == .abacus ? snapshot?.primary : snapshot?.secondary + let weeklyPace = paceWindow.flatMap { window in self.store.weeklyPace(provider: target, window: window, now: now) } let input = UsageMenuCardView.Model.Input( From d6a3d984798409a3b9de7ee783d06b1086640b88 Mon Sep 17 00:00:00 2001 From: "Christian C. Berclaz" Date: Mon, 9 Mar 2026 13:00:03 +0100 Subject: [PATCH 7/9] fix(abacus): fix menu bar metric options and pace indicator Remove Secondary (Weekly) from the menu bar metric picker since Abacus has no secondary window; only Automatic and Primary (Credits) are valid options. Enable pace computation for Abacus in weeklyPace() so the bar tick indicator (reserve/deficit/on-pace) is rendered correctly. Abacus uses the simple UsagePace.weekly() path with the monthly window already set in RateWindow (30 days). --- Sources/CodexBar/PreferencesProvidersPane.swift | 12 +++++++++++- Sources/CodexBar/UsageStore+HistoricalPace.swift | 2 +- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index 7a040dafd..689d4d8f0 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -272,6 +272,14 @@ struct ProvidersPane: View { id: MenuBarMetricPreference.primary.rawValue, title: "Primary (API key limit)"), ] + } else if provider == .abacus { + let metadata = self.store.metadata(for: provider) + options = [ + ProviderSettingsPickerOption(id: MenuBarMetricPreference.automatic.rawValue, title: "Automatic"), + ProviderSettingsPickerOption( + id: MenuBarMetricPreference.primary.rawValue, + title: "Primary (\(metadata.sessionLabel))"), + ] } else { let metadata = self.store.metadata(for: provider) let supportsAverage = self.settings.menuBarMetricSupportsAverage(for: provider) @@ -339,7 +347,9 @@ struct ProvidersPane: View { } let now = Date() - let weeklyPace = snapshot?.secondary.flatMap { window in + // Abacus uses primary for monthly credits (no secondary window) + let paceWindow = provider == .abacus ? snapshot?.primary : snapshot?.secondary + let weeklyPace = paceWindow.flatMap { window in self.store.weeklyPace(provider: provider, window: window, now: now) } let input = UsageMenuCardView.Model.Input( diff --git a/Sources/CodexBar/UsageStore+HistoricalPace.swift b/Sources/CodexBar/UsageStore+HistoricalPace.swift index e228025f3..985a5e08a 100644 --- a/Sources/CodexBar/UsageStore+HistoricalPace.swift +++ b/Sources/CodexBar/UsageStore+HistoricalPace.swift @@ -8,7 +8,7 @@ extension UsageStore { private static let backfillMaxTimestampMismatch: TimeInterval = 5 * 60 func weeklyPace(provider: UsageProvider, window: RateWindow, now: Date = .init()) -> UsagePace? { - guard provider == .codex || provider == .claude else { return nil } + guard provider == .codex || provider == .claude || provider == .abacus else { return nil } guard window.remainingPercent > 0 else { return nil } let resolved: UsagePace? if provider == .codex, self.settings.historicalTrackingEnabled { From d7e8c9752fb8ca8defcb213c7067bd8717d44fff Mon Sep 17 00:00:00 2001 From: "Christian C. Berclaz" Date: Sun, 15 Mar 2026 20:43:39 +0100 Subject: [PATCH 8/9] docs(abacus): add provider documentation and update provider listings Add docs/abacus.md with setup, API details, and troubleshooting for the Abacus AI provider. Add Abacus AI entry to docs/providers.md strategy table and detailed section. Add Abacus AI to README provider list. --- README.md | 3 ++- docs/abacus.md | 67 +++++++++++++++++++++++++++++++++++++++++++++++ docs/providers.md | 11 +++++++- 3 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 docs/abacus.md diff --git a/README.md b/README.md index 40dd56d2a..fd68dddbb 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # CodexBar 🎚️ - May your tokens never run out. -Tiny macOS 14+ menu bar app that keeps your Codex, Claude, Cursor, Gemini, Antigravity, Droid (Factory), Copilot, z.ai, Kiro, Vertex AI, Augment, Amp, JetBrains AI, and OpenRouter limits visible (session + weekly where available) and shows when each window resets. One status item per provider (or Merge Icons mode with a provider switcher and optional Overview tab); enable what you use from Settings. No Dock icon, minimal UI, dynamic bar icons in the menu bar. +Tiny macOS 14+ menu bar app that keeps your Codex, Claude, Cursor, Gemini, Antigravity, Droid (Factory), Copilot, z.ai, Kiro, Vertex AI, Augment, Amp, JetBrains AI, OpenRouter, and Abacus AI limits visible (session + weekly where available) and shows when each window resets. One status item per provider (or Merge Icons mode with a provider switcher and optional Overview tab); enable what you use from Settings. No Dock icon, minimal UI, dynamic bar icons in the menu bar. CodexBar menu screenshot @@ -47,6 +47,7 @@ Linux support via Omarchy: community Waybar module and TUI, driven by the `codex - [Amp](docs/amp.md) — Browser cookie-based authentication with Amp Free usage tracking. - [JetBrains AI](docs/jetbrains.md) — Local XML-based quota from JetBrains IDE configuration; monthly credits tracking. - [OpenRouter](docs/openrouter.md) — API token for credit-based usage tracking across multiple AI providers. +- [Abacus AI](docs/abacus.md) — Browser cookie auth for ChatLLM/RouteLLM compute credit tracking. - Open to new providers: [provider authoring guide](docs/provider.md). ## Icon & Screenshot diff --git a/docs/abacus.md b/docs/abacus.md new file mode 100644 index 000000000..c4f9893ae --- /dev/null +++ b/docs/abacus.md @@ -0,0 +1,67 @@ +--- +summary: "Abacus AI provider: browser cookie auth for ChatLLM/RouteLLM compute credit tracking." +read_when: + - Adding or modifying the Abacus AI provider + - Debugging Abacus cookie imports or API responses + - Adjusting Abacus usage display or credit formatting +--- + +# Abacus AI Provider + +The Abacus AI provider tracks ChatLLM/RouteLLM compute credit usage via browser cookie authentication. + +## Features + +- **Monthly credit gauge**: Shows credits used vs. plan total with pace tick indicator. +- **Reserve/deficit estimate**: Projected credit usage through the billing cycle. +- **Reset timing**: Displays the next billing date from the Abacus billing API. +- **Subscription tiers**: Detects Basic and Pro plans. +- **Cookie auth**: Automatic browser cookie import (Safari, Chrome, Firefox) or manual cookie header. + +## Setup + +1. Open **Settings → Providers** +2. Enable **Abacus AI** +3. Log in to [apps.abacus.ai](https://apps.abacus.ai) in your browser +4. Cookie import happens automatically on the next refresh + +### Manual cookie mode + +1. In **Settings → Providers → Abacus AI**, set Cookie source to **Manual** +2. Open your browser DevTools on `apps.abacus.ai`, copy the `Cookie:` header from any API request +3. Paste the header into the cookie field in CodexBar + +## How it works + +Two API endpoints are fetched concurrently using browser session cookies: + +- `GET https://apps.abacus.ai/api/_getOrganizationComputePoints` — returns `totalComputePoints` and `computePointsLeft` (values are in credit units, no conversion needed). +- `POST https://apps.abacus.ai/api/_getBillingInfo` — returns `nextBillingDate` (ISO 8601) and `currentTier` (plan name). + +Cookie domains: `abacus.ai`, `apps.abacus.ai`. Session cookies are validated before use (anonymous/marketing-only cookie sets are skipped). Valid cookies are cached in Keychain and reused until the session expires. + +The billing cycle window is set to 30 days for pace calculation. + +## CLI + +```bash +codexbar usage --provider abacusai --verbose +``` + +## Troubleshooting + +### "No Abacus AI session found" + +Log in to [apps.abacus.ai](https://apps.abacus.ai) in a supported browser (Safari, Chrome, Firefox), then refresh CodexBar. + +### "Abacus AI session expired" + +Re-login to Abacus AI. The cached cookie will be cleared automatically and a fresh one imported on the next refresh. + +### "Unauthorized" + +Your session cookies may be invalid. Log out and back in to Abacus AI, or paste a fresh `Cookie:` header in manual mode. + +### Credits show 0 + +Verify that your Abacus AI account has an active subscription with compute credits allocated. diff --git a/docs/providers.md b/docs/providers.md index 99da2542d..61c4bd0c7 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -1,5 +1,5 @@ --- -summary: "Provider data sources and parsing overview (Codex, Claude, Gemini, Antigravity, Cursor, Droid/Factory, z.ai, Copilot, Kimi, Kilo, Kimi K2, Kiro, Warp, Vertex AI, Augment, Amp, Ollama, JetBrains AI, OpenRouter)." +summary: "Provider data sources and parsing overview (Codex, Claude, Gemini, Antigravity, Cursor, Droid/Factory, z.ai, Copilot, Kimi, Kilo, Kimi K2, Kiro, Warp, Vertex AI, Augment, Amp, Ollama, JetBrains AI, OpenRouter, Abacus AI)." read_when: - Adding or modifying provider fetch/parsing - Adjusting provider labels, toggles, or metadata @@ -38,6 +38,7 @@ until the session is invalid, to avoid repeated Keychain prompts. | Warp | API token (config/env) → GraphQL request limits (`api`). | | Ollama | Web settings page via browser cookies (`web`). | | OpenRouter | API token (config, overrides env) → credits API (`api`). | +| Abacus AI | Browser cookies → compute points + billing API (`web`). | ## Codex - Web dashboard (when enabled): `https://chatgpt.com/codex/settings/usage` via WebView + browser cookies. @@ -172,4 +173,12 @@ until the session is invalid, to avoid repeated Keychain prompts. - Status: `https://status.openrouter.ai` (link only, no auto-polling yet). - Details: `docs/openrouter.md`. +## Abacus AI +- Browser cookies (`abacus.ai`, `apps.abacus.ai`) via automatic import or manual header. +- `GET https://apps.abacus.ai/api/_getOrganizationComputePoints` (credits used/total). +- `POST https://apps.abacus.ai/api/_getBillingInfo` (next billing date, subscription tier). +- Shows monthly credit gauge with pace tick and reserve/deficit estimate. +- Status: none yet. +- Details: `docs/abacus.md`. + See also: `docs/provider.md` for architecture notes. From 31102c4f613288afc31943927b75766cc84f34e9 Mon Sep 17 00:00:00 2001 From: "Christian C. Berclaz" Date: Tue, 17 Mar 2026 14:23:28 +0100 Subject: [PATCH 9/9] test(abacus): add unit tests for Abacus AI provider Add AbacusProviderTests.swift with 23 tests in 3 suites covering: - AbacusDescriptorTests: provider metadata, source modes, CLI config - AbacusUsageSnapshotTests: credit conversion, formatting, edge cases - AbacusErrorTests: error description completeness Uses the Swift Testing framework (@Test + #expect) matching the convention established by recent upstream provider tests. --- Tests/CodexBarTests/AbacusProviderTests.swift | 233 ++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 Tests/CodexBarTests/AbacusProviderTests.swift diff --git a/Tests/CodexBarTests/AbacusProviderTests.swift b/Tests/CodexBarTests/AbacusProviderTests.swift new file mode 100644 index 000000000..b256ffdc2 --- /dev/null +++ b/Tests/CodexBarTests/AbacusProviderTests.swift @@ -0,0 +1,233 @@ +import Foundation +import Testing +@testable import CodexBarCore + +// MARK: - Descriptor Tests + +struct AbacusDescriptorTests { + @Test + func `descriptor has correct identity`() { + let descriptor = AbacusProviderDescriptor.descriptor + #expect(descriptor.id == .abacus) + #expect(descriptor.metadata.displayName == "Abacus AI") + #expect(descriptor.metadata.cliName == "abacusai") + } + + @Test + func `descriptor supports credits not opus`() { + let meta = AbacusProviderDescriptor.descriptor.metadata + #expect(meta.supportsCredits == true) + #expect(meta.supportsOpus == false) + } + + @Test + func `descriptor is not primary provider`() { + let meta = AbacusProviderDescriptor.descriptor.metadata + #expect(meta.isPrimaryProvider == false) + #expect(meta.defaultEnabled == false) + } + + @Test + func `descriptor supports auto and web source modes`() { + let descriptor = AbacusProviderDescriptor.descriptor + #expect(descriptor.fetchPlan.sourceModes.contains(.auto)) + #expect(descriptor.fetchPlan.sourceModes.contains(.web)) + } + + @Test + func `descriptor has no version detector`() { + let descriptor = AbacusProviderDescriptor.descriptor + #expect(descriptor.cli.versionDetector == nil) + } + + @Test + func `descriptor does not support token cost`() { + let descriptor = AbacusProviderDescriptor.descriptor + #expect(descriptor.tokenCost.supportsTokenCost == false) + } + + @Test + func `cli aliases include abacus-ai`() { + let descriptor = AbacusProviderDescriptor.descriptor + #expect(descriptor.cli.aliases.contains("abacus-ai")) + } + + @Test + func `dashboard url points to compute points page`() { + let meta = AbacusProviderDescriptor.descriptor.metadata + #expect(meta.dashboardURL?.contains("compute-points") == true) + } +} + +// MARK: - Usage Snapshot Conversion Tests + +struct AbacusUsageSnapshotTests { + @Test + func `converts full snapshot to usage snapshot`() { + let resetDate = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = AbacusUsageSnapshot( + creditsUsed: 250, + creditsTotal: 1000, + resetsAt: resetDate, + planName: "Pro") + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary != nil) + #expect(abs((usage.primary?.usedPercent ?? 0) - 25.0) < 0.01) + #expect(usage.primary?.resetDescription == "250 / 1,000 credits") + #expect(usage.primary?.resetsAt == resetDate) + #expect(usage.primary?.windowMinutes == 30 * 24 * 60) + #expect(usage.secondary == nil) + #expect(usage.tertiary == nil) + #expect(usage.identity?.providerID == .abacus) + #expect(usage.identity?.loginMethod == "Pro") + } + + @Test + func `handles zero usage`() { + let snapshot = AbacusUsageSnapshot( + creditsUsed: 0, + creditsTotal: 500, + resetsAt: nil, + planName: "Basic") + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 0.0) + #expect(usage.primary?.resetDescription == "0 / 500 credits") + } + + @Test + func `handles full usage`() { + let snapshot = AbacusUsageSnapshot( + creditsUsed: 1000, + creditsTotal: 1000, + resetsAt: nil, + planName: nil) + + let usage = snapshot.toUsageSnapshot() + #expect(abs((usage.primary?.usedPercent ?? 0) - 100.0) < 0.01) + #expect(usage.primary?.resetDescription == "1,000 / 1,000 credits") + } + + @Test + func `handles nil credits gracefully`() { + let snapshot = AbacusUsageSnapshot( + creditsUsed: nil, + creditsTotal: nil, + resetsAt: nil, + planName: nil) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 0.0) + #expect(usage.primary?.resetDescription == nil) + } + + @Test + func `handles nil total with non-nil used`() { + let snapshot = AbacusUsageSnapshot( + creditsUsed: 100, + creditsTotal: nil, + resetsAt: nil, + planName: nil) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 0.0) + } + + @Test + func `handles zero total credits`() { + let snapshot = AbacusUsageSnapshot( + creditsUsed: 0, + creditsTotal: 0, + resetsAt: nil, + planName: nil) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 0.0) + } + + @Test + func `formats large credit values with comma grouping`() { + let snapshot = AbacusUsageSnapshot( + creditsUsed: 12345, + creditsTotal: 50000, + resetsAt: nil, + planName: nil) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.resetDescription == "12,345 / 50,000 credits") + } + + @Test + func `formats fractional credit values`() { + let snapshot = AbacusUsageSnapshot( + creditsUsed: 42.5, + creditsTotal: 100, + resetsAt: nil, + planName: nil) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.resetDescription == "42.5 / 100 credits") + } + + @Test + func `window minutes represents monthly cycle`() { + let snapshot = AbacusUsageSnapshot( + creditsUsed: 0, + creditsTotal: 100, + resetsAt: nil, + planName: nil) + + let usage = snapshot.toUsageSnapshot() + // 30 days * 24 hours * 60 minutes = 43200 + #expect(usage.primary?.windowMinutes == 43200) + } + + @Test + func `identity has no email or organization`() { + let snapshot = AbacusUsageSnapshot( + creditsUsed: 0, + creditsTotal: 100, + resetsAt: nil, + planName: "Pro") + + let usage = snapshot.toUsageSnapshot() + #expect(usage.identity?.accountEmail == nil) + #expect(usage.identity?.accountOrganization == nil) + } +} + +// MARK: - Error Description Tests + +struct AbacusErrorTests { + @Test + func `noSessionCookie error mentions login`() { + let error = AbacusUsageError.noSessionCookie + #expect(error.errorDescription?.contains("log in") == true) + } + + @Test + func `sessionExpired error mentions expired`() { + let error = AbacusUsageError.sessionExpired + #expect(error.errorDescription?.contains("expired") == true) + } + + @Test + func `networkError includes message`() { + let error = AbacusUsageError.networkError("HTTP 500") + #expect(error.errorDescription?.contains("HTTP 500") == true) + } + + @Test + func `parseFailed includes message`() { + let error = AbacusUsageError.parseFailed("Invalid JSON") + #expect(error.errorDescription?.contains("Invalid JSON") == true) + } + + @Test + func `unauthorized error mentions login`() { + let error = AbacusUsageError.unauthorized + #expect(error.errorDescription?.contains("log in") == true) + } +}