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. 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/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.