diff --git a/README.md b/README.md
index 78d34d3e0..d0e0474de 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, OpenRouter, and Perplexity 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, Perplexity, 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.
@@ -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/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift
index a47ecd6cc..4d9c05127 100644
--- a/Sources/CodexBar/MenuCardView.swift
+++ b/Sources/CodexBar/MenuCardView.swift
@@ -1057,6 +1057,34 @@ 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 primary.resetsAt == nil {
+ primaryResetText = nil
+ }
+ if let pace = input.weeklyPace {
+ let paceDetail = Self.weeklyPaceDetail(
+ window: primary,
+ now: input.now,
+ pace: pace,
+ showUsed: input.usageBarsShowUsed)
+ if let paceDetail {
+ primaryDetailLeft = paceDetail.leftLabel
+ primaryDetailRight = paceDetail.rightLabel
+ primaryPacePercent = paceDetail.pacePercent
+ primaryPaceOnTop = paceDetail.paceOnTop
+ }
+ }
+ }
return Metric(
id: "primary",
title: input.metadata.sessionLabel,
@@ -1065,10 +1093,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)
}
private static func secondaryMetric(
diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift
index 5b2b4e5ce..46e0a2c6a 100644
--- a/Sources/CodexBar/MenuDescriptor.swift
+++ b/Sources/CodexBar/MenuDescriptor.swift
@@ -146,9 +146,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,
@@ -163,12 +163,18 @@ 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 pace = store.weeklyPace(provider: provider, window: primary)
+ {
+ let paceSummary = UsagePaceText.weeklySummary(pace: pace)
+ entries.append(.text(paceSummary, .secondary))
+ }
}
if let weekly = snap.secondary {
let weeklyResetOverride: String? = {
diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift
index 9e8a63640..7e39ee0e2 100644
--- a/Sources/CodexBar/PreferencesProvidersPane.swift
+++ b/Sources/CodexBar/PreferencesProvidersPane.swift
@@ -452,6 +452,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 snapshot = self.store.snapshot(for: provider)
@@ -535,12 +543,14 @@ struct ProvidersPane: View {
tokenError = nil
}
+ // Abacus uses primary for monthly credits (no secondary window)
+ let paceWindow = provider == .abacus ? snapshot?.primary : snapshot?.secondary
let weeklyPace = if let codexProjection,
let weekly = codexProjection.rateWindow(for: .weekly)
{
self.store.weeklyPace(provider: provider, window: weekly, now: now)
} else {
- snapshot?.secondary.flatMap { window in
+ paceWindow.flatMap { window in
self.store.weeklyPace(provider: provider, window: window, now: now)
}
}
diff --git a/Sources/CodexBar/Providers/Abacus/AbacusProviderImplementation.swift b/Sources/CodexBar/Providers/Abacus/AbacusProviderImplementation.swift
new file mode 100644
index 000000000..1e0cdeb9f
--- /dev/null
+++ b/Sources/CodexBar/Providers/Abacus/AbacusProviderImplementation.swift
@@ -0,0 +1,100 @@
+import AppKit
+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] {
+ [
+ ProviderSettingsFieldDescriptor(
+ id: "abacus-cookie",
+ title: "",
+ subtitle: "",
+ kind: .secure,
+ placeholder: "Cookie: \u{2026}\n\nor paste a cURL capture from the Abacus AI dashboard",
+ binding: context.stringBinding(\.abacusCookieHeader),
+ actions: [
+ ProviderSettingsActionDescriptor(
+ id: "abacus-open-dashboard",
+ title: "Open Dashboard",
+ style: .link,
+ isVisible: nil,
+ perform: {
+ if let url = URL(string: "https://apps.abacus.ai/chatllm/admin/compute-points-usage") {
+ NSWorkspace.shared.open(url)
+ }
+ }),
+ ],
+ isVisible: { context.settings.abacusCookieSource == .manual },
+ onActivate: nil),
+ ]
+ }
+}
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 c2b008592..6dd8c45c4 100644
--- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
+++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
@@ -38,6 +38,7 @@ enum ProviderImplementationRegistry {
case .openrouter: OpenRouterProviderImplementation()
case .warp: WarpProviderImplementation()
case .perplexity: PerplexityProviderImplementation()
+ 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/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift
index 468f2d566..4392686e3 100644
--- a/Sources/CodexBar/StatusItemController+Animation.swift
+++ b/Sources/CodexBar/StatusItemController+Animation.swift
@@ -559,7 +559,10 @@ extension StatusItemController {
case .percent:
pace = nil
case .pace, .both:
- let weeklyWindow = codexProjection?.rateWindow(for: .weekly) ?? snapshot?.secondary
+ let weeklyWindow = codexProjection?.rateWindow(for: .weekly)
+ ?? snapshot?.secondary
+ // Abacus has no secondary window; pace is computed on primary monthly credits
+ ?? (provider == .abacus ? snapshot?.primary : nil)
pace = weeklyWindow.flatMap { window in
self.store.weeklyPace(provider: provider, window: window, now: now)
}
diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift
index 88055ad39..55ff40b7a 100644
--- a/Sources/CodexBar/StatusItemController+Menu.swift
+++ b/Sources/CodexBar/StatusItemController+Menu.swift
@@ -1339,12 +1339,14 @@ extension StatusItemController {
let sourceLabel = snapshotOverride == nil ? self.store.sourceLabel(for: target) : nil
let kiloAutoMode = target == .kilo && self.settings.kiloUsageDataSource == .auto
+ // Abacus uses primary for monthly credits (no secondary window)
+ let paceWindow = target == .abacus ? snapshot?.primary : snapshot?.secondary
let weeklyPace = if let codexProjection,
let weekly = codexProjection.rateWindow(for: .weekly)
{
self.store.weeklyPace(provider: target, window: weekly, now: now)
} else {
- snapshot?.secondary.flatMap { window in
+ paceWindow.flatMap { window in
self.store.weeklyPace(provider: target, window: window, now: now)
}
}
diff --git a/Sources/CodexBar/UsageStore+HistoricalPace.swift b/Sources/CodexBar/UsageStore+HistoricalPace.swift
index 08b38a5b5..cc26cd0bf 100644
--- a/Sources/CodexBar/UsageStore+HistoricalPace.swift
+++ b/Sources/CodexBar/UsageStore+HistoricalPace.swift
@@ -5,7 +5,7 @@ import Foundation
extension UsageStore {
func supportsWeeklyPace(for provider: UsageProvider) -> Bool {
switch provider {
- case .codex, .claude, .opencode:
+ case .codex, .claude, .opencode, .abacus:
true
default:
false
diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift
index 354147954..d2af04b21 100644
--- a/Sources/CodexBar/UsageStore.swift
+++ b/Sources/CodexBar/UsageStore.swift
@@ -876,7 +876,7 @@ extension UsageStore {
let source = resolution?.source.rawValue ?? "none"
return "WARP_API_KEY=\(hasAny ? "present" : "missing") source=\(source)"
case .gemini, .antigravity, .opencode, .opencodego, .factory, .copilot, .vertexai, .kilo, .kiro, .kimi,
- .kimik2, .jetbrains, .perplexity:
+ .kimik2, .jetbrains, .perplexity, .abacus:
return unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented"
}
}
diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift
index d639019cf..e43073e82 100644
--- a/Sources/CodexBarCLI/TokenAccountCLI.swift
+++ b/Sources/CodexBarCLI/TokenAccountCLI.swift
@@ -186,6 +186,13 @@ struct TokenAccountCLIContext {
perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings(
cookieSource: cookieSource,
manualCookieHeader: cookieHeader))
+ case .abacus:
+ let cookieHeader = self.manualCookieHeader(provider: provider, account: account, config: config)
+ let cookieSource = self.cookieSource(provider: provider, account: account, config: config)
+ return self.makeSnapshot(
+ abacus: ProviderSettingsSnapshot.AbacusProviderSettings(
+ cookieSource: cookieSource,
+ manualCookieHeader: cookieHeader))
case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic, .openrouter, .warp:
return nil
}
@@ -207,7 +214,8 @@ struct TokenAccountCLIContext {
amp: ProviderSettingsSnapshot.AmpProviderSettings? = nil,
ollama: ProviderSettingsSnapshot.OllamaProviderSettings? = nil,
jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings? = nil,
- perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings? = nil) -> ProviderSettingsSnapshot
+ perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings? = nil,
+ abacus: ProviderSettingsSnapshot.AbacusProviderSettings? = nil) -> ProviderSettingsSnapshot
{
ProviderSettingsSnapshot.make(
codex: codex,
@@ -225,7 +233,8 @@ struct TokenAccountCLIContext {
amp: amp,
ollama: ollama,
jetbrains: jetbrains,
- perplexity: perplexity)
+ perplexity: perplexity,
+ abacus: abacus)
}
private func makeCodexSettingsSnapshot(account: ProviderTokenAccount?) ->
diff --git a/Sources/CodexBarCore/Logging/LogCategories.swift b/Sources/CodexBarCore/Logging/LogCategories.swift
index 5f6cf9217..90a7175d2 100644
--- a/Sources/CodexBarCore/Logging/LogCategories.swift
+++ b/Sources/CodexBarCore/Logging/LogCategories.swift
@@ -1,4 +1,6 @@
public enum LogCategories {
+ public static let abacusCookie = "abacus-cookie"
+ 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/AbacusCookieImporter.swift b/Sources/CodexBarCore/Providers/Abacus/AbacusCookieImporter.swift
new file mode 100644
index 000000000..c32c8bbb3
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Abacus/AbacusCookieImporter.swift
@@ -0,0 +1,134 @@
+import Foundation
+
+#if os(macOS)
+import SweetCookieKit
+
+// MARK: - Abacus Cookie Importer
+
+public enum AbacusCookieImporter {
+ private static let log = CodexBarLog.logger(LogCategories.abacusCookie)
+ private static let cookieClient = BrowserCookieClient()
+ private static let cookieDomains = ["abacus.ai", "apps.abacus.ai"]
+ private static let cookieImportOrder: BrowserCookieImportOrder =
+ ProviderDefaults.metadata[.abacus]?.browserCookieOrder ?? Browser.defaultImportOrder
+
+ /// Exact cookie names known to carry Abacus session state.
+ /// CSRF tokens are deliberately excluded — they are present in anonymous
+ /// jars and do not indicate an authenticated session.
+ private static let knownSessionCookieNames: Set = [
+ "sessionid", "session_id", "session_token",
+ "auth_token", "access_token",
+ ]
+
+ /// Substrings that indicate a session or auth cookie (applied only when
+ /// no exact-name match is found). Deliberately excludes overly broad
+ /// patterns like "id" and "token" that match analytics/CSRF cookies.
+ private static let sessionCookieSubstrings = ["session", "auth", "sid", "jwt"]
+
+ /// Cookie name prefixes that indicate a non-session cookie even when a
+ /// substring match would otherwise accept it (e.g. "csrftoken").
+ private static let excludedCookiePrefixes = ["csrf", "_ga", "_gid", "tracking", "analytics"]
+
+ public struct SessionInfo: Sendable {
+ public let cookies: [HTTPCookie]
+ public let sourceLabel: String
+
+ public init(cookies: [HTTPCookie], sourceLabel: String) {
+ self.cookies = cookies
+ self.sourceLabel = sourceLabel
+ }
+
+ public var cookieHeader: String {
+ self.cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ")
+ }
+ }
+
+ /// Returns all candidate sessions across browsers/profiles, ordered by
+ /// import priority. Callers should try each in turn so that a stale
+ /// session in the first source doesn't block a valid one further down.
+ ///
+ /// Defaults to Chrome-only per AGENTS.md guideline. Pass an empty
+ /// `preferredBrowsers` list to fall back to the full descriptor-defined
+ /// import order (Safari, Firefox, etc.) when Chrome has no cookies.
+ public static func importSessions(
+ browserDetection: BrowserDetection = BrowserDetection(),
+ preferredBrowsers: [Browser] = [.chrome],
+ logger: ((String) -> Void)? = nil) throws -> [SessionInfo]
+ {
+ var candidates: [SessionInfo] = []
+ let installedBrowsers = preferredBrowsers.isEmpty
+ ? self.cookieImportOrder.cookieImportCandidates(using: browserDetection)
+ : preferredBrowsers.cookieImportCandidates(using: browserDetection)
+
+ for browserSource in installedBrowsers {
+ do {
+ let query = BrowserCookieQuery(domains: self.cookieDomains)
+ let sources = try Self.cookieClient.codexBarRecords(
+ matching: query,
+ in: browserSource,
+ logger: { msg in self.emit(msg, logger: logger) })
+ for source in sources where !source.records.isEmpty {
+ let httpCookies = BrowserCookieClient.makeHTTPCookies(source.records, origin: query.origin)
+ guard !httpCookies.isEmpty else { continue }
+
+ guard Self.containsSessionCookie(httpCookies) else {
+ self.emit(
+ "Skipping \(source.label): no session cookie found",
+ logger: logger)
+ continue
+ }
+
+ self.emit(
+ "Found \(httpCookies.count) session cookies in \(source.label)",
+ logger: logger)
+ candidates.append(SessionInfo(cookies: httpCookies, sourceLabel: source.label))
+ }
+ } catch {
+ BrowserCookieAccessGate.recordIfNeeded(error)
+ self.emit(
+ "\(browserSource.displayName) cookie import failed: \(error.localizedDescription)",
+ logger: logger)
+ }
+ }
+
+ guard !candidates.isEmpty else {
+ throw AbacusUsageError.noSessionCookie
+ }
+ return candidates
+ }
+
+ /// Cheap check for whether any browser has an Abacus session cookie,
+ /// used by the fetch strategy's `isAvailable()`.
+ public static func hasSession(
+ browserDetection: BrowserDetection = BrowserDetection(),
+ preferredBrowsers: [Browser] = [.chrome],
+ logger: ((String) -> Void)? = nil) -> Bool
+ {
+ do {
+ return try !self.importSessions(
+ browserDetection: browserDetection,
+ preferredBrowsers: preferredBrowsers,
+ logger: logger).isEmpty
+ } catch {
+ return false
+ }
+ }
+
+ /// Returns `true` if the cookie set contains at least one cookie whose name
+ /// indicates session or authentication state. Checks exact known names
+ /// first, then falls back to conservative substring matching.
+ private static func containsSessionCookie(_ cookies: [HTTPCookie]) -> Bool {
+ cookies.contains { cookie in
+ let lower = cookie.name.lowercased()
+ if self.knownSessionCookieNames.contains(lower) { return true }
+ if self.excludedCookiePrefixes.contains(where: { lower.hasPrefix($0) }) { return false }
+ return self.sessionCookieSubstrings.contains { lower.contains($0) }
+ }
+ }
+
+ private static func emit(_ message: String, logger: ((String) -> Void)?) {
+ logger?("[abacus-cookie] \(message)")
+ self.log.debug(message)
+ }
+}
+#endif
diff --git a/Sources/CodexBarCore/Providers/Abacus/AbacusProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Abacus/AbacusProviderDescriptor.swift
new file mode 100644
index 000000000..1cc11ae83
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Abacus/AbacusProviderDescriptor.swift
@@ -0,0 +1,95 @@
+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: nil,
+ 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))
+ }
+}
+
+// MARK: - Fetch Strategy
+
+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 }
+ if context.settings?.abacus?.cookieSource == .manual {
+ return CookieHeaderNormalizer.normalize(context.settings?.abacus?.manualCookieHeader) != nil
+ }
+ if CookieHeaderCache.load(provider: .abacus) != nil { return true }
+ #if os(macOS)
+ // Try Chrome first, then any installed browser as fallback.
+ if AbacusCookieImporter.hasSession(browserDetection: context.browserDetection) {
+ return true
+ }
+ return AbacusCookieImporter.hasSession(
+ browserDetection: context.browserDetection,
+ preferredBrowsers: [])
+ #else
+ return false
+ #endif
+ }
+
+ 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/AbacusUsageError.swift b/Sources/CodexBarCore/Providers/Abacus/AbacusUsageError.swift
new file mode 100644
index 000000000..8d27e2e16
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Abacus/AbacusUsageError.swift
@@ -0,0 +1,47 @@
+import Foundation
+
+// MARK: - Abacus Usage Error
+
+public enum AbacusUsageError: LocalizedError, Sendable, Equatable {
+ case noSessionCookie
+ case sessionExpired
+ case networkError(String)
+ case parseFailed(String)
+ case unauthorized
+
+ /// Whether this error indicates an authentication/session problem that
+ /// should trigger cache eviction and candidate fallthrough.
+ /// Parse failures are deterministic — retrying with another session
+ /// produces the same result, so they are NOT recoverable.
+ public var isRecoverable: Bool {
+ switch self {
+ case .unauthorized, .sessionExpired: true
+ default: false
+ }
+ }
+
+ public var isAuthRelated: Bool {
+ self.isRecoverable
+ }
+
+ public var errorDescription: String? {
+ switch self {
+ case .noSessionCookie:
+ "No Abacus AI session found. Please log in to apps.abacus.ai in your browser."
+ 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."
+ }
+ }
+}
+
+#if !os(macOS)
+extension AbacusUsageError {
+ public static let notSupported = AbacusUsageError.networkError("Abacus AI is only supported on macOS.")
+}
+#endif
diff --git a/Sources/CodexBarCore/Providers/Abacus/AbacusUsageFetcher.swift b/Sources/CodexBarCore/Providers/Abacus/AbacusUsageFetcher.swift
new file mode 100644
index 000000000..ebe34f401
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Abacus/AbacusUsageFetcher.swift
@@ -0,0 +1,270 @@
+import Foundation
+
+#if canImport(FoundationNetworking)
+import FoundationNetworking
+#endif
+
+#if os(macOS)
+import SweetCookieKit
+
+// MARK: - Abacus Usage Fetcher
+
+public enum AbacusUsageFetcher {
+ 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,
+ 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 — clear on recoverable errors and fall through
+ 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.isRecoverable {
+ CookieHeaderCache.clear(provider: .abacus)
+ self.emit(
+ "Cached cookie failed (\(error.localizedDescription)); cleared, 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 all Chrome sessions fail
+ // with recoverable errors (expired/unauthorized cookies).
+ var lastError: AbacusUsageError = .noSessionCookie
+ if let snapshot = try await self.tryFetchFromBrowsers(
+ 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(
+ 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 all failed
+ /// with recoverable errors. Non-recoverable errors are rethrown directly.
+ private static func tryFetchFromBrowsers(
+ preferredBrowsers: [Browser],
+ label: String,
+ timeout: TimeInterval,
+ logger: ((String) -> Void)?,
+ lastError: inout AbacusUsageError) async throws -> AbacusUsageSnapshot?
+ {
+ let sessions: [AbacusCookieImporter.SessionInfo]
+ do {
+ sessions = try AbacusCookieImporter.importSessions(
+ preferredBrowsers: preferredBrowsers, logger: logger)
+ } catch {
+ BrowserCookieAccessGate.recordIfNeeded(error)
+ self.emit("\(label) cookie import failed: \(error.localizedDescription)", logger: logger)
+ return nil
+ }
+
+ for session in sessions {
+ self.emit("Trying cookies from \(session.sourceLabel)", logger: logger)
+ do {
+ let snapshot = try await self.fetchWithCookieHeader(
+ session.cookieHeader, timeout: timeout, logger: logger)
+ CookieHeaderCache.store(
+ provider: .abacus,
+ cookieHeader: session.cookieHeader,
+ sourceLabel: session.sourceLabel)
+ return snapshot
+ } catch let error as AbacusUsageError where error.isRecoverable {
+ self.emit(
+ "\(session.sourceLabel): \(error.localizedDescription), trying next source",
+ logger: logger)
+ lastError = error
+ continue
+ }
+ }
+ return nil
+ }
+
+ // MARK: - API Requests
+
+ private static func fetchWithCookieHeader(
+ _ cookieHeader: String,
+ timeout: TimeInterval,
+ logger: ((String) -> Void)? = nil) async throws -> AbacusUsageSnapshot
+ {
+ // 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)
+ async let computePoints = self.fetchJSON(
+ url: self.computePointsURL, method: "GET", cookieHeader: cookieHeader, timeout: timeout)
+ async let billingInfo = self.fetchJSON(
+ url: self.billingInfoURL, method: "POST", cookieHeader: cookieHeader, timeout: billingBudget)
+
+ let cpResult = try await computePoints
+ let biResult: [String: Any]
+ do {
+ biResult = try await billingInfo
+ } catch let error as AbacusUsageError where error.isAuthRelated {
+ throw error
+ } catch {
+ self.emit(
+ "Billing info fetch failed: \(error.localizedDescription); credits shown without plan/reset",
+ logger: logger)
+ biResult = [:]
+ }
+
+ return try 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")
+ 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 from \(url.lastPathComponent)")
+ }
+
+ if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 {
+ throw AbacusUsageError.unauthorized
+ }
+
+ guard httpResponse.statusCode == 200 else {
+ let body = String(data: data.prefix(200), encoding: .utf8) ?? ""
+ throw AbacusUsageError.networkError("HTTP \(httpResponse.statusCode): \(body)")
+ }
+
+ let parsed: Any
+ do {
+ parsed = try JSONSerialization.jsonObject(with: data)
+ } catch {
+ let preview = String(data: data.prefix(200), encoding: .utf8) ?? ""
+ throw AbacusUsageError.parseFailed(
+ "\(url.lastPathComponent): \(error.localizedDescription) — preview: \(preview)")
+ }
+
+ guard let root = parsed as? [String: Any] else {
+ throw AbacusUsageError.parseFailed("\(url.lastPathComponent): top-level JSON is not a dictionary")
+ }
+
+ guard root["success"] as? Bool == true,
+ let result = root["result"] as? [String: Any]
+ else {
+ let errorMsg = (root["error"] as? String ?? "Unknown error").lowercased()
+ if errorMsg.contains("expired") || errorMsg.contains("session")
+ || errorMsg.contains("login") || errorMsg.contains("authenticate")
+ || errorMsg.contains("unauthorized") || errorMsg.contains("unauthenticated")
+ || errorMsg.contains("forbidden")
+ {
+ throw AbacusUsageError.unauthorized
+ }
+ throw AbacusUsageError.parseFailed("\(url.lastPathComponent): \(errorMsg)")
+ }
+
+ return result
+ }
+
+ // MARK: - Parsing
+
+ private static func parseResults(
+ computePoints: [String: Any], billingInfo: [String: Any]) throws -> AbacusUsageSnapshot
+ {
+ let totalCredits = self.double(from: computePoints["totalComputePoints"])
+ let creditsLeft = self.double(from: computePoints["computePointsLeft"])
+
+ guard let totalCredits, let creditsLeft else {
+ let keys = computePoints.keys.sorted().joined(separator: ", ")
+ throw AbacusUsageError.parseFailed(
+ "Missing credit fields in compute points response. Keys: [\(keys)]")
+ }
+
+ let creditsUsed = totalCredits - creditsLeft
+
+ let nextBillingDate = billingInfo["nextBillingDate"] as? String
+ let currentTier = billingInfo["currentTier"] as? String
+ let resetsAt = self.parseDate(nextBillingDate)
+
+ return AbacusUsageSnapshot(
+ creditsUsed: creditsUsed,
+ creditsTotal: totalCredits,
+ resetsAt: resetsAt,
+ planName: currentTier)
+ }
+
+ private static func double(from value: Any?) -> Double? {
+ if let d = value as? Double { return d }
+ if let i = value as? Int { return Double(i) }
+ if let n = value as? NSNumber { return n.doubleValue }
+ return nil
+ }
+
+ private static func parseDate(_ isoString: String?) -> Date? {
+ guard let isoString else { return nil }
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ if let date = formatter.date(from: isoString) { return date }
+ formatter.formatOptions = [.withInternetDateTime]
+ return formatter.date(from: isoString)
+ }
+
+ // MARK: - Logging
+
+ private static func emit(_ message: String, logger: ((String) -> Void)?) {
+ logger?("[abacus] \(message)")
+ self.log.debug(message)
+ }
+}
+
+#else
+
+// MARK: - Abacus (Unsupported)
+
+public enum AbacusUsageFetcher {
+ public static func fetchUsage(
+ cookieHeaderOverride _: String? = nil,
+ timeout _: TimeInterval = 15.0,
+ logger _: ((String) -> Void)? = nil) async throws -> AbacusUsageSnapshot
+ {
+ throw AbacusUsageError.notSupported
+ }
+}
+
+#endif
diff --git a/Sources/CodexBarCore/Providers/Abacus/AbacusUsageSnapshot.swift b/Sources/CodexBarCore/Providers/Abacus/AbacusUsageSnapshot.swift
new file mode 100644
index 000000000..018684487
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Abacus/AbacusUsageSnapshot.swift
@@ -0,0 +1,77 @@
+import Foundation
+
+// MARK: - Abacus Usage Snapshot
+
+public struct AbacusUsageSnapshot: Sendable {
+ public let creditsUsed: Double?
+ public let creditsTotal: Double?
+ public let resetsAt: Date?
+ public let planName: String?
+
+ public init(
+ creditsUsed: Double? = nil,
+ creditsTotal: Double? = nil,
+ resetsAt: Date? = nil,
+ planName: String? = nil)
+ {
+ self.creditsUsed = creditsUsed
+ self.creditsTotal = creditsTotal
+ self.resetsAt = resetsAt
+ self.planName = planName
+ }
+
+ 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
+ }
+
+ // Derive window from actual billing cycle when possible.
+ // Assume the cycle started one calendar month before resetsAt.
+ let windowMinutes: Int = if let resetDate = self.resetsAt,
+ let cycleStart = Calendar.current.date(byAdding: .month, value: -1, to: resetDate)
+ {
+ max(1, Int(resetDate.timeIntervalSince(cycleStart) / 60))
+ } else {
+ 30 * 24 * 60
+ }
+
+ let primary = RateWindow(
+ usedPercent: percentUsed,
+ windowMinutes: windowMinutes,
+ resetsAt: self.resetsAt,
+ resetDescription: resetDesc)
+
+ let identity = ProviderIdentitySnapshot(
+ providerID: .abacus,
+ accountEmail: nil,
+ accountOrganization: nil,
+ loginMethod: self.planName)
+
+ return UsageSnapshot(
+ primary: primary,
+ secondary: nil,
+ tertiary: nil,
+ providerCost: nil,
+ updatedAt: Date(),
+ identity: identity)
+ }
+
+ // MARK: - Formatting
+
+ /// Thread-safe credit formatting — allocates per call to avoid shared mutable state.
+ private static func formatCredits(_ value: Double) -> String {
+ let formatter = NumberFormatter()
+ formatter.numberStyle = .decimal
+ formatter.locale = Locale(identifier: "en_US")
+ formatter.maximumFractionDigits = value >= 1000 ? 0 : 1
+ return formatter.string(from: NSNumber(value: value)) ?? String(format: "%.0f", value)
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
index c55d0a194..6fb994efc 100644
--- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
+++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
@@ -78,6 +78,7 @@ public enum ProviderDescriptorRegistry {
.openrouter: OpenRouterProviderDescriptor.descriptor,
.warp: WarpProviderDescriptor.descriptor,
.perplexity: PerplexityProviderDescriptor.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 d0e6be940..63aa6221c 100644
--- a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift
+++ b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift
@@ -20,7 +20,8 @@ public struct ProviderSettingsSnapshot: Sendable {
amp: AmpProviderSettings? = nil,
ollama: OllamaProviderSettings? = nil,
jetbrains: JetBrainsProviderSettings? = nil,
- perplexity: PerplexityProviderSettings? = nil) -> ProviderSettingsSnapshot
+ perplexity: PerplexityProviderSettings? = nil,
+ abacus: AbacusProviderSettings? = nil) -> ProviderSettingsSnapshot
{
ProviderSettingsSnapshot(
debugMenuEnabled: debugMenuEnabled,
@@ -41,7 +42,8 @@ public struct ProviderSettingsSnapshot: Sendable {
amp: amp,
ollama: ollama,
jetbrains: jetbrains,
- perplexity: perplexity)
+ perplexity: perplexity,
+ abacus: abacus)
}
public struct CodexProviderSettings: Sendable {
@@ -232,6 +234,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?
@@ -251,6 +263,7 @@ public struct ProviderSettingsSnapshot: Sendable {
public let ollama: OllamaProviderSettings?
public let jetbrains: JetBrainsProviderSettings?
public let perplexity: PerplexityProviderSettings?
+ public let abacus: AbacusProviderSettings?
public var jetbrainsIDEBasePath: String? {
self.jetbrains?.ideBasePath
@@ -275,7 +288,8 @@ public struct ProviderSettingsSnapshot: Sendable {
amp: AmpProviderSettings?,
ollama: OllamaProviderSettings?,
jetbrains: JetBrainsProviderSettings? = nil,
- perplexity: PerplexityProviderSettings? = nil)
+ perplexity: PerplexityProviderSettings? = nil,
+ abacus: AbacusProviderSettings? = nil)
{
self.debugMenuEnabled = debugMenuEnabled
self.debugKeepCLISessionsAlive = debugKeepCLISessionsAlive
@@ -296,6 +310,7 @@ public struct ProviderSettingsSnapshot: Sendable {
self.ollama = ollama
self.jetbrains = jetbrains
self.perplexity = perplexity
+ self.abacus = abacus
}
}
@@ -317,6 +332,7 @@ public enum ProviderSettingsSnapshotContribution: Sendable {
case ollama(ProviderSettingsSnapshot.OllamaProviderSettings)
case jetbrains(ProviderSettingsSnapshot.JetBrainsProviderSettings)
case perplexity(ProviderSettingsSnapshot.PerplexityProviderSettings)
+ case abacus(ProviderSettingsSnapshot.AbacusProviderSettings)
}
public struct ProviderSettingsSnapshotBuilder: Sendable {
@@ -339,6 +355,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable {
public var ollama: ProviderSettingsSnapshot.OllamaProviderSettings?
public var jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings?
public var perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings?
+ public var abacus: ProviderSettingsSnapshot.AbacusProviderSettings?
public init(debugMenuEnabled: Bool = false, debugKeepCLISessionsAlive: Bool = false) {
self.debugMenuEnabled = debugMenuEnabled
@@ -364,6 +381,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable {
case let .ollama(value): self.ollama = value
case let .jetbrains(value): self.jetbrains = value
case let .perplexity(value): self.perplexity = value
+ case let .abacus(value): self.abacus = value
}
}
@@ -387,6 +405,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable {
amp: self.amp,
ollama: self.ollama,
jetbrains: self.jetbrains,
- perplexity: self.perplexity)
+ perplexity: self.perplexity,
+ abacus: self.abacus)
}
}
diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift
index eb9f08e92..83dade054 100644
--- a/Sources/CodexBarCore/Providers/Providers.swift
+++ b/Sources/CodexBarCore/Providers/Providers.swift
@@ -28,6 +28,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable {
case warp
case openrouter
case perplexity
+ case abacus
}
// swiftformat:enable sortDeclarations
@@ -58,6 +59,7 @@ public enum IconStyle: Sendable, CaseIterable {
case warp
case openrouter
case perplexity
+ case abacus
case combined
}
diff --git a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift
index 36308cdf0..a13d28a80 100644
--- a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift
+++ b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift
@@ -65,5 +65,12 @@ extension TokenAccountSupportCatalog {
injection: .cookieHeader,
requiresManualCookieSource: true,
cookieName: nil),
+ .abacus: TokenAccountSupport(
+ title: "Session tokens",
+ subtitle: "Store multiple Abacus AI Cookie headers.",
+ placeholder: "Cookie: …",
+ injection: .cookieHeader,
+ requiresManualCookieSource: true,
+ cookieName: nil),
]
}
diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
index 91de4e1ca..17ddf1dba 100644
--- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
+++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
@@ -235,7 +235,7 @@ enum CostUsageScanner {
return self.loadClaudeDaily(provider: .vertexai, range: range, now: now, options: filtered)
case .zai, .gemini, .antigravity, .cursor, .opencode, .opencodego, .alibaba, .factory, .copilot,
.minimax, .kilo, .kiro, .kimi,
- .kimik2, .augment, .jetbrains, .amp, .ollama, .synthetic, .openrouter, .warp, .perplexity:
+ .kimik2, .augment, .jetbrains, .amp, .ollama, .synthetic, .openrouter, .warp, .perplexity, .abacus:
return emptyReport
}
}
diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
index 7e4a7ddb0..73055305f 100644
--- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
+++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
@@ -76,6 +76,7 @@ enum ProviderChoice: String, AppEnum {
case .openrouter: return nil // OpenRouter not yet supported in widgets
case .warp: return nil // Warp not yet supported in widgets
case .perplexity: return nil // Perplexity 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 4ccc2b57e..4e03801b3 100644
--- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift
+++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift
@@ -282,6 +282,7 @@ private struct ProviderSwitchChip: View {
case .openrouter: "OpenRouter"
case .warp: "Warp"
case .perplexity: "Pplx"
+ case .abacus: "Abacus"
}
}
}
@@ -641,6 +642,8 @@ enum WidgetColors {
Color(red: 147 / 255, green: 139 / 255, blue: 180 / 255)
case .perplexity:
Color(red: 32 / 255, green: 178 / 255, blue: 170 / 255) // Perplexity teal
+ case .abacus:
+ Color(red: 56 / 255, green: 189 / 255, blue: 248 / 255)
}
}
}
diff --git a/Tests/CodexBarTests/AbacusProviderTests.swift b/Tests/CodexBarTests/AbacusProviderTests.swift
new file mode 100644
index 000000000..0bdba3934
--- /dev/null
+++ b/Tests/CodexBarTests/AbacusProviderTests.swift
@@ -0,0 +1,275 @@
+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`() throws {
+ 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)
+ // Window derived from actual billing cycle (1 calendar month before resetDate)
+ let cycleStart = try #require(Calendar.current.date(byAdding: .month, value: -1, to: resetDate))
+ let expectedMinutes = Int(resetDate.timeIntervalSince(cycleStart) / 60)
+ #expect(usage.primary?.windowMinutes == expectedMinutes)
+ #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)
+ }
+}
+
+// MARK: - Error Classification Tests
+
+struct AbacusErrorClassificationTests {
+ @Test
+ func `unauthorized is recoverable and auth related`() {
+ let error = AbacusUsageError.unauthorized
+ #expect(error.isRecoverable == true)
+ #expect(error.isAuthRelated == true)
+ }
+
+ @Test
+ func `sessionExpired is recoverable and auth related`() {
+ let error = AbacusUsageError.sessionExpired
+ #expect(error.isRecoverable == true)
+ #expect(error.isAuthRelated == true)
+ }
+
+ @Test
+ func `parseFailed is not recoverable`() {
+ let error = AbacusUsageError.parseFailed("bad json")
+ #expect(error.isRecoverable == false)
+ #expect(error.isAuthRelated == false)
+ }
+
+ @Test
+ func `networkError is not recoverable`() {
+ let error = AbacusUsageError.networkError("timeout")
+ #expect(error.isRecoverable == false)
+ #expect(error.isAuthRelated == false)
+ }
+
+ @Test
+ func `noSessionCookie is not recoverable`() {
+ let error = AbacusUsageError.noSessionCookie
+ #expect(error.isRecoverable == false)
+ #expect(error.isAuthRelated == false)
+ }
+}
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 e6f0516bc..a82898e78 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, OpenCode, Alibaba Coding Plan, 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, OpenCode, Alibaba Coding Plan, 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
@@ -39,6 +39,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 (optional, off by default): `https://chatgpt.com/codex/settings/usage` via WebView + browser cookies.
@@ -183,4 +184,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.