diff --git a/README.md b/README.md
index 3f685d6fc..4f6f7a40b 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 9da4f9e04..f9c9e6d5d 100644
--- a/Sources/CodexBar/MenuCardView.swift
+++ b/Sources/CodexBar/MenuCardView.swift
@@ -964,6 +964,29 @@ extension UsageMenuCardView.Model {
if input.provider == .warp || input.provider == .kilo, primary.resetsAt == nil {
primaryResetText = nil
}
+ // Abacus: show credits as detail, compute pace on the primary monthly window
+ var primaryDetailLeft: String?
+ var primaryDetailRight: String?
+ var primaryPacePercent: Double?
+ var primaryPaceOnTop = true
+ if input.provider == .abacus {
+ if let detail = primary.resetDescription,
+ !detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ {
+ primaryDetailText = detail
+ }
+ if let paceDetail = Self.weeklyPaceDetail(
+ window: primary,
+ now: input.now,
+ pace: input.weeklyPace,
+ showUsed: input.usageBarsShowUsed)
+ {
+ primaryDetailLeft = paceDetail.leftLabel
+ primaryDetailRight = paceDetail.rightLabel
+ primaryPacePercent = paceDetail.pacePercent
+ primaryPaceOnTop = paceDetail.paceOnTop
+ }
+ }
metrics.append(Metric(
id: "primary",
title: input.metadata.sessionLabel,
@@ -972,10 +995,10 @@ extension UsageMenuCardView.Model {
percentStyle: percentStyle,
resetText: primaryResetText,
detailText: primaryDetailText,
- detailLeftText: nil,
- detailRightText: nil,
- pacePercent: nil,
- paceOnTop: true))
+ detailLeftText: primaryDetailLeft,
+ detailRightText: primaryDetailRight,
+ pacePercent: primaryPacePercent,
+ paceOnTop: primaryPaceOnTop))
}
if let weekly = snapshot.secondary {
let paceDetail = Self.weeklyPaceDetail(
diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift
index a9ae51bf8..ba39bb5d5 100644
--- a/Sources/CodexBar/MenuDescriptor.swift
+++ b/Sources/CodexBar/MenuDescriptor.swift
@@ -118,9 +118,9 @@ struct MenuDescriptor {
if let snap = store.snapshot(for: provider) {
let resetStyle = settings.resetTimeDisplayStyle
if let primary = snap.primary {
- let primaryWindow = if provider == .warp || provider == .kilo {
- // Warp/Kilo primary uses resetDescription for non-reset detail (e.g., "Unlimited", "X/Y credits").
- // Avoid rendering it as a "Resets ..." line.
+ let primaryWindow = if provider == .warp || provider == .kilo || provider == .abacus {
+ // Warp/Kilo/Abacus primary uses resetDescription for non-reset detail
+ // (e.g., "Unlimited", "X/Y credits"). Avoid rendering it as a "Resets ..." line.
RateWindow(
usedPercent: primary.usedPercent,
windowMinutes: primary.windowMinutes,
@@ -135,12 +135,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 f3d5bc112..8fbc6f665 100644
--- a/Sources/CodexBar/PreferencesProvidersPane.swift
+++ b/Sources/CodexBar/PreferencesProvidersPane.swift
@@ -272,6 +272,14 @@ struct ProvidersPane: View {
id: MenuBarMetricPreference.primary.rawValue,
title: "Primary (API key limit)"),
]
+ } else if provider == .abacus {
+ let metadata = self.store.metadata(for: provider)
+ options = [
+ ProviderSettingsPickerOption(id: MenuBarMetricPreference.automatic.rawValue, title: "Automatic"),
+ ProviderSettingsPickerOption(
+ id: MenuBarMetricPreference.primary.rawValue,
+ title: "Primary (\(metadata.sessionLabel))"),
+ ]
} else {
let metadata = self.store.metadata(for: provider)
let snapshot = self.store.snapshot(for: provider)
@@ -351,7 +359,9 @@ struct ProvidersPane: View {
}
let now = Date()
- let weeklyPace = snapshot?.secondary.flatMap { window in
+ // Abacus uses primary for monthly credits (no secondary window)
+ let paceWindow = provider == .abacus ? snapshot?.primary : snapshot?.secondary
+ let weeklyPace = paceWindow.flatMap { window in
self.store.weeklyPace(provider: provider, window: window, now: now)
}
let input = UsageMenuCardView.Model.Input(
diff --git a/Sources/CodexBar/Providers/Abacus/AbacusProviderImplementation.swift b/Sources/CodexBar/Providers/Abacus/AbacusProviderImplementation.swift
new file mode 100644
index 000000000..2c3809118
--- /dev/null
+++ b/Sources/CodexBar/Providers/Abacus/AbacusProviderImplementation.swift
@@ -0,0 +1,77 @@
+import CodexBarCore
+import CodexBarMacroSupport
+import Foundation
+import SwiftUI
+
+@ProviderImplementationRegistration
+struct AbacusProviderImplementation: ProviderImplementation {
+ let id: UsageProvider = .abacus
+
+ @MainActor
+ func observeSettings(_ settings: SettingsStore) {
+ _ = settings.abacusCookieSource
+ _ = settings.abacusCookieHeader
+ }
+
+ @MainActor
+ func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? {
+ .abacus(context.settings.abacusSettingsSnapshot(tokenOverride: context.tokenOverride))
+ }
+
+ @MainActor
+ func tokenAccountsVisibility(context: ProviderSettingsContext, support: TokenAccountSupport) -> Bool {
+ guard support.requiresManualCookieSource else { return true }
+ if !context.settings.tokenAccounts(for: context.provider).isEmpty { return true }
+ return context.settings.abacusCookieSource == .manual
+ }
+
+ @MainActor
+ func applyTokenAccountCookieSource(settings: SettingsStore) {
+ if settings.abacusCookieSource != .manual {
+ settings.abacusCookieSource = .manual
+ }
+ }
+
+ @MainActor
+ func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] {
+ let cookieBinding = Binding(
+ get: { context.settings.abacusCookieSource.rawValue },
+ set: { raw in
+ context.settings.abacusCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto
+ })
+ let cookieOptions = ProviderCookieSourceUI.options(
+ allowsOff: false,
+ keychainDisabled: context.settings.debugDisableKeychainAccess)
+
+ let cookieSubtitle: () -> String? = {
+ ProviderCookieSourceUI.subtitle(
+ source: context.settings.abacusCookieSource,
+ keychainDisabled: context.settings.debugDisableKeychainAccess,
+ auto: "Automatic imports browser cookies.",
+ manual: "Paste a Cookie header or cURL capture from the Abacus AI dashboard.",
+ off: "Abacus AI cookies are disabled.")
+ }
+
+ return [
+ ProviderSettingsPickerDescriptor(
+ id: "abacus-cookie-source",
+ title: "Cookie source",
+ subtitle: "Automatic imports browser cookies.",
+ dynamicSubtitle: cookieSubtitle,
+ binding: cookieBinding,
+ options: cookieOptions,
+ isVisible: nil,
+ onChange: nil,
+ trailingText: {
+ guard let entry = CookieHeaderCache.load(provider: .abacus) else { return nil }
+ let when = entry.storedAt.relativeDescription()
+ return "Cached: \(entry.sourceLabel) โข \(when)"
+ }),
+ ]
+ }
+
+ @MainActor
+ func settingsFields(context _: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] {
+ []
+ }
+}
diff --git a/Sources/CodexBar/Providers/Abacus/AbacusSettingsStore.swift b/Sources/CodexBar/Providers/Abacus/AbacusSettingsStore.swift
new file mode 100644
index 000000000..d5e5c3e30
--- /dev/null
+++ b/Sources/CodexBar/Providers/Abacus/AbacusSettingsStore.swift
@@ -0,0 +1,61 @@
+import CodexBarCore
+import Foundation
+
+extension SettingsStore {
+ var abacusCookieHeader: String {
+ get { self.configSnapshot.providerConfig(for: .abacus)?.sanitizedCookieHeader ?? "" }
+ set {
+ self.updateProviderConfig(provider: .abacus) { entry in
+ entry.cookieHeader = self.normalizedConfigValue(newValue)
+ }
+ self.logSecretUpdate(provider: .abacus, field: "cookieHeader", value: newValue)
+ }
+ }
+
+ var abacusCookieSource: ProviderCookieSource {
+ get { self.resolvedCookieSource(provider: .abacus, fallback: .auto) }
+ set {
+ self.updateProviderConfig(provider: .abacus) { entry in
+ entry.cookieSource = newValue
+ }
+ self.logProviderModeChange(provider: .abacus, field: "cookieSource", value: newValue.rawValue)
+ }
+ }
+}
+
+extension SettingsStore {
+ func abacusSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot
+ .AbacusProviderSettings {
+ ProviderSettingsSnapshot.AbacusProviderSettings(
+ cookieSource: self.abacusSnapshotCookieSource(tokenOverride: tokenOverride),
+ manualCookieHeader: self.abacusSnapshotCookieHeader(tokenOverride: tokenOverride))
+ }
+
+ private func abacusSnapshotCookieHeader(tokenOverride: TokenAccountOverride?) -> String {
+ let fallback = self.abacusCookieHeader
+ guard let support = TokenAccountSupportCatalog.support(for: .abacus),
+ case .cookieHeader = support.injection
+ else {
+ return fallback
+ }
+ guard let account = ProviderTokenAccountSelection.selectedAccount(
+ provider: .abacus,
+ settings: self,
+ override: tokenOverride)
+ else {
+ return fallback
+ }
+ return TokenAccountSupportCatalog.normalizedCookieHeader(account.token, support: support)
+ }
+
+ private func abacusSnapshotCookieSource(tokenOverride _: TokenAccountOverride?) -> ProviderCookieSource {
+ let fallback = self.abacusCookieSource
+ guard let support = TokenAccountSupportCatalog.support(for: .abacus),
+ support.requiresManualCookieSource
+ else {
+ return fallback
+ }
+ if self.tokenAccounts(for: .abacus).isEmpty { return fallback }
+ return .manual
+ }
+}
diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
index 6fb94b479..67dab1184 100644
--- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
+++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
@@ -2,75 +2,78 @@ import CodexBarCore
import Foundation
enum ProviderImplementationRegistry {
- private final class Store: @unchecked Sendable {
- var ordered: [any ProviderImplementation] = []
- var byID: [UsageProvider: any ProviderImplementation] = [:]
- }
+ private final class Store: @unchecked Sendable {
+ var ordered: [any ProviderImplementation] = []
+ var byID: [UsageProvider: any ProviderImplementation] = [:]
+ }
- private static let lock = NSLock()
- private static let store = Store()
+ private static let lock = NSLock()
+ private static let store = Store()
- // swiftlint:disable:next cyclomatic_complexity
- private static func makeImplementation(for provider: UsageProvider) -> (any ProviderImplementation) {
- switch provider {
- case .codex: CodexProviderImplementation()
- case .claude: ClaudeProviderImplementation()
- case .cursor: CursorProviderImplementation()
- case .opencode: OpenCodeProviderImplementation()
- case .alibaba: AlibabaCodingPlanProviderImplementation()
- case .factory: FactoryProviderImplementation()
- case .gemini: GeminiProviderImplementation()
- case .antigravity: AntigravityProviderImplementation()
- case .copilot: CopilotProviderImplementation()
- case .zai: ZaiProviderImplementation()
- case .minimax: MiniMaxProviderImplementation()
- case .kimi: KimiProviderImplementation()
- case .kilo: KiloProviderImplementation()
- case .kiro: KiroProviderImplementation()
- case .vertexai: VertexAIProviderImplementation()
- case .augment: AugmentProviderImplementation()
- case .jetbrains: JetBrainsProviderImplementation()
- case .kimik2: KimiK2ProviderImplementation()
- case .amp: AmpProviderImplementation()
- case .ollama: OllamaProviderImplementation()
- case .synthetic: SyntheticProviderImplementation()
- case .openrouter: OpenRouterProviderImplementation()
- case .warp: WarpProviderImplementation()
- case .perplexity: PerplexityProviderImplementation()
- }
+ // swiftlint:disable:next cyclomatic_complexity
+ private static func makeImplementation(for provider: UsageProvider) -> (
+ any ProviderImplementation
+ ) {
+ switch provider {
+ case .codex: CodexProviderImplementation()
+ case .claude: ClaudeProviderImplementation()
+ case .cursor: CursorProviderImplementation()
+ case .opencode: OpenCodeProviderImplementation()
+ case .alibaba: AlibabaCodingPlanProviderImplementation()
+ case .factory: FactoryProviderImplementation()
+ case .gemini: GeminiProviderImplementation()
+ case .antigravity: AntigravityProviderImplementation()
+ case .copilot: CopilotProviderImplementation()
+ case .zai: ZaiProviderImplementation()
+ case .minimax: MiniMaxProviderImplementation()
+ case .kimi: KimiProviderImplementation()
+ case .kilo: KiloProviderImplementation()
+ case .kiro: KiroProviderImplementation()
+ case .vertexai: VertexAIProviderImplementation()
+ case .augment: AugmentProviderImplementation()
+ case .jetbrains: JetBrainsProviderImplementation()
+ case .kimik2: KimiK2ProviderImplementation()
+ case .amp: AmpProviderImplementation()
+ case .ollama: OllamaProviderImplementation()
+ case .synthetic: SyntheticProviderImplementation()
+ case .openrouter: OpenRouterProviderImplementation()
+ case .warp: WarpProviderImplementation()
+ case .perplexity: PerplexityProviderImplementation()
+ case .abacus: AbacusProviderImplementation()
}
+ }
- private static let bootstrap: Void = {
- for provider in UsageProvider.allCases {
- _ = ProviderImplementationRegistry.register(makeImplementation(for: provider))
- }
- }()
-
- private static func ensureBootstrapped() {
- _ = self.bootstrap
+ private static let bootstrap: Void = {
+ for provider in UsageProvider.allCases {
+ _ = ProviderImplementationRegistry.register(makeImplementation(for: provider))
}
+ }()
- @discardableResult
- static func register(_ implementation: any ProviderImplementation) -> any ProviderImplementation {
- self.lock.lock()
- defer { self.lock.unlock() }
- if self.store.byID[implementation.id] == nil {
- self.store.ordered.append(implementation)
- }
- self.store.byID[implementation.id] = implementation
- return implementation
- }
+ private static func ensureBootstrapped() {
+ _ = self.bootstrap
+ }
- static var all: [any ProviderImplementation] {
- self.ensureBootstrapped()
- self.lock.lock()
- defer { self.lock.unlock() }
- return self.store.ordered
+ @discardableResult
+ static func register(_ implementation: any ProviderImplementation) -> any ProviderImplementation {
+ self.lock.lock()
+ defer { self.lock.unlock() }
+ if self.store.byID[implementation.id] == nil {
+ self.store.ordered.append(implementation)
}
+ self.store.byID[implementation.id] = implementation
+ return implementation
+ }
- static func implementation(for id: UsageProvider) -> (any ProviderImplementation)? {
- self.ensureBootstrapped()
- if let found = self.store.byID[id] { return found }
- return self.all.first(where: { $0.id == id })
- }
+ static var all: [any ProviderImplementation] {
+ self.ensureBootstrapped()
+ self.lock.lock()
+ defer { self.lock.unlock() }
+ return self.store.ordered
+ }
+
+ static func implementation(for id: UsageProvider) -> (any ProviderImplementation)? {
+ self.ensureBootstrapped()
+ if let found = self.store.byID[id] { return found }
+ return self.all.first(where: { $0.id == id })
+ }
}
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+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift
index 484be310a..52a7c6b03 100644
--- a/Sources/CodexBar/StatusItemController+Menu.swift
+++ b/Sources/CodexBar/StatusItemController+Menu.swift
@@ -1459,7 +1459,9 @@ extension StatusItemController {
let sourceLabel = snapshotOverride == nil ? self.store.sourceLabel(for: target) : nil
let kiloAutoMode = target == .kilo && self.settings.kiloUsageDataSource == .auto
let now = Date()
- let weeklyPace = snapshot?.secondary.flatMap { window in
+ // Abacus uses primary for monthly credits (no secondary window)
+ let paceWindow = target == .abacus ? snapshot?.primary : snapshot?.secondary
+ let weeklyPace = paceWindow.flatMap { window in
self.store.weeklyPace(provider: target, window: window, now: now)
}
let input = UsageMenuCardView.Model.Input(
diff --git a/Sources/CodexBar/UsageStore+HistoricalPace.swift b/Sources/CodexBar/UsageStore+HistoricalPace.swift
index e228025f3..985a5e08a 100644
--- a/Sources/CodexBar/UsageStore+HistoricalPace.swift
+++ b/Sources/CodexBar/UsageStore+HistoricalPace.swift
@@ -8,7 +8,7 @@ extension UsageStore {
private static let backfillMaxTimestampMismatch: TimeInterval = 5 * 60
func weeklyPace(provider: UsageProvider, window: RateWindow, now: Date = .init()) -> UsagePace? {
- guard provider == .codex || provider == .claude else { return nil }
+ guard provider == .codex || provider == .claude || provider == .abacus else { return nil }
guard window.remainingPercent > 0 else { return nil }
let resolved: UsagePace?
if provider == .codex, self.settings.historicalTrackingEnabled {
diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift
index 9bb258a14..815478020 100644
--- a/Sources/CodexBar/UsageStore.swift
+++ b/Sources/CodexBar/UsageStore.swift
@@ -1230,6 +1230,8 @@ extension UsageStore {
let hasAny = resolution != nil
let source = resolution?.source.rawValue ?? "none"
return "WARP_API_KEY=\(hasAny ? "present" : "missing") source=\(source)"
+ case .abacus:
+ return "Abacus AI debug log not yet implemented"
case .gemini, .antigravity, .opencode, .factory, .copilot, .vertexai, .kilo, .kiro, .kimi,
.kimik2, .jetbrains, .perplexity:
return unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented"
diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift
index d52302847..4041e91b5 100644
--- a/Sources/CodexBarCLI/TokenAccountCLI.swift
+++ b/Sources/CodexBarCLI/TokenAccountCLI.swift
@@ -184,6 +184,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
}
@@ -204,7 +211,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,
@@ -221,7 +229,8 @@ struct TokenAccountCLIContext {
amp: amp,
ollama: ollama,
jetbrains: jetbrains,
- perplexity: perplexity)
+ perplexity: perplexity,
+ abacus: abacus)
}
func environment(
diff --git a/Sources/CodexBarCore/Logging/LogCategories.swift b/Sources/CodexBarCore/Logging/LogCategories.swift
index 0f2a6b0f9..fcebc0f04 100644
--- a/Sources/CodexBarCore/Logging/LogCategories.swift
+++ b/Sources/CodexBarCore/Logging/LogCategories.swift
@@ -1,4 +1,5 @@
public enum LogCategories {
+ public static let abacusUsage = "abacus-usage"
public static let amp = "amp"
public static let antigravity = "antigravity"
public static let app = "app"
diff --git a/Sources/CodexBarCore/Providers/Abacus/AbacusProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Abacus/AbacusProviderDescriptor.swift
new file mode 100644
index 000000000..7876b3e10
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Abacus/AbacusProviderDescriptor.swift
@@ -0,0 +1,79 @@
+import CodexBarMacroSupport
+import Foundation
+
+#if os(macOS)
+import SweetCookieKit
+#endif
+
+@ProviderDescriptorRegistration
+@ProviderDescriptorDefinition
+public enum AbacusProviderDescriptor {
+ static func makeDescriptor() -> ProviderDescriptor {
+ ProviderDescriptor(
+ id: .abacus,
+ metadata: ProviderMetadata(
+ id: .abacus,
+ displayName: "Abacus AI",
+ sessionLabel: "Credits",
+ weeklyLabel: "Weekly",
+ opusLabel: nil,
+ supportsOpus: false,
+ supportsCredits: true,
+ creditsHint: "Abacus AI compute credits for ChatLLM/RouteLLM usage.",
+ toggleTitle: "Show Abacus AI usage",
+ cliName: "abacusai",
+ defaultEnabled: false,
+ isPrimaryProvider: false,
+ usesAccountFallback: false,
+ browserCookieOrder: ProviderBrowserCookieDefaults.defaultImportOrder,
+ dashboardURL: "https://apps.abacus.ai/chatllm/admin/compute-points-usage",
+ statusPageURL: 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))
+ }
+}
+
+struct AbacusWebFetchStrategy: ProviderFetchStrategy {
+ let id: String = "abacus.web"
+ let kind: ProviderFetchKind = .web
+
+ func isAvailable(_ context: ProviderFetchContext) async -> Bool {
+ guard context.settings?.abacus?.cookieSource != .off else { return false }
+ return true
+ }
+
+ func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
+ let manual = Self.manualCookieHeader(from: context)
+ let logger: ((String) -> Void)? = context.verbose
+ ? { msg in CodexBarLog.logger(LogCategories.abacusUsage).verbose(msg) }
+ : nil
+ let snap = try await AbacusUsageFetcher.fetchUsage(cookieHeaderOverride: manual, logger: logger)
+ return self.makeResult(
+ usage: snap.toUsageSnapshot(),
+ sourceLabel: "web")
+ }
+
+ func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool {
+ false
+ }
+
+ private static func manualCookieHeader(from context: ProviderFetchContext) -> String? {
+ guard context.settings?.abacus?.cookieSource == .manual else { return nil }
+ return CookieHeaderNormalizer.normalize(context.settings?.abacus?.manualCookieHeader)
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Abacus/AbacusUsageFetcher.swift b/Sources/CodexBarCore/Providers/Abacus/AbacusUsageFetcher.swift
new file mode 100644
index 000000000..56b7353c7
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Abacus/AbacusUsageFetcher.swift
@@ -0,0 +1,344 @@
+import Foundation
+
+#if os(macOS)
+import SweetCookieKit
+
+private let abacusCookieImportOrder: BrowserCookieImportOrder =
+ ProviderDefaults.metadata[.abacus]?.browserCookieOrder ?? Browser.defaultImportOrder
+
+// MARK: - Abacus Cookie Importer
+
+public enum AbacusCookieImporter {
+ private static let cookieClient = BrowserCookieClient()
+ private static let cookieDomains = ["abacus.ai", "apps.abacus.ai"]
+
+ /// Cookie name prefixes/substrings that indicate a session or auth cookie.
+ private static let sessionCookiePatterns = ["session", "sess", "auth", "token", "sid", "jwt", "id"]
+
+ public struct SessionInfo: Sendable {
+ public let cookies: [HTTPCookie]
+ public let sourceLabel: String
+
+ public var cookieHeader: String {
+ self.cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ")
+ }
+ }
+
+ public static func importSession(logger: ((String) -> Void)? = nil) throws -> SessionInfo {
+ let log: (String) -> Void = { msg in logger?("[abacus-cookie] \(msg)") }
+
+ for browserSource in abacusCookieImportOrder {
+ do {
+ let query = BrowserCookieQuery(domains: cookieDomains)
+ let sources = try Self.cookieClient.records(
+ matching: query,
+ in: browserSource,
+ logger: log)
+ for source in sources where !source.records.isEmpty {
+ let httpCookies = BrowserCookieClient.makeHTTPCookies(source.records, origin: query.origin)
+ guard !httpCookies.isEmpty else { continue }
+
+ // Only accept cookie sets that contain at least one session/auth cookie
+ guard Self.containsSessionCookie(httpCookies) else {
+ let cookieNames = httpCookies.map(\.name).joined(separator: ", ")
+ log("Skipping \(source.label): no session cookie found among [\(cookieNames)]")
+ continue
+ }
+
+ let cookieNames = httpCookies.map(\.name).joined(separator: ", ")
+ log("Found \(httpCookies.count) cookies in \(source.label): \(cookieNames)")
+ return SessionInfo(cookies: httpCookies, sourceLabel: source.label)
+ }
+ } catch {
+ BrowserCookieAccessGate.recordIfNeeded(error)
+ log("\(browserSource.displayName) cookie import failed: \(error.localizedDescription)")
+ }
+ }
+
+ throw AbacusUsageError.noSessionCookie
+ }
+
+ /// Returns `true` if the cookie set contains at least one cookie whose name
+ /// suggests it carries session or authentication state.
+ private static func containsSessionCookie(_ cookies: [HTTPCookie]) -> Bool {
+ cookies.contains { cookie in
+ let lower = cookie.name.lowercased()
+ return sessionCookiePatterns.contains { lower.contains($0) }
+ }
+ }
+}
+
+// MARK: - Abacus Usage Snapshot
+
+public struct AbacusUsageSnapshot: Sendable {
+ public let creditsUsed: Double?
+ public let creditsTotal: Double?
+ public let resetsAt: Date?
+ public let planName: String?
+
+ public func toUsageSnapshot() -> UsageSnapshot {
+ let percentUsed: Double = if let used = self.creditsUsed, let total = self.creditsTotal, total > 0 {
+ (used / total) * 100.0
+ } else {
+ 0
+ }
+
+ let resetDesc: String? = if let used = self.creditsUsed, let total = self.creditsTotal {
+ "\(Self.formatCredits(used)) / \(Self.formatCredits(total)) credits"
+ } else {
+ nil
+ }
+
+ // Use windowMinutes matching the monthly billing cycle so pace calculation works.
+ // Approximate 1 month as 30 days.
+ let windowMinutes = 30 * 24 * 60
+
+ let primary = RateWindow(
+ usedPercent: percentUsed,
+ windowMinutes: 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)
+ }
+
+ private static func formatCredits(_ value: Double) -> String {
+ let formatter = NumberFormatter()
+ formatter.numberStyle = .decimal
+ formatter.maximumFractionDigits = value >= 1000 ? 0 : 1
+ formatter.groupingSeparator = ","
+ return formatter.string(from: NSNumber(value: value)) ?? String(format: "%.0f", value)
+ }
+}
+
+// MARK: - Abacus Usage Error
+
+public enum AbacusUsageError: LocalizedError, Sendable {
+ case noSessionCookie
+ case sessionExpired
+ case networkError(String)
+ case parseFailed(String)
+ case unauthorized
+
+ public var errorDescription: String? {
+ switch self {
+ case .noSessionCookie:
+ "No Abacus AI session found. Please log in to apps.abacus.ai in \(abacusCookieImportOrder.loginHint)."
+ case .sessionExpired:
+ "Abacus AI session expired. Please log in again."
+ case let .networkError(msg):
+ "Abacus AI API error: \(msg)"
+ case let .parseFailed(msg):
+ "Could not parse Abacus AI usage: \(msg)"
+ case .unauthorized:
+ "Unauthorized. Please log in to Abacus AI."
+ }
+ }
+}
+
+// MARK: - Abacus Usage Fetcher
+
+public enum AbacusUsageFetcher {
+ private static let 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
+ {
+ let log: (String) -> Void = { msg in logger?("[abacus] \(msg)") }
+
+ if let override = CookieHeaderNormalizer.normalize(cookieHeaderOverride) {
+ log("Using manual cookie header")
+ return try await Self.fetchWithCookieHeader(override, timeout: timeout)
+ }
+
+ if let cached = CookieHeaderCache.load(provider: .abacus),
+ !cached.cookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ {
+ log("Using cached cookie header from \(cached.sourceLabel)")
+ do {
+ return try await Self.fetchWithCookieHeader(cached.cookieHeader, timeout: timeout)
+ } catch let error as AbacusUsageError {
+ switch error {
+ case .unauthorized, .sessionExpired:
+ CookieHeaderCache.clear(provider: .abacus)
+ default:
+ throw error
+ }
+ }
+ }
+
+ let session: AbacusCookieImporter.SessionInfo
+ do {
+ session = try AbacusCookieImporter.importSession(logger: log)
+ log("Using cookies from \(session.sourceLabel)")
+ } catch {
+ BrowserCookieAccessGate.recordIfNeeded(error)
+ log("Browser cookie import failed: \(error.localizedDescription)")
+ throw AbacusUsageError.noSessionCookie
+ }
+
+ // API errors after a successful cookie import must propagate directly
+ let snapshot = try await Self.fetchWithCookieHeader(session.cookieHeader, timeout: timeout)
+ CookieHeaderCache.store(
+ provider: .abacus,
+ cookieHeader: session.cookieHeader,
+ sourceLabel: session.sourceLabel)
+ return snapshot
+ }
+
+ private static func fetchWithCookieHeader(
+ _ cookieHeader: String,
+ timeout: TimeInterval) async throws -> AbacusUsageSnapshot
+ {
+ // Fetch compute points (GET) and billing info (POST) concurrently
+ async let computePoints = Self.fetchJSON(
+ url: computePointsURL, method: "GET", cookieHeader: cookieHeader, timeout: timeout)
+ async let billingInfo = Self.fetchJSON(
+ url: billingInfoURL, method: "POST", cookieHeader: cookieHeader, timeout: timeout)
+
+ let cpResult = try await computePoints
+ let biResult = (try? await billingInfo) ?? [:]
+
+ return Self.parseResults(computePoints: cpResult, billingInfo: biResult)
+ }
+
+ private static func fetchJSON(
+ url: URL, method: String, cookieHeader: String, timeout: TimeInterval
+ ) async throws -> [String: Any] {
+ var request = URLRequest(url: url)
+ request.httpMethod = method
+ request.timeoutInterval = timeout
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
+ request.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
+ 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, encoding: .utf8) ?? ""
+ throw AbacusUsageError.networkError("HTTP \(httpResponse.statusCode): \(body)")
+ }
+
+ guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
+ throw AbacusUsageError.parseFailed("Invalid JSON from \(url.lastPathComponent)")
+ }
+
+ guard root["success"] as? Bool == true,
+ let result = root["result"] as? [String: Any]
+ else {
+ let errorMsg = root["error"] as? String ?? "Unknown error"
+ throw AbacusUsageError.parseFailed("\(url.lastPathComponent): \(errorMsg)")
+ }
+
+ return result
+ }
+
+ // MARK: - Parsing
+
+ private static func parseResults(
+ computePoints: [String: Any], billingInfo: [String: Any]
+ ) -> AbacusUsageSnapshot {
+ // _getOrganizationComputePoints returns values already in credits (no division needed)
+ let totalCredits = Self.double(from: computePoints["totalComputePoints"])
+ let creditsLeft = Self.double(from: computePoints["computePointsLeft"])
+ let creditsUsed: Double? = if let total = totalCredits, let left = creditsLeft {
+ total - left
+ } else {
+ nil
+ }
+
+ // _getBillingInfo returns the exact next billing date and plan tier
+ 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)
+ }
+}
+
+#else
+
+// MARK: - Abacus (Unsupported)
+
+public enum AbacusUsageError: LocalizedError, Sendable {
+ case notSupported
+
+ public var errorDescription: String? {
+ "Abacus AI is only supported on macOS."
+ }
+}
+
+public struct AbacusUsageSnapshot: Sendable {
+ public init() {}
+
+ public func toUsageSnapshot() -> UsageSnapshot {
+ UsageSnapshot(
+ primary: RateWindow(usedPercent: 0, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ secondary: nil,
+ tertiary: nil,
+ providerCost: nil,
+ updatedAt: Date(),
+ identity: nil)
+ }
+}
+
+public enum AbacusUsageFetcher {
+ public static func fetchUsage(
+ cookieHeaderOverride _: String? = nil,
+ timeout _: TimeInterval = 15.0,
+ logger _: ((String) -> Void)? = nil) async throws -> AbacusUsageSnapshot
+ {
+ throw AbacusUsageError.notSupported
+ }
+}
+
+#endif
diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
index 236af4bd3..b57ed1128 100644
--- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
+++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
@@ -1,134 +1,135 @@
import Foundation
public struct ProviderTokenCostConfig: Sendable {
- public let supportsTokenCost: Bool
- public let noDataMessage: @Sendable () -> String
+ public let supportsTokenCost: Bool
+ public let noDataMessage: @Sendable () -> String
- public init(supportsTokenCost: Bool, noDataMessage: @escaping @Sendable () -> String) {
- self.supportsTokenCost = supportsTokenCost
- self.noDataMessage = noDataMessage
- }
+ public init(supportsTokenCost: Bool, noDataMessage: @escaping @Sendable () -> String) {
+ self.supportsTokenCost = supportsTokenCost
+ self.noDataMessage = noDataMessage
+ }
}
public struct ProviderDescriptor: Sendable {
- public let id: UsageProvider
- public let metadata: ProviderMetadata
- public let branding: ProviderBranding
- public let tokenCost: ProviderTokenCostConfig
- public let fetchPlan: ProviderFetchPlan
- public let cli: ProviderCLIConfig
+ public let id: UsageProvider
+ public let metadata: ProviderMetadata
+ public let branding: ProviderBranding
+ public let tokenCost: ProviderTokenCostConfig
+ public let fetchPlan: ProviderFetchPlan
+ public let cli: ProviderCLIConfig
- public init(
- id: UsageProvider,
- metadata: ProviderMetadata,
- branding: ProviderBranding,
- tokenCost: ProviderTokenCostConfig,
- fetchPlan: ProviderFetchPlan,
- cli: ProviderCLIConfig)
- {
- self.id = id
- self.metadata = metadata
- self.branding = branding
- self.tokenCost = tokenCost
- self.fetchPlan = fetchPlan
- self.cli = cli
- }
+ public init(
+ id: UsageProvider,
+ metadata: ProviderMetadata,
+ branding: ProviderBranding,
+ tokenCost: ProviderTokenCostConfig,
+ fetchPlan: ProviderFetchPlan,
+ cli: ProviderCLIConfig
+ ) {
+ self.id = id
+ self.metadata = metadata
+ self.branding = branding
+ self.tokenCost = tokenCost
+ self.fetchPlan = fetchPlan
+ self.cli = cli
+ }
- public func fetchOutcome(context: ProviderFetchContext) async -> ProviderFetchOutcome {
- await self.fetchPlan.fetchOutcome(context: context, provider: self.id)
- }
+ public func fetchOutcome(context: ProviderFetchContext) async -> ProviderFetchOutcome {
+ await self.fetchPlan.fetchOutcome(context: context, provider: self.id)
+ }
- public func fetch(context: ProviderFetchContext) async throws -> ProviderFetchResult {
- let outcome = await self.fetchOutcome(context: context)
- return try outcome.result.get()
- }
+ public func fetch(context: ProviderFetchContext) async throws -> ProviderFetchResult {
+ let outcome = await self.fetchOutcome(context: context)
+ return try outcome.result.get()
+ }
}
public enum ProviderDescriptorRegistry {
- private final class Store: @unchecked Sendable {
- var ordered: [ProviderDescriptor] = []
- var byID: [UsageProvider: ProviderDescriptor] = [:]
- }
+ private final class Store: @unchecked Sendable {
+ var ordered: [ProviderDescriptor] = []
+ var byID: [UsageProvider: ProviderDescriptor] = [:]
+ }
- private static let lock = NSLock()
- private static let store = Store()
- private static let descriptorsByID: [UsageProvider: ProviderDescriptor] = [
- .codex: CodexProviderDescriptor.descriptor,
- .claude: ClaudeProviderDescriptor.descriptor,
- .cursor: CursorProviderDescriptor.descriptor,
- .opencode: OpenCodeProviderDescriptor.descriptor,
- .alibaba: AlibabaCodingPlanProviderDescriptor.descriptor,
- .factory: FactoryProviderDescriptor.descriptor,
- .gemini: GeminiProviderDescriptor.descriptor,
- .antigravity: AntigravityProviderDescriptor.descriptor,
- .copilot: CopilotProviderDescriptor.descriptor,
- .zai: ZaiProviderDescriptor.descriptor,
- .minimax: MiniMaxProviderDescriptor.descriptor,
- .kimi: KimiProviderDescriptor.descriptor,
- .kilo: KiloProviderDescriptor.descriptor,
- .kiro: KiroProviderDescriptor.descriptor,
- .vertexai: VertexAIProviderDescriptor.descriptor,
- .augment: AugmentProviderDescriptor.descriptor,
- .jetbrains: JetBrainsProviderDescriptor.descriptor,
- .kimik2: KimiK2ProviderDescriptor.descriptor,
- .amp: AmpProviderDescriptor.descriptor,
- .ollama: OllamaProviderDescriptor.descriptor,
- .synthetic: SyntheticProviderDescriptor.descriptor,
- .openrouter: OpenRouterProviderDescriptor.descriptor,
- .warp: WarpProviderDescriptor.descriptor,
- .perplexity: PerplexityProviderDescriptor.descriptor,
- ]
- private static let bootstrap: Void = {
- for provider in UsageProvider.allCases {
- guard let descriptor = descriptorsByID[provider] else {
- preconditionFailure("Missing ProviderDescriptor for \(provider.rawValue)")
- }
- _ = ProviderDescriptorRegistry.register(descriptor)
- }
- }()
-
- private static func ensureBootstrapped() {
- _ = self.bootstrap
+ private static let lock = NSLock()
+ private static let store = Store()
+ private static let descriptorsByID: [UsageProvider: ProviderDescriptor] = [
+ .codex: CodexProviderDescriptor.descriptor,
+ .claude: ClaudeProviderDescriptor.descriptor,
+ .cursor: CursorProviderDescriptor.descriptor,
+ .opencode: OpenCodeProviderDescriptor.descriptor,
+ .alibaba: AlibabaCodingPlanProviderDescriptor.descriptor,
+ .factory: FactoryProviderDescriptor.descriptor,
+ .gemini: GeminiProviderDescriptor.descriptor,
+ .antigravity: AntigravityProviderDescriptor.descriptor,
+ .copilot: CopilotProviderDescriptor.descriptor,
+ .zai: ZaiProviderDescriptor.descriptor,
+ .minimax: MiniMaxProviderDescriptor.descriptor,
+ .kimi: KimiProviderDescriptor.descriptor,
+ .kilo: KiloProviderDescriptor.descriptor,
+ .kiro: KiroProviderDescriptor.descriptor,
+ .vertexai: VertexAIProviderDescriptor.descriptor,
+ .augment: AugmentProviderDescriptor.descriptor,
+ .jetbrains: JetBrainsProviderDescriptor.descriptor,
+ .kimik2: KimiK2ProviderDescriptor.descriptor,
+ .amp: AmpProviderDescriptor.descriptor,
+ .ollama: OllamaProviderDescriptor.descriptor,
+ .synthetic: SyntheticProviderDescriptor.descriptor,
+ .openrouter: OpenRouterProviderDescriptor.descriptor,
+ .warp: WarpProviderDescriptor.descriptor,
+ .perplexity: PerplexityProviderDescriptor.descriptor,
+ .abacus: AbacusProviderDescriptor.descriptor,
+ ]
+ private static let bootstrap: Void = {
+ for provider in UsageProvider.allCases {
+ guard let descriptor = descriptorsByID[provider] else {
+ preconditionFailure("Missing ProviderDescriptor for \(provider.rawValue)")
+ }
+ _ = ProviderDescriptorRegistry.register(descriptor)
}
+ }()
- @discardableResult
- public static func register(_ descriptor: ProviderDescriptor) -> ProviderDescriptor {
- self.lock.lock()
- defer { self.lock.unlock() }
- if self.store.byID[descriptor.id] == nil {
- self.store.ordered.append(descriptor)
- }
- self.store.byID[descriptor.id] = descriptor
- return descriptor
- }
+ private static func ensureBootstrapped() {
+ _ = self.bootstrap
+ }
- public static var all: [ProviderDescriptor] {
- self.ensureBootstrapped()
- self.lock.lock()
- defer { self.lock.unlock() }
- return self.store.ordered
+ @discardableResult
+ public static func register(_ descriptor: ProviderDescriptor) -> ProviderDescriptor {
+ self.lock.lock()
+ defer { self.lock.unlock() }
+ if self.store.byID[descriptor.id] == nil {
+ self.store.ordered.append(descriptor)
}
+ self.store.byID[descriptor.id] = descriptor
+ return descriptor
+ }
- public static var metadata: [UsageProvider: ProviderMetadata] {
- Dictionary(uniqueKeysWithValues: self.all.map { ($0.id, $0.metadata) })
- }
+ public static var all: [ProviderDescriptor] {
+ self.ensureBootstrapped()
+ self.lock.lock()
+ defer { self.lock.unlock() }
+ return self.store.ordered
+ }
- public static func descriptor(for id: UsageProvider) -> ProviderDescriptor {
- self.ensureBootstrapped()
- if let found = self.store.byID[id] { return found }
- if let found = self.all.first(where: { $0.id == id }) { return found }
- fatalError("Missing ProviderDescriptor for \(id.rawValue)")
- }
+ public static var metadata: [UsageProvider: ProviderMetadata] {
+ Dictionary(uniqueKeysWithValues: self.all.map { ($0.id, $0.metadata) })
+ }
+
+ public static func descriptor(for id: UsageProvider) -> ProviderDescriptor {
+ self.ensureBootstrapped()
+ if let found = self.store.byID[id] { return found }
+ if let found = self.all.first(where: { $0.id == id }) { return found }
+ fatalError("Missing ProviderDescriptor for \(id.rawValue)")
+ }
- public static var cliNameMap: [String: UsageProvider] {
- self.ensureBootstrapped()
- var map: [String: UsageProvider] = [:]
- for descriptor in self.all {
- map[descriptor.cli.name] = descriptor.id
- for alias in descriptor.cli.aliases {
- map[alias] = descriptor.id
- }
- }
- return map
+ public static var cliNameMap: [String: UsageProvider] {
+ self.ensureBootstrapped()
+ var map: [String: UsageProvider] = [:]
+ for descriptor in self.all {
+ map[descriptor.cli.name] = descriptor.id
+ for alias in descriptor.cli.aliases {
+ map[alias] = descriptor.id
+ }
}
+ return map
+ }
}
diff --git a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift
index 7c1d1f786..c40167975 100644
--- a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift
+++ b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift
@@ -19,7 +19,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,
@@ -39,7 +40,8 @@ public struct ProviderSettingsSnapshot: Sendable {
amp: amp,
ollama: ollama,
jetbrains: jetbrains,
- perplexity: perplexity)
+ perplexity: perplexity,
+ abacus: abacus)
}
public struct CodexProviderSettings: Sendable {
@@ -221,6 +223,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?
@@ -239,6 +251,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
@@ -262,7 +275,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
@@ -282,6 +296,7 @@ public struct ProviderSettingsSnapshot: Sendable {
self.ollama = ollama
self.jetbrains = jetbrains
self.perplexity = perplexity
+ self.abacus = abacus
}
}
@@ -302,6 +317,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 {
@@ -323,6 +339,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
@@ -347,6 +364,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
}
}
@@ -369,6 +387,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 ff0f8eeb4..96544a992 100644
--- a/Sources/CodexBarCore/Providers/Providers.swift
+++ b/Sources/CodexBarCore/Providers/Providers.swift
@@ -3,150 +3,152 @@ import SweetCookieKit
// swiftformat:disable sortDeclarations
public enum UsageProvider: String, CaseIterable, Sendable, Codable {
- case codex
- case claude
- case cursor
- case opencode
- case alibaba
- case factory
- case gemini
- case antigravity
- case copilot
- case zai
- case minimax
- case kimi
- case kilo
- case kiro
- case vertexai
- case augment
- case jetbrains
- case kimik2
- case amp
- case ollama
- case synthetic
- case warp
- case openrouter
- case perplexity
+ case codex
+ case claude
+ case cursor
+ case opencode
+ case alibaba
+ case factory
+ case gemini
+ case antigravity
+ case copilot
+ case zai
+ case minimax
+ case kimi
+ case kilo
+ case kiro
+ case vertexai
+ case augment
+ case jetbrains
+ case kimik2
+ case amp
+ case ollama
+ case synthetic
+ case warp
+ case openrouter
+ case perplexity
+ case abacus
}
// swiftformat:enable sortDeclarations
public enum IconStyle: Sendable, CaseIterable {
- case codex
- case claude
- case zai
- case minimax
- case gemini
- case antigravity
- case cursor
- case opencode
- case alibaba
- case factory
- case copilot
- case kimi
- case kimik2
- case kilo
- case kiro
- case vertexai
- case augment
- case jetbrains
- case amp
- case ollama
- case synthetic
- case warp
- case openrouter
- case perplexity
- case combined
+ case codex
+ case claude
+ case zai
+ case minimax
+ case gemini
+ case antigravity
+ case cursor
+ case opencode
+ case alibaba
+ case factory
+ case copilot
+ case kimi
+ case kimik2
+ case kilo
+ case kiro
+ case vertexai
+ case augment
+ case jetbrains
+ case amp
+ case ollama
+ case synthetic
+ case warp
+ case openrouter
+ case perplexity
+ case abacus
+ case combined
}
public struct ProviderMetadata: Sendable {
- public let id: UsageProvider
- public let displayName: String
- public let sessionLabel: String
- public let weeklyLabel: String
- public let opusLabel: String?
- public let supportsOpus: Bool
- public let supportsCredits: Bool
- public let creditsHint: String
- public let toggleTitle: String
- public let cliName: String
- public let defaultEnabled: Bool
- public let isPrimaryProvider: Bool
- public let usesAccountFallback: Bool
- public let browserCookieOrder: BrowserCookieImportOrder?
- public let dashboardURL: String?
- public let subscriptionDashboardURL: String?
- /// Statuspage.io base URL for incident polling (append /api/v2/status.json).
- public let statusPageURL: String?
- /// Browser-only status link (no API polling); used when statusPageURL is nil.
- public let statusLinkURL: String?
- /// Google Workspace product ID for status polling (appsstatus dashboard).
- public let statusWorkspaceProductID: String?
+ public let id: UsageProvider
+ public let displayName: String
+ public let sessionLabel: String
+ public let weeklyLabel: String
+ public let opusLabel: String?
+ public let supportsOpus: Bool
+ public let supportsCredits: Bool
+ public let creditsHint: String
+ public let toggleTitle: String
+ public let cliName: String
+ public let defaultEnabled: Bool
+ public let isPrimaryProvider: Bool
+ public let usesAccountFallback: Bool
+ public let browserCookieOrder: BrowserCookieImportOrder?
+ public let dashboardURL: String?
+ public let subscriptionDashboardURL: String?
+ /// Statuspage.io base URL for incident polling (append /api/v2/status.json).
+ public let statusPageURL: String?
+ /// Browser-only status link (no API polling); used when statusPageURL is nil.
+ public let statusLinkURL: String?
+ /// Google Workspace product ID for status polling (appsstatus dashboard).
+ public let statusWorkspaceProductID: String?
- public init(
- id: UsageProvider,
- displayName: String,
- sessionLabel: String,
- weeklyLabel: String,
- opusLabel: String?,
- supportsOpus: Bool,
- supportsCredits: Bool,
- creditsHint: String,
- toggleTitle: String,
- cliName: String,
- defaultEnabled: Bool,
- isPrimaryProvider: Bool = false,
- usesAccountFallback: Bool = false,
- browserCookieOrder: BrowserCookieImportOrder? = nil,
- dashboardURL: String?,
- subscriptionDashboardURL: String? = nil,
- statusPageURL: String?,
- statusLinkURL: String? = nil,
- statusWorkspaceProductID: String? = nil)
- {
- self.id = id
- self.displayName = displayName
- self.sessionLabel = sessionLabel
- self.weeklyLabel = weeklyLabel
- self.opusLabel = opusLabel
- self.supportsOpus = supportsOpus
- self.supportsCredits = supportsCredits
- self.creditsHint = creditsHint
- self.toggleTitle = toggleTitle
- self.cliName = cliName
- self.defaultEnabled = defaultEnabled
- self.isPrimaryProvider = isPrimaryProvider
- self.usesAccountFallback = usesAccountFallback
- self.browserCookieOrder = browserCookieOrder
- self.dashboardURL = dashboardURL
- self.subscriptionDashboardURL = subscriptionDashboardURL
- self.statusPageURL = statusPageURL
- self.statusLinkURL = statusLinkURL
- self.statusWorkspaceProductID = statusWorkspaceProductID
- }
+ public init(
+ id: UsageProvider,
+ displayName: String,
+ sessionLabel: String,
+ weeklyLabel: String,
+ opusLabel: String?,
+ supportsOpus: Bool,
+ supportsCredits: Bool,
+ creditsHint: String,
+ toggleTitle: String,
+ cliName: String,
+ defaultEnabled: Bool,
+ isPrimaryProvider: Bool = false,
+ usesAccountFallback: Bool = false,
+ browserCookieOrder: BrowserCookieImportOrder? = nil,
+ dashboardURL: String?,
+ subscriptionDashboardURL: String? = nil,
+ statusPageURL: String?,
+ statusLinkURL: String? = nil,
+ statusWorkspaceProductID: String? = nil
+ ) {
+ self.id = id
+ self.displayName = displayName
+ self.sessionLabel = sessionLabel
+ self.weeklyLabel = weeklyLabel
+ self.opusLabel = opusLabel
+ self.supportsOpus = supportsOpus
+ self.supportsCredits = supportsCredits
+ self.creditsHint = creditsHint
+ self.toggleTitle = toggleTitle
+ self.cliName = cliName
+ self.defaultEnabled = defaultEnabled
+ self.isPrimaryProvider = isPrimaryProvider
+ self.usesAccountFallback = usesAccountFallback
+ self.browserCookieOrder = browserCookieOrder
+ self.dashboardURL = dashboardURL
+ self.subscriptionDashboardURL = subscriptionDashboardURL
+ self.statusPageURL = statusPageURL
+ self.statusLinkURL = statusLinkURL
+ self.statusWorkspaceProductID = statusWorkspaceProductID
+ }
}
public enum ProviderDefaults {
- public static var metadata: [UsageProvider: ProviderMetadata] {
- ProviderDescriptorRegistry.metadata
- }
+ public static var metadata: [UsageProvider: ProviderMetadata] {
+ ProviderDescriptorRegistry.metadata
+ }
}
public enum ProviderBrowserCookieDefaults {
- public static var defaultImportOrder: BrowserCookieImportOrder? {
- #if os(macOS)
- Browser.defaultImportOrder
- #else
- nil
- #endif
- }
+ public static var defaultImportOrder: BrowserCookieImportOrder? {
+ #if os(macOS)
+ Browser.defaultImportOrder
+ #else
+ nil
+ #endif
+ }
- /// Safari first for Cursor: active sessions often live only there, and Chromium profiles may carry stale tokens.
- public static var cursorCookieImportOrder: BrowserCookieImportOrder? {
- #if os(macOS)
- [.safari] + Browser.defaultImportOrder.filter { $0 != .safari }
- #else
- nil
- #endif
- }
+ /// Safari first for Cursor: active sessions often live only there, and Chromium profiles may carry stale tokens.
+ public static var cursorCookieImportOrder: BrowserCookieImportOrder? {
+ #if os(macOS)
+ [.safari] + Browser.defaultImportOrder.filter { $0 != .safari }
+ #else
+ nil
+ #endif
+ }
}
diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
index f4a3ba8bb..36e4a79d0 100644
--- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
+++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
@@ -1,760 +1,798 @@
import Foundation
enum CostUsageScanner {
- enum ClaudeLogProviderFilter {
- case all
- case vertexAIOnly
- case excludeVertexAI
+ enum ClaudeLogProviderFilter {
+ case all
+ case vertexAIOnly
+ case excludeVertexAI
+ }
+
+ struct Options {
+ var codexSessionsRoot: URL?
+ var claudeProjectsRoots: [URL]?
+ var cacheRoot: URL?
+ var refreshMinIntervalSeconds: TimeInterval = 60
+ var claudeLogProviderFilter: ClaudeLogProviderFilter = .all
+ /// Force a full rescan, ignoring per-file cache and incremental offsets.
+ var forceRescan: Bool = false
+
+ init(
+ codexSessionsRoot: URL? = nil,
+ claudeProjectsRoots: [URL]? = nil,
+ cacheRoot: URL? = nil,
+ claudeLogProviderFilter: ClaudeLogProviderFilter = .all,
+ forceRescan: Bool = false
+ ) {
+ self.codexSessionsRoot = codexSessionsRoot
+ self.claudeProjectsRoots = claudeProjectsRoots
+ self.cacheRoot = cacheRoot
+ self.claudeLogProviderFilter = claudeLogProviderFilter
+ self.forceRescan = forceRescan
}
-
- struct Options {
- var codexSessionsRoot: URL?
- var claudeProjectsRoots: [URL]?
- var cacheRoot: URL?
- var refreshMinIntervalSeconds: TimeInterval = 60
- var claudeLogProviderFilter: ClaudeLogProviderFilter = .all
- /// Force a full rescan, ignoring per-file cache and incremental offsets.
- var forceRescan: Bool = false
-
- init(
- codexSessionsRoot: URL? = nil,
- claudeProjectsRoots: [URL]? = nil,
- cacheRoot: URL? = nil,
- claudeLogProviderFilter: ClaudeLogProviderFilter = .all,
- forceRescan: Bool = false)
- {
- self.codexSessionsRoot = codexSessionsRoot
- self.claudeProjectsRoots = claudeProjectsRoots
- self.cacheRoot = cacheRoot
- self.claudeLogProviderFilter = claudeLogProviderFilter
- self.forceRescan = forceRescan
- }
+ }
+
+ struct CodexParseResult {
+ let days: [String: [String: [Int]]]
+ let parsedBytes: Int64
+ let lastModel: String?
+ let lastTotals: CostUsageCodexTotals?
+ let sessionId: String?
+ }
+
+ private struct CodexScanState {
+ var seenSessionIds: Set = []
+ var seenFileIds: Set = []
+ }
+
+ struct ClaudeParseResult {
+ let days: [String: [String: [Int]]]
+ let parsedBytes: Int64
+ }
+
+ static func loadDailyReport(
+ provider: UsageProvider,
+ since: Date,
+ until: Date,
+ now: Date = Date(),
+ options: Options = Options()
+ ) -> CostUsageDailyReport {
+ let range = CostUsageDayRange(since: since, until: until)
+ let emptyReport = CostUsageDailyReport(data: [], summary: nil)
+
+ switch provider {
+ case .codex:
+ return self.loadCodexDaily(range: range, now: now, options: options)
+ case .claude:
+ return self.loadClaudeDaily(provider: .claude, range: range, now: now, options: options)
+ case .vertexai:
+ var filtered = options
+ if filtered.claudeLogProviderFilter == .all {
+ filtered.claudeLogProviderFilter = .vertexAIOnly
+ }
+ return self.loadClaudeDaily(provider: .vertexai, range: range, now: now, options: filtered)
+ case .zai, .gemini, .antigravity, .cursor, .opencode, .alibaba, .factory, .copilot, .minimax,
+ .kilo,
+ .kiro, .kimi, .kimik2, .augment, .jetbrains, .amp, .ollama, .synthetic, .openrouter, .warp,
+ .perplexity, .abacus:
+ return emptyReport
}
-
- struct CodexParseResult {
- let days: [String: [String: [Int]]]
- let parsedBytes: Int64
- let lastModel: String?
- let lastTotals: CostUsageCodexTotals?
- let sessionId: String?
+ }
+
+ // MARK: - Day keys
+
+ struct CostUsageDayRange {
+ let sinceKey: String
+ let untilKey: String
+ let scanSinceKey: String
+ let scanUntilKey: String
+
+ init(since: Date, until: Date) {
+ self.sinceKey = Self.dayKey(from: since)
+ self.untilKey = Self.dayKey(from: until)
+ self.scanSinceKey = Self.dayKey(
+ from: Calendar.current.date(byAdding: .day, value: -1, to: since) ?? since)
+ self.scanUntilKey = Self.dayKey(
+ from: Calendar.current.date(byAdding: .day, value: 1, to: until) ?? until)
}
- private struct CodexScanState {
- var seenSessionIds: Set = []
- var seenFileIds: Set = []
+ static func dayKey(from date: Date) -> String {
+ let cal = Calendar.current
+ let comps = cal.dateComponents([.year, .month, .day], from: date)
+ let y = comps.year ?? 1970
+ let m = comps.month ?? 1
+ let d = comps.day ?? 1
+ return String(format: "%04d-%02d-%02d", y, m, d)
}
- struct ClaudeParseResult {
- let days: [String: [String: [Int]]]
- let parsedBytes: Int64
+ static func isInRange(dayKey: String, since: String, until: String) -> Bool {
+ if dayKey < since { return false }
+ if dayKey > until { return false }
+ return true
}
+ }
- static func loadDailyReport(
- provider: UsageProvider,
- since: Date,
- until: Date,
- now: Date = Date(),
- options: Options = Options()) -> CostUsageDailyReport
- {
- let range = CostUsageDayRange(since: since, until: until)
- let emptyReport = CostUsageDailyReport(data: [], summary: nil)
-
- switch provider {
- case .codex:
- return self.loadCodexDaily(range: range, now: now, options: options)
- case .claude:
- return self.loadClaudeDaily(provider: .claude, range: range, now: now, options: options)
- case .vertexai:
- var filtered = options
- if filtered.claudeLogProviderFilter == .all {
- filtered.claudeLogProviderFilter = .vertexAIOnly
- }
- return self.loadClaudeDaily(provider: .vertexai, range: range, now: now, options: filtered)
- case .zai, .gemini, .antigravity, .cursor, .opencode, .alibaba, .factory, .copilot, .minimax, .kilo,
- .kiro, .kimi,
- .kimik2, .augment, .jetbrains, .amp, .ollama, .synthetic, .openrouter, .warp, .perplexity:
- return emptyReport
- }
- }
+ // MARK: - Codex
- // MARK: - Day keys
-
- struct CostUsageDayRange {
- let sinceKey: String
- let untilKey: String
- let scanSinceKey: String
- let scanUntilKey: String
-
- init(since: Date, until: Date) {
- self.sinceKey = Self.dayKey(from: since)
- self.untilKey = Self.dayKey(from: until)
- self.scanSinceKey = Self.dayKey(from: Calendar.current.date(byAdding: .day, value: -1, to: since) ?? since)
- self.scanUntilKey = Self.dayKey(from: Calendar.current.date(byAdding: .day, value: 1, to: until) ?? until)
- }
-
- static func dayKey(from date: Date) -> String {
- let cal = Calendar.current
- let comps = cal.dateComponents([.year, .month, .day], from: date)
- let y = comps.year ?? 1970
- let m = comps.month ?? 1
- let d = comps.day ?? 1
- return String(format: "%04d-%02d-%02d", y, m, d)
+ private static func defaultCodexSessionsRoot(options: Options) -> URL {
+ if let override = options.codexSessionsRoot { return override }
+ let env = ProcessInfo.processInfo.environment["CODEX_HOME"]?.trimmingCharacters(
+ in: .whitespacesAndNewlines)
+ if let env, !env.isEmpty {
+ return URL(fileURLWithPath: env).appendingPathComponent("sessions", isDirectory: true)
+ }
+ return FileManager.default.homeDirectoryForCurrentUser
+ .appendingPathComponent(".codex", isDirectory: true)
+ .appendingPathComponent("sessions", isDirectory: true)
+ }
+
+ private static func codexSessionsRoots(options: Options) -> [URL] {
+ let root = self.defaultCodexSessionsRoot(options: options)
+ if let archived = self.codexArchivedSessionsRoot(sessionsRoot: root) {
+ return [root, archived]
+ }
+ return [root]
+ }
+
+ private static func codexArchivedSessionsRoot(sessionsRoot: URL) -> URL? {
+ guard sessionsRoot.lastPathComponent == "sessions" else { return nil }
+ return
+ sessionsRoot
+ .deletingLastPathComponent()
+ .appendingPathComponent("archived_sessions", isDirectory: true)
+ }
+
+ private static func listCodexSessionFiles(root: URL, scanSinceKey: String, scanUntilKey: String)
+ -> [URL]
+ {
+ let partitioned = self.listCodexSessionFilesByDatePartition(
+ root: root,
+ scanSinceKey: scanSinceKey,
+ scanUntilKey: scanUntilKey)
+ let flat = self.listCodexSessionFilesFlat(
+ root: root, scanSinceKey: scanSinceKey, scanUntilKey: scanUntilKey)
+ var seen: Set = []
+ var out: [URL] = []
+ for item in partitioned + flat where !seen.contains(item.path) {
+ seen.insert(item.path)
+ out.append(item)
+ }
+ return out
+ }
+
+ private static func listCodexSessionFilesByDatePartition(
+ root: URL,
+ scanSinceKey: String,
+ scanUntilKey: String
+ ) -> [URL] {
+ guard FileManager.default.fileExists(atPath: root.path) else { return [] }
+ var out: [URL] = []
+ var date = Self.parseDayKey(scanSinceKey) ?? Date()
+ let untilDate = Self.parseDayKey(scanUntilKey) ?? date
+
+ while date <= untilDate {
+ let comps = Calendar.current.dateComponents([.year, .month, .day], from: date)
+ let y = String(format: "%04d", comps.year ?? 1970)
+ let m = String(format: "%02d", comps.month ?? 1)
+ let d = String(format: "%02d", comps.day ?? 1)
+
+ let dayDir = root.appendingPathComponent(y, isDirectory: true)
+ .appendingPathComponent(m, isDirectory: true)
+ .appendingPathComponent(d, isDirectory: true)
+
+ if let items = try? FileManager.default.contentsOfDirectory(
+ at: dayDir,
+ includingPropertiesForKeys: [.isRegularFileKey],
+ options: [.skipsHiddenFiles])
+ {
+ for item in items where item.pathExtension.lowercased() == "jsonl" {
+ out.append(item)
}
+ }
- static func isInRange(dayKey: String, since: String, until: String) -> Bool {
- if dayKey < since { return false }
- if dayKey > until { return false }
- return true
- }
+ date =
+ Calendar.current.date(byAdding: .day, value: 1, to: date) ?? untilDate.addingTimeInterval(1)
}
- // MARK: - Codex
-
- private static func defaultCodexSessionsRoot(options: Options) -> URL {
- if let override = options.codexSessionsRoot { return override }
- let env = ProcessInfo.processInfo.environment["CODEX_HOME"]?.trimmingCharacters(in: .whitespacesAndNewlines)
- if let env, !env.isEmpty {
- return URL(fileURLWithPath: env).appendingPathComponent("sessions", isDirectory: true)
+ return out
+ }
+
+ private static func listCodexSessionFilesFlat(
+ root: URL, scanSinceKey: String, scanUntilKey: String
+ ) -> [URL] {
+ guard FileManager.default.fileExists(atPath: root.path) else { return [] }
+ guard
+ let items = try? FileManager.default.contentsOfDirectory(
+ at: root,
+ includingPropertiesForKeys: [.isRegularFileKey],
+ options: [.skipsHiddenFiles, .skipsPackageDescendants])
+ else { return [] }
+
+ var out: [URL] = []
+ for item in items where item.pathExtension.lowercased() == "jsonl" {
+ if let dayKey = Self.dayKeyFromFilename(item.lastPathComponent) {
+ if !CostUsageDayRange.isInRange(dayKey: dayKey, since: scanSinceKey, until: scanUntilKey) {
+ continue
}
- return FileManager.default.homeDirectoryForCurrentUser
- .appendingPathComponent(".codex", isDirectory: true)
- .appendingPathComponent("sessions", isDirectory: true)
+ }
+ out.append(item)
}
-
- private static func codexSessionsRoots(options: Options) -> [URL] {
- let root = self.defaultCodexSessionsRoot(options: options)
- if let archived = self.codexArchivedSessionsRoot(sessionsRoot: root) {
- return [root, archived]
- }
- return [root]
+ return out
+ }
+
+ private static let codexFilenameDateRegex = try? NSRegularExpression(
+ pattern: "(\\d{4}-\\d{2}-\\d{2})")
+
+ private static func dayKeyFromFilename(_ filename: String) -> String? {
+ guard let regex = self.codexFilenameDateRegex else { return nil }
+ let range = NSRange(filename.startIndex.. String? {
+ guard let values = try? fileURL.resourceValues(forKeys: [.fileResourceIdentifierKey]) else {
+ return nil
}
-
- private static func codexArchivedSessionsRoot(sessionsRoot: URL) -> URL? {
- guard sessionsRoot.lastPathComponent == "sessions" else { return nil }
- return sessionsRoot
- .deletingLastPathComponent()
- .appendingPathComponent("archived_sessions", isDirectory: true)
+ guard let identifier = values.fileResourceIdentifier else { return nil }
+ if let data = identifier as? Data {
+ return data.base64EncodedString()
}
-
- private static func listCodexSessionFiles(root: URL, scanSinceKey: String, scanUntilKey: String) -> [URL] {
- let partitioned = self.listCodexSessionFilesByDatePartition(
- root: root,
- scanSinceKey: scanSinceKey,
- scanUntilKey: scanUntilKey)
- let flat = self.listCodexSessionFilesFlat(root: root, scanSinceKey: scanSinceKey, scanUntilKey: scanUntilKey)
- var seen: Set = []
- var out: [URL] = []
- for item in partitioned + flat where !seen.contains(item.path) {
- seen.insert(item.path)
- out.append(item)
- }
- return out
+ return String(describing: identifier)
+ }
+
+ static func parseCodexFile(
+ fileURL: URL,
+ range: CostUsageDayRange,
+ startOffset: Int64 = 0,
+ initialModel: String? = nil,
+ initialTotals: CostUsageCodexTotals? = nil
+ ) -> CodexParseResult {
+ var currentModel = initialModel
+ var previousTotals = initialTotals
+ var sessionId: String?
+
+ var days: [String: [String: [Int]]] = [:]
+
+ func add(dayKey: String, model: String, input: Int, cached: Int, output: Int) {
+ guard
+ CostUsageDayRange.isInRange(
+ dayKey: dayKey, since: range.scanSinceKey, until: range.scanUntilKey)
+ else { return }
+ let normModel = CostUsagePricing.normalizeCodexModel(model)
+
+ var dayModels = days[dayKey] ?? [:]
+ var packed = dayModels[normModel] ?? [0, 0, 0]
+ packed[0] = (packed[safe: 0] ?? 0) + input
+ packed[1] = (packed[safe: 1] ?? 0) + cached
+ packed[2] = (packed[safe: 2] ?? 0) + output
+ dayModels[normModel] = packed
+ days[dayKey] = dayModels
}
- private static func listCodexSessionFilesByDatePartition(
- root: URL,
- scanSinceKey: String,
- scanUntilKey: String) -> [URL]
- {
- guard FileManager.default.fileExists(atPath: root.path) else { return [] }
- var out: [URL] = []
- var date = Self.parseDayKey(scanSinceKey) ?? Date()
- let untilDate = Self.parseDayKey(scanUntilKey) ?? date
-
- while date <= untilDate {
- let comps = Calendar.current.dateComponents([.year, .month, .day], from: date)
- let y = String(format: "%04d", comps.year ?? 1970)
- let m = String(format: "%02d", comps.month ?? 1)
- let d = String(format: "%02d", comps.day ?? 1)
-
- let dayDir = root.appendingPathComponent(y, isDirectory: true)
- .appendingPathComponent(m, isDirectory: true)
- .appendingPathComponent(d, isDirectory: true)
-
- if let items = try? FileManager.default.contentsOfDirectory(
- at: dayDir,
- includingPropertiesForKeys: [.isRegularFileKey],
- options: [.skipsHiddenFiles])
- {
- for item in items where item.pathExtension.lowercased() == "jsonl" {
- out.append(item)
- }
+ let maxLineBytes = 256 * 1024
+ let prefixBytes = 32 * 1024
+
+ let parsedBytes =
+ (try? CostUsageJsonl.scan(
+ fileURL: fileURL,
+ offset: startOffset,
+ maxLineBytes: maxLineBytes,
+ prefixBytes: prefixBytes,
+ onLine: { line in
+ guard !line.bytes.isEmpty else { return }
+ guard !line.wasTruncated else { return }
+
+ guard
+ line.bytes.containsAscii(#""type":"event_msg""#)
+ || line.bytes.containsAscii(#""type":"turn_context""#)
+ || line.bytes.containsAscii(#""type":"session_meta""#)
+ else { return }
+
+ if line.bytes.containsAscii(#""type":"event_msg""#),
+ !line.bytes.containsAscii(#""token_count""#)
+ {
+ return
+ }
+
+ guard
+ let obj = (try? JSONSerialization.jsonObject(with: line.bytes)) as? [String: Any],
+ let type = obj["type"] as? String
+ else { return }
+
+ if type == "session_meta" {
+ if sessionId == nil {
+ let payload = obj["payload"] as? [String: Any]
+ sessionId =
+ payload?["session_id"] as? String
+ ?? payload?["sessionId"] as? String
+ ?? payload?["id"] as? String
+ ?? obj["session_id"] as? String
+ ?? obj["sessionId"] as? String
+ ?? obj["id"] as? String
}
-
- date = Calendar.current.date(byAdding: .day, value: 1, to: date) ?? untilDate.addingTimeInterval(1)
- }
-
- return out
- }
-
- private static func listCodexSessionFilesFlat(root: URL, scanSinceKey: String, scanUntilKey: String) -> [URL] {
- guard FileManager.default.fileExists(atPath: root.path) else { return [] }
- guard let items = try? FileManager.default.contentsOfDirectory(
- at: root,
- includingPropertiesForKeys: [.isRegularFileKey],
- options: [.skipsHiddenFiles, .skipsPackageDescendants])
- else { return [] }
-
- var out: [URL] = []
- for item in items where item.pathExtension.lowercased() == "jsonl" {
- if let dayKey = Self.dayKeyFromFilename(item.lastPathComponent) {
- if !CostUsageDayRange.isInRange(dayKey: dayKey, since: scanSinceKey, until: scanUntilKey) {
- continue
- }
+ return
+ }
+
+ guard let tsText = obj["timestamp"] as? String else { return }
+ guard let dayKey = Self.dayKeyFromTimestamp(tsText) ?? Self.dayKeyFromParsedISO(tsText)
+ else { return }
+
+ if type == "turn_context" {
+ if let payload = obj["payload"] as? [String: Any] {
+ if let model = payload["model"] as? String {
+ currentModel = model
+ } else if let info = payload["info"] as? [String: Any],
+ let model = info["model"] as? String
+ {
+ currentModel = model
+ }
}
- out.append(item)
- }
- return out
+ return
+ }
+
+ guard type == "event_msg" else { return }
+ guard let payload = obj["payload"] as? [String: Any] else { return }
+ guard (payload["type"] as? String) == "token_count" else { return }
+
+ let info = payload["info"] as? [String: Any]
+ let modelFromInfo =
+ info?["model"] as? String
+ ?? info?["model_name"] as? String
+ ?? payload["model"] as? String
+ ?? obj["model"] as? String
+ let model = modelFromInfo ?? currentModel ?? "gpt-5"
+
+ func toInt(_ v: Any?) -> Int {
+ if let n = v as? NSNumber { return n.intValue }
+ return 0
+ }
+
+ let total = (info?["total_token_usage"] as? [String: Any])
+ let last = (info?["last_token_usage"] as? [String: Any])
+
+ var deltaInput = 0
+ var deltaCached = 0
+ var deltaOutput = 0
+
+ if let total {
+ let input = toInt(total["input_tokens"])
+ let cached = toInt(total["cached_input_tokens"] ?? total["cache_read_input_tokens"])
+ let output = toInt(total["output_tokens"])
+
+ let prev = previousTotals
+ deltaInput = max(0, input - (prev?.input ?? 0))
+ deltaCached = max(0, cached - (prev?.cached ?? 0))
+ deltaOutput = max(0, output - (prev?.output ?? 0))
+ previousTotals = CostUsageCodexTotals(input: input, cached: cached, output: output)
+ } else if let last {
+ deltaInput = max(0, toInt(last["input_tokens"]))
+ deltaCached = max(
+ 0, toInt(last["cached_input_tokens"] ?? last["cache_read_input_tokens"]))
+ deltaOutput = max(0, toInt(last["output_tokens"]))
+ } else {
+ return
+ }
+
+ if deltaInput == 0, deltaCached == 0, deltaOutput == 0 { return }
+ let cachedClamp = min(deltaCached, deltaInput)
+ add(
+ dayKey: dayKey, model: model, input: deltaInput, cached: cachedClamp,
+ output: deltaOutput)
+ })) ?? startOffset
+
+ return CodexParseResult(
+ days: days,
+ parsedBytes: parsedBytes,
+ lastModel: currentModel,
+ lastTotals: previousTotals,
+ sessionId: sessionId)
+ }
+
+ private static func scanCodexFile(
+ fileURL: URL,
+ range: CostUsageDayRange,
+ cache: inout CostUsageCache,
+ state: inout CodexScanState
+ ) {
+ let path = fileURL.path
+ let attrs = (try? FileManager.default.attributesOfItem(atPath: path)) ?? [:]
+ let mtime = (attrs[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0
+ let size = (attrs[.size] as? NSNumber)?.int64Value ?? 0
+ let mtimeMs = Int64(mtime * 1000)
+ let fileId = Self.fileIdentityString(fileURL: fileURL)
+
+ func dropCachedFile(_ cached: CostUsageFileUsage?) {
+ if let cached {
+ Self.applyFileDays(cache: &cache, fileDays: cached.days, sign: -1)
+ }
+ cache.files.removeValue(forKey: path)
}
- private static let codexFilenameDateRegex = try? NSRegularExpression(pattern: "(\\d{4}-\\d{2}-\\d{2})")
-
- private static func dayKeyFromFilename(_ filename: String) -> String? {
- guard let regex = self.codexFilenameDateRegex else { return nil }
- let range = NSRange(filename.startIndex.. String? {
- guard let values = try? fileURL.resourceValues(forKeys: [.fileResourceIdentifierKey]) else { return nil }
- guard let identifier = values.fileResourceIdentifier else { return nil }
- if let data = identifier as? Data {
- return data.base64EncodedString()
- }
- return String(describing: identifier)
+ let cached = cache.files[path]
+ if let cachedSessionId = cached?.sessionId, state.seenSessionIds.contains(cachedSessionId) {
+ dropCachedFile(cached)
+ return
}
- static func parseCodexFile(
- fileURL: URL,
- range: CostUsageDayRange,
- startOffset: Int64 = 0,
- initialModel: String? = nil,
- initialTotals: CostUsageCodexTotals? = nil) -> CodexParseResult
+ let needsSessionId = cached != nil && cached?.sessionId == nil
+ if let cached,
+ cached.mtimeUnixMs == mtimeMs,
+ cached.size == size,
+ !needsSessionId
{
- var currentModel = initialModel
- var previousTotals = initialTotals
- var sessionId: String?
-
- var days: [String: [String: [Int]]] = [:]
-
- func add(dayKey: String, model: String, input: Int, cached: Int, output: Int) {
- guard CostUsageDayRange.isInRange(dayKey: dayKey, since: range.scanSinceKey, until: range.scanUntilKey)
- else { return }
- let normModel = CostUsagePricing.normalizeCodexModel(model)
-
- var dayModels = days[dayKey] ?? [:]
- var packed = dayModels[normModel] ?? [0, 0, 0]
- packed[0] = (packed[safe: 0] ?? 0) + input
- packed[1] = (packed[safe: 1] ?? 0) + cached
- packed[2] = (packed[safe: 2] ?? 0) + output
- dayModels[normModel] = packed
- days[dayKey] = dayModels
- }
-
- let maxLineBytes = 256 * 1024
- let prefixBytes = 32 * 1024
-
- let parsedBytes = (try? CostUsageJsonl.scan(
- fileURL: fileURL,
- offset: startOffset,
- maxLineBytes: maxLineBytes,
- prefixBytes: prefixBytes,
- onLine: { line in
- guard !line.bytes.isEmpty else { return }
- guard !line.wasTruncated else { return }
-
- guard
- line.bytes.containsAscii(#""type":"event_msg""#)
- || line.bytes.containsAscii(#""type":"turn_context""#)
- || line.bytes.containsAscii(#""type":"session_meta""#)
- else { return }
-
- if line.bytes.containsAscii(#""type":"event_msg""#), !line.bytes.containsAscii(#""token_count""#) {
- return
- }
-
- guard
- let obj = (try? JSONSerialization.jsonObject(with: line.bytes)) as? [String: Any],
- let type = obj["type"] as? String
- else { return }
-
- if type == "session_meta" {
- if sessionId == nil {
- let payload = obj["payload"] as? [String: Any]
- sessionId = payload?["session_id"] as? String
- ?? payload?["sessionId"] as? String
- ?? payload?["id"] as? String
- ?? obj["session_id"] as? String
- ?? obj["sessionId"] as? String
- ?? obj["id"] as? String
- }
- return
- }
-
- guard let tsText = obj["timestamp"] as? String else { return }
- guard let dayKey = Self.dayKeyFromTimestamp(tsText) ?? Self.dayKeyFromParsedISO(tsText) else { return }
-
- if type == "turn_context" {
- if let payload = obj["payload"] as? [String: Any] {
- if let model = payload["model"] as? String {
- currentModel = model
- } else if let info = payload["info"] as? [String: Any], let model = info["model"] as? String {
- currentModel = model
- }
- }
- return
- }
-
- guard type == "event_msg" else { return }
- guard let payload = obj["payload"] as? [String: Any] else { return }
- guard (payload["type"] as? String) == "token_count" else { return }
-
- let info = payload["info"] as? [String: Any]
- let modelFromInfo = info?["model"] as? String
- ?? info?["model_name"] as? String
- ?? payload["model"] as? String
- ?? obj["model"] as? String
- let model = modelFromInfo ?? currentModel ?? "gpt-5"
-
- func toInt(_ v: Any?) -> Int {
- if let n = v as? NSNumber { return n.intValue }
- return 0
- }
-
- let total = (info?["total_token_usage"] as? [String: Any])
- let last = (info?["last_token_usage"] as? [String: Any])
-
- var deltaInput = 0
- var deltaCached = 0
- var deltaOutput = 0
-
- if let total {
- let input = toInt(total["input_tokens"])
- let cached = toInt(total["cached_input_tokens"] ?? total["cache_read_input_tokens"])
- let output = toInt(total["output_tokens"])
-
- let prev = previousTotals
- deltaInput = max(0, input - (prev?.input ?? 0))
- deltaCached = max(0, cached - (prev?.cached ?? 0))
- deltaOutput = max(0, output - (prev?.output ?? 0))
- previousTotals = CostUsageCodexTotals(input: input, cached: cached, output: output)
- } else if let last {
- deltaInput = max(0, toInt(last["input_tokens"]))
- deltaCached = max(0, toInt(last["cached_input_tokens"] ?? last["cache_read_input_tokens"]))
- deltaOutput = max(0, toInt(last["output_tokens"]))
- } else {
- return
- }
-
- if deltaInput == 0, deltaCached == 0, deltaOutput == 0 { return }
- let cachedClamp = min(deltaCached, deltaInput)
- add(dayKey: dayKey, model: model, input: deltaInput, cached: cachedClamp, output: deltaOutput)
- })) ?? startOffset
-
- return CodexParseResult(
- days: days,
- parsedBytes: parsedBytes,
- lastModel: currentModel,
- lastTotals: previousTotals,
- sessionId: sessionId)
+ if let cachedSessionId = cached.sessionId {
+ state.seenSessionIds.insert(cachedSessionId)
+ }
+ if let fileId {
+ state.seenFileIds.insert(fileId)
+ }
+ return
}
- private static func scanCodexFile(
- fileURL: URL,
- range: CostUsageDayRange,
- cache: inout CostUsageCache,
- state: inout CodexScanState)
- {
- let path = fileURL.path
- let attrs = (try? FileManager.default.attributesOfItem(atPath: path)) ?? [:]
- let mtime = (attrs[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0
- let size = (attrs[.size] as? NSNumber)?.int64Value ?? 0
- let mtimeMs = Int64(mtime * 1000)
- let fileId = Self.fileIdentityString(fileURL: fileURL)
-
- func dropCachedFile(_ cached: CostUsageFileUsage?) {
- if let cached {
- Self.applyFileDays(cache: &cache, fileDays: cached.days, sign: -1)
- }
- cache.files.removeValue(forKey: path)
- }
-
- if let fileId, state.seenFileIds.contains(fileId) {
- dropCachedFile(cache.files[path])
- return
- }
-
- let cached = cache.files[path]
- if let cachedSessionId = cached?.sessionId, state.seenSessionIds.contains(cachedSessionId) {
- dropCachedFile(cached)
- return
- }
-
- let needsSessionId = cached != nil && cached?.sessionId == nil
- if let cached,
- cached.mtimeUnixMs == mtimeMs,
- cached.size == size,
- !needsSessionId
- {
- if let cachedSessionId = cached.sessionId {
- state.seenSessionIds.insert(cachedSessionId)
- }
- if let fileId {
- state.seenFileIds.insert(fileId)
- }
- return
- }
-
- if let cached, cached.sessionId != nil {
- let startOffset = cached.parsedBytes ?? cached.size
- let canIncremental = size > cached.size && startOffset > 0 && startOffset <= size
- && cached.lastTotals != nil
- if canIncremental {
- let delta = Self.parseCodexFile(
- fileURL: fileURL,
- range: range,
- startOffset: startOffset,
- initialModel: cached.lastModel,
- initialTotals: cached.lastTotals)
- let sessionId = delta.sessionId ?? cached.sessionId
- if let sessionId, state.seenSessionIds.contains(sessionId) {
- dropCachedFile(cached)
- return
- }
-
- if !delta.days.isEmpty {
- Self.applyFileDays(cache: &cache, fileDays: delta.days, sign: 1)
- }
-
- var mergedDays = cached.days
- Self.mergeFileDays(existing: &mergedDays, delta: delta.days)
- cache.files[path] = Self.makeFileUsage(
- mtimeUnixMs: mtimeMs,
- size: size,
- days: mergedDays,
- parsedBytes: delta.parsedBytes,
- lastModel: delta.lastModel,
- lastTotals: delta.lastTotals,
- sessionId: sessionId)
- if let sessionId {
- state.seenSessionIds.insert(sessionId)
- }
- if let fileId {
- state.seenFileIds.insert(fileId)
- }
- return
- }
+ if let cached, cached.sessionId != nil {
+ let startOffset = cached.parsedBytes ?? cached.size
+ let canIncremental =
+ size > cached.size && startOffset > 0 && startOffset <= size
+ && cached.lastTotals != nil
+ if canIncremental {
+ let delta = Self.parseCodexFile(
+ fileURL: fileURL,
+ range: range,
+ startOffset: startOffset,
+ initialModel: cached.lastModel,
+ initialTotals: cached.lastTotals)
+ let sessionId = delta.sessionId ?? cached.sessionId
+ if let sessionId, state.seenSessionIds.contains(sessionId) {
+ dropCachedFile(cached)
+ return
}
- if let cached {
- Self.applyFileDays(cache: &cache, fileDays: cached.days, sign: -1)
+ if !delta.days.isEmpty {
+ Self.applyFileDays(cache: &cache, fileDays: delta.days, sign: 1)
}
- let parsed = Self.parseCodexFile(fileURL: fileURL, range: range)
- let sessionId = parsed.sessionId ?? cached?.sessionId
- if let sessionId, state.seenSessionIds.contains(sessionId) {
- cache.files.removeValue(forKey: path)
- return
- }
-
- let usage = Self.makeFileUsage(
- mtimeUnixMs: mtimeMs,
- size: size,
- days: parsed.days,
- parsedBytes: parsed.parsedBytes,
- lastModel: parsed.lastModel,
- lastTotals: parsed.lastTotals,
- sessionId: sessionId)
- cache.files[path] = usage
- Self.applyFileDays(cache: &cache, fileDays: usage.days, sign: 1)
+ var mergedDays = cached.days
+ Self.mergeFileDays(existing: &mergedDays, delta: delta.days)
+ cache.files[path] = Self.makeFileUsage(
+ mtimeUnixMs: mtimeMs,
+ size: size,
+ days: mergedDays,
+ parsedBytes: delta.parsedBytes,
+ lastModel: delta.lastModel,
+ lastTotals: delta.lastTotals,
+ sessionId: sessionId)
if let sessionId {
- state.seenSessionIds.insert(sessionId)
+ state.seenSessionIds.insert(sessionId)
}
if let fileId {
- state.seenFileIds.insert(fileId)
+ state.seenFileIds.insert(fileId)
}
+ return
+ }
}
- private static func loadCodexDaily(range: CostUsageDayRange, now: Date, options: Options) -> CostUsageDailyReport {
- var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: options.cacheRoot)
- let nowMs = Int64(now.timeIntervalSince1970 * 1000)
-
- let refreshMs = Int64(max(0, options.refreshMinIntervalSeconds) * 1000)
- let shouldRefresh = refreshMs == 0 || cache.lastScanUnixMs == 0 || nowMs - cache.lastScanUnixMs > refreshMs
-
- let roots = self.codexSessionsRoots(options: options)
- var seenPaths: Set = []
- var files: [URL] = []
- for root in roots {
- let rootFiles = Self.listCodexSessionFiles(
- root: root,
- scanSinceKey: range.scanSinceKey,
- scanUntilKey: range.scanUntilKey)
- for fileURL in rootFiles.sorted(by: { $0.path < $1.path }) where !seenPaths.contains(fileURL.path) {
- seenPaths.insert(fileURL.path)
- files.append(fileURL)
- }
- }
- let filePathsInScan = Set(files.map(\.path))
-
- if shouldRefresh {
- if options.forceRescan {
- cache = CostUsageCache()
- }
- var scanState = CodexScanState()
- for fileURL in files {
- Self.scanCodexFile(
- fileURL: fileURL,
- range: range,
- cache: &cache,
- state: &scanState)
- }
-
- for key in cache.files.keys where !filePathsInScan.contains(key) {
- if let old = cache.files[key] {
- Self.applyFileDays(cache: &cache, fileDays: old.days, sign: -1)
- }
- cache.files.removeValue(forKey: key)
- }
-
- Self.pruneDays(cache: &cache, sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey)
- cache.lastScanUnixMs = nowMs
- CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: options.cacheRoot)
- }
-
- return Self.buildCodexReportFromCache(cache: cache, range: range)
+ if let cached {
+ Self.applyFileDays(cache: &cache, fileDays: cached.days, sign: -1)
}
- private static func buildCodexReportFromCache(
- cache: CostUsageCache,
- range: CostUsageDayRange) -> CostUsageDailyReport
- {
- var entries: [CostUsageDailyReport.Entry] = []
- var totalInput = 0
- var totalOutput = 0
- var totalTokens = 0
- var totalCost: Double = 0
- var costSeen = false
-
- let dayKeys = cache.days.keys.sorted().filter {
- CostUsageDayRange.isInRange(dayKey: $0, since: range.sinceKey, until: range.untilKey)
- }
-
- for day in dayKeys {
- guard let models = cache.days[day] else { continue }
- let modelNames = models.keys.sorted()
-
- var dayInput = 0
- var dayOutput = 0
-
- var breakdown: [CostUsageDailyReport.ModelBreakdown] = []
- var dayCost: Double = 0
- var dayCostSeen = false
-
- for model in modelNames {
- let packed = models[model] ?? [0, 0, 0]
- let input = packed[safe: 0] ?? 0
- let cached = packed[safe: 1] ?? 0
- let output = packed[safe: 2] ?? 0
- let totalTokens = input + output
-
- dayInput += input
- dayOutput += output
-
- let cost = CostUsagePricing.codexCostUSD(
- model: model,
- inputTokens: input,
- cachedInputTokens: cached,
- outputTokens: output)
- breakdown.append(
- CostUsageDailyReport.ModelBreakdown(
- modelName: model,
- costUSD: cost,
- totalTokens: totalTokens))
- if let cost {
- dayCost += cost
- dayCostSeen = true
- }
- }
+ let parsed = Self.parseCodexFile(fileURL: fileURL, range: range)
+ let sessionId = parsed.sessionId ?? cached?.sessionId
+ if let sessionId, state.seenSessionIds.contains(sessionId) {
+ cache.files.removeValue(forKey: path)
+ return
+ }
- let sortedBreakdown = Self.sortedModelBreakdowns(breakdown)
-
- let dayTotal = dayInput + dayOutput
- let entryCost = dayCostSeen ? dayCost : nil
- entries.append(CostUsageDailyReport.Entry(
- date: day,
- inputTokens: dayInput,
- outputTokens: dayOutput,
- totalTokens: dayTotal,
- costUSD: entryCost,
- modelsUsed: modelNames,
- modelBreakdowns: sortedBreakdown))
-
- totalInput += dayInput
- totalOutput += dayOutput
- totalTokens += dayTotal
- if let entryCost {
- totalCost += entryCost
- costSeen = true
- }
+ let usage = Self.makeFileUsage(
+ mtimeUnixMs: mtimeMs,
+ size: size,
+ days: parsed.days,
+ parsedBytes: parsed.parsedBytes,
+ lastModel: parsed.lastModel,
+ lastTotals: parsed.lastTotals,
+ sessionId: sessionId)
+ cache.files[path] = usage
+ Self.applyFileDays(cache: &cache, fileDays: usage.days, sign: 1)
+ if let sessionId {
+ state.seenSessionIds.insert(sessionId)
+ }
+ if let fileId {
+ state.seenFileIds.insert(fileId)
+ }
+ }
+
+ private static func loadCodexDaily(range: CostUsageDayRange, now: Date, options: Options)
+ -> CostUsageDailyReport
+ {
+ var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: options.cacheRoot)
+ let nowMs = Int64(now.timeIntervalSince1970 * 1000)
+
+ let refreshMs = Int64(max(0, options.refreshMinIntervalSeconds) * 1000)
+ let shouldRefresh =
+ refreshMs == 0 || cache.lastScanUnixMs == 0 || nowMs - cache.lastScanUnixMs > refreshMs
+
+ let roots = self.codexSessionsRoots(options: options)
+ var seenPaths: Set = []
+ var files: [URL] = []
+ for root in roots {
+ let rootFiles = Self.listCodexSessionFiles(
+ root: root,
+ scanSinceKey: range.scanSinceKey,
+ scanUntilKey: range.scanUntilKey)
+ for fileURL in rootFiles.sorted(by: { $0.path < $1.path })
+ where !seenPaths.contains(fileURL.path) {
+ seenPaths.insert(fileURL.path)
+ files.append(fileURL)
+ }
+ }
+ let filePathsInScan = Set(files.map(\.path))
+
+ if shouldRefresh {
+ if options.forceRescan {
+ cache = CostUsageCache()
+ }
+ var scanState = CodexScanState()
+ for fileURL in files {
+ Self.scanCodexFile(
+ fileURL: fileURL,
+ range: range,
+ cache: &cache,
+ state: &scanState)
+ }
+
+ for key in cache.files.keys where !filePathsInScan.contains(key) {
+ if let old = cache.files[key] {
+ Self.applyFileDays(cache: &cache, fileDays: old.days, sign: -1)
}
+ cache.files.removeValue(forKey: key)
+ }
- let summary: CostUsageDailyReport.Summary? = entries.isEmpty
- ? nil
- : CostUsageDailyReport.Summary(
- totalInputTokens: totalInput,
- totalOutputTokens: totalOutput,
- totalTokens: totalTokens,
- totalCostUSD: costSeen ? totalCost : nil)
-
- return CostUsageDailyReport(data: entries, summary: summary)
+ Self.pruneDays(cache: &cache, sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey)
+ cache.lastScanUnixMs = nowMs
+ CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: options.cacheRoot)
}
- // MARK: - Shared cache mutations
-
- static func makeFileUsage(
- mtimeUnixMs: Int64,
- size: Int64,
- days: [String: [String: [Int]]],
- parsedBytes: Int64?,
- lastModel: String? = nil,
- lastTotals: CostUsageCodexTotals? = nil,
- sessionId: String? = nil) -> CostUsageFileUsage
- {
- CostUsageFileUsage(
- mtimeUnixMs: mtimeUnixMs,
- size: size,
- days: days,
- parsedBytes: parsedBytes,
- lastModel: lastModel,
- lastTotals: lastTotals,
- sessionId: sessionId)
+ return Self.buildCodexReportFromCache(cache: cache, range: range)
+ }
+
+ private static func buildCodexReportFromCache(
+ cache: CostUsageCache,
+ range: CostUsageDayRange
+ ) -> CostUsageDailyReport {
+ var entries: [CostUsageDailyReport.Entry] = []
+ var totalInput = 0
+ var totalOutput = 0
+ var totalTokens = 0
+ var totalCost: Double = 0
+ var costSeen = false
+
+ let dayKeys = cache.days.keys.sorted().filter {
+ CostUsageDayRange.isInRange(dayKey: $0, since: range.sinceKey, until: range.untilKey)
}
- static func mergeFileDays(
- existing: inout [String: [String: [Int]]],
- delta: [String: [String: [Int]]])
- {
- for (day, models) in delta {
- var dayModels = existing[day] ?? [:]
- for (model, packed) in models {
- let existingPacked = dayModels[model] ?? []
- let merged = Self.addPacked(a: existingPacked, b: packed, sign: 1)
- if merged.allSatisfy({ $0 == 0 }) {
- dayModels.removeValue(forKey: model)
- } else {
- dayModels[model] = merged
- }
- }
-
- if dayModels.isEmpty {
- existing.removeValue(forKey: day)
- } else {
- existing[day] = dayModels
- }
+ for day in dayKeys {
+ guard let models = cache.days[day] else { continue }
+ let modelNames = models.keys.sorted()
+
+ var dayInput = 0
+ var dayOutput = 0
+
+ var breakdown: [CostUsageDailyReport.ModelBreakdown] = []
+ var dayCost: Double = 0
+ var dayCostSeen = false
+
+ for model in modelNames {
+ let packed = models[model] ?? [0, 0, 0]
+ let input = packed[safe: 0] ?? 0
+ let cached = packed[safe: 1] ?? 0
+ let output = packed[safe: 2] ?? 0
+ let totalTokens = input + output
+
+ dayInput += input
+ dayOutput += output
+
+ let cost = CostUsagePricing.codexCostUSD(
+ model: model,
+ inputTokens: input,
+ cachedInputTokens: cached,
+ outputTokens: output)
+ breakdown.append(
+ CostUsageDailyReport.ModelBreakdown(
+ modelName: model,
+ costUSD: cost,
+ totalTokens: totalTokens))
+ if let cost {
+ dayCost += cost
+ dayCostSeen = true
}
+ }
+
+ let sortedBreakdown = Self.sortedModelBreakdowns(breakdown)
+
+ let dayTotal = dayInput + dayOutput
+ let entryCost = dayCostSeen ? dayCost : nil
+ entries.append(
+ CostUsageDailyReport.Entry(
+ date: day,
+ inputTokens: dayInput,
+ outputTokens: dayOutput,
+ totalTokens: dayTotal,
+ costUSD: entryCost,
+ modelsUsed: modelNames,
+ modelBreakdowns: sortedBreakdown))
+
+ totalInput += dayInput
+ totalOutput += dayOutput
+ totalTokens += dayTotal
+ if let entryCost {
+ totalCost += entryCost
+ costSeen = true
+ }
}
- static func applyFileDays(cache: inout CostUsageCache, fileDays: [String: [String: [Int]]], sign: Int) {
- for (day, models) in fileDays {
- var dayModels = cache.days[day] ?? [:]
- for (model, packed) in models {
- let existing = dayModels[model] ?? []
- let merged = Self.addPacked(a: existing, b: packed, sign: sign)
- if merged.allSatisfy({ $0 == 0 }) {
- dayModels.removeValue(forKey: model)
- } else {
- dayModels[model] = merged
- }
- }
-
- if dayModels.isEmpty {
- cache.days.removeValue(forKey: day)
- } else {
- cache.days[day] = dayModels
- }
+ let summary: CostUsageDailyReport.Summary? =
+ entries.isEmpty
+ ? nil
+ : CostUsageDailyReport.Summary(
+ totalInputTokens: totalInput,
+ totalOutputTokens: totalOutput,
+ totalTokens: totalTokens,
+ totalCostUSD: costSeen ? totalCost : nil)
+
+ return CostUsageDailyReport(data: entries, summary: summary)
+ }
+
+ // MARK: - Shared cache mutations
+
+ static func makeFileUsage(
+ mtimeUnixMs: Int64,
+ size: Int64,
+ days: [String: [String: [Int]]],
+ parsedBytes: Int64?,
+ lastModel: String? = nil,
+ lastTotals: CostUsageCodexTotals? = nil,
+ sessionId: String? = nil
+ ) -> CostUsageFileUsage {
+ CostUsageFileUsage(
+ mtimeUnixMs: mtimeUnixMs,
+ size: size,
+ days: days,
+ parsedBytes: parsedBytes,
+ lastModel: lastModel,
+ lastTotals: lastTotals,
+ sessionId: sessionId)
+ }
+
+ static func mergeFileDays(
+ existing: inout [String: [String: [Int]]],
+ delta: [String: [String: [Int]]]
+ ) {
+ for (day, models) in delta {
+ var dayModels = existing[day] ?? [:]
+ for (model, packed) in models {
+ let existingPacked = dayModels[model] ?? []
+ let merged = Self.addPacked(a: existingPacked, b: packed, sign: 1)
+ if merged.allSatisfy({ $0 == 0 }) {
+ dayModels.removeValue(forKey: model)
+ } else {
+ dayModels[model] = merged
}
- }
+ }
- static func pruneDays(cache: inout CostUsageCache, sinceKey: String, untilKey: String) {
- for key in cache.days.keys where !CostUsageDayRange.isInRange(dayKey: key, since: sinceKey, until: untilKey) {
- cache.days.removeValue(forKey: key)
- }
+ if dayModels.isEmpty {
+ existing.removeValue(forKey: day)
+ } else {
+ existing[day] = dayModels
+ }
}
-
- static func addPacked(a: [Int], b: [Int], sign: Int) -> [Int] {
- let len = max(a.count, b.count)
- var out: [Int] = Array(repeating: 0, count: len)
- for idx in 0.. [CostUsageDailyReport.ModelBreakdown]
- {
- breakdowns.sorted { lhs, rhs in
- let lhsCost = lhs.costUSD ?? -1
- let rhsCost = rhs.costUSD ?? -1
- if lhsCost != rhsCost {
- return lhsCost > rhsCost
- }
-
- let lhsTokens = lhs.totalTokens ?? -1
- let rhsTokens = rhs.totalTokens ?? -1
- if lhsTokens != rhsTokens {
- return lhsTokens > rhsTokens
- }
+ }
- return lhs.modelName > rhs.modelName
- }
+ if dayModels.isEmpty {
+ cache.days.removeValue(forKey: day)
+ } else {
+ cache.days[day] = dayModels
+ }
}
+ }
- // MARK: - Date parsing
-
- private static func parseDayKey(_ key: String) -> Date? {
- let parts = key.split(separator: "-")
- guard parts.count == 3 else { return nil }
- guard
- let y = Int(parts[0]),
- let m = Int(parts[1]),
- let d = Int(parts[2])
- else { return nil }
-
- var comps = DateComponents()
- comps.calendar = Calendar.current
- comps.timeZone = TimeZone.current
- comps.year = y
- comps.month = m
- comps.day = d
- comps.hour = 12
- return comps.date
+ static func pruneDays(cache: inout CostUsageCache, sinceKey: String, untilKey: String) {
+ for key in cache.days.keys
+ where !CostUsageDayRange.isInRange(dayKey: key, since: sinceKey, until: untilKey) {
+ cache.days.removeValue(forKey: key)
+ }
+ }
+
+ static func addPacked(a: [Int], b: [Int], sign: Int) -> [Int] {
+ let len = max(a.count, b.count)
+ var out: [Int] = Array(repeating: 0, count: len)
+ for idx in 0.. [CostUsageDailyReport.ModelBreakdown]
+ {
+ breakdowns.sorted { lhs, rhs in
+ let lhsCost = lhs.costUSD ?? -1
+ let rhsCost = rhs.costUSD ?? -1
+ if lhsCost != rhsCost {
+ return lhsCost > rhsCost
+ }
+
+ let lhsTokens = lhs.totalTokens ?? -1
+ let rhsTokens = rhs.totalTokens ?? -1
+ if lhsTokens != rhsTokens {
+ return lhsTokens > rhsTokens
+ }
+
+ return lhs.modelName > rhs.modelName
}
+ }
+
+ // MARK: - Date parsing
+
+ private static func parseDayKey(_ key: String) -> Date? {
+ let parts = key.split(separator: "-")
+ guard parts.count == 3 else { return nil }
+ guard
+ let y = Int(parts[0]),
+ let m = Int(parts[1]),
+ let d = Int(parts[2])
+ else { return nil }
+
+ var comps = DateComponents()
+ comps.calendar = Calendar.current
+ comps.timeZone = TimeZone.current
+ comps.year = y
+ comps.month = m
+ comps.day = d
+ comps.hour = 12
+ return comps.date
+ }
}
extension Data {
- func containsAscii(_ needle: String) -> Bool {
- guard let n = needle.data(using: .utf8) else { return false }
- return self.range(of: n) != nil
- }
+ func containsAscii(_ needle: String) -> Bool {
+ guard let n = needle.data(using: .utf8) else { return false }
+ return self.range(of: n) != nil
+ }
}
extension [Int] {
- subscript(safe index: Int) -> Int? {
- if index < 0 { return nil }
- if index >= self.count { return nil }
- return self[index]
- }
+ subscript(safe index: Int) -> Int? {
+ if index < 0 { return nil }
+ if index >= self.count { return nil }
+ return self[index]
+ }
}
extension [UInt8] {
- subscript(safe index: Int) -> UInt8? {
- if index < 0 { return nil }
- if index >= self.count { return nil }
- return self[index]
- }
+ subscript(safe index: Int) -> UInt8? {
+ if index < 0 { return nil }
+ if index >= self.count { return nil }
+ return self[index]
+ }
}
diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
index c0e600fa9..b38d2c9e7 100644
--- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
+++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
@@ -4,296 +4,305 @@ import SwiftUI
import WidgetKit
enum ProviderChoice: String, AppEnum {
- case codex
- case claude
- case gemini
- case alibaba
- case antigravity
- case zai
- case copilot
- case minimax
- case kilo
- case opencode
-
- static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Provider")
-
- static let caseDisplayRepresentations: [ProviderChoice: DisplayRepresentation] = [
- .codex: DisplayRepresentation(title: "Codex"),
- .claude: DisplayRepresentation(title: "Claude"),
- .gemini: DisplayRepresentation(title: "Gemini"),
- .alibaba: DisplayRepresentation(title: "Alibaba"),
- .antigravity: DisplayRepresentation(title: "Antigravity"),
- .zai: DisplayRepresentation(title: "z.ai"),
- .copilot: DisplayRepresentation(title: "Copilot"),
- .minimax: DisplayRepresentation(title: "MiniMax"),
- .kilo: DisplayRepresentation(title: "Kilo"),
- .opencode: DisplayRepresentation(title: "OpenCode"),
- ]
-
- var provider: UsageProvider {
- switch self {
- case .codex: .codex
- case .claude: .claude
- case .gemini: .gemini
- case .alibaba: .alibaba
- case .antigravity: .antigravity
- case .zai: .zai
- case .copilot: .copilot
- case .minimax: .minimax
- case .kilo: .kilo
- case .opencode: .opencode
- }
+ case codex
+ case claude
+ case gemini
+ case alibaba
+ case antigravity
+ case zai
+ case copilot
+ case minimax
+ case kilo
+ case opencode
+
+ static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Provider")
+
+ static let caseDisplayRepresentations: [ProviderChoice: DisplayRepresentation] = [
+ .codex: DisplayRepresentation(title: "Codex"),
+ .claude: DisplayRepresentation(title: "Claude"),
+ .gemini: DisplayRepresentation(title: "Gemini"),
+ .alibaba: DisplayRepresentation(title: "Alibaba"),
+ .antigravity: DisplayRepresentation(title: "Antigravity"),
+ .zai: DisplayRepresentation(title: "z.ai"),
+ .copilot: DisplayRepresentation(title: "Copilot"),
+ .minimax: DisplayRepresentation(title: "MiniMax"),
+ .kilo: DisplayRepresentation(title: "Kilo"),
+ .opencode: DisplayRepresentation(title: "OpenCode"),
+ ]
+
+ var provider: UsageProvider {
+ switch self {
+ case .codex: .codex
+ case .claude: .claude
+ case .gemini: .gemini
+ case .alibaba: .alibaba
+ case .antigravity: .antigravity
+ case .zai: .zai
+ case .copilot: .copilot
+ case .minimax: .minimax
+ case .kilo: .kilo
+ case .opencode: .opencode
}
-
- // swiftlint:disable:next cyclomatic_complexity
- init?(provider: UsageProvider) {
- switch provider {
- case .codex: self = .codex
- case .claude: self = .claude
- case .gemini: self = .gemini
- case .alibaba: self = .alibaba
- case .antigravity: self = .antigravity
- case .cursor: return nil // Cursor not yet supported in widgets
- case .opencode: self = .opencode
- case .zai: self = .zai
- case .factory: return nil // Factory not yet supported in widgets
- case .copilot: self = .copilot
- case .minimax: self = .minimax
- case .vertexai: return nil // Vertex AI not yet supported in widgets
- case .kilo: self = .kilo
- case .kiro: return nil // Kiro not yet supported in widgets
- case .augment: return nil // Augment not yet supported in widgets
- case .jetbrains: return nil // JetBrains not yet supported in widgets
- case .kimi: return nil // Kimi not yet supported in widgets
- case .kimik2: return nil // Kimi K2 not yet supported in widgets
- case .amp: return nil // Amp not yet supported in widgets
- case .ollama: return nil // Ollama not yet supported in widgets
- case .synthetic: return nil // Synthetic not yet supported in widgets
- case .openrouter: return nil // OpenRouter not yet supported in widgets
- case .warp: return nil // Warp not yet supported in widgets
- case .perplexity: return nil // Perplexity not yet supported in widgets
- }
+ }
+
+ // swiftlint:disable:next cyclomatic_complexity
+ init?(provider: UsageProvider) {
+ switch provider {
+ case .codex: self = .codex
+ case .claude: self = .claude
+ case .gemini: self = .gemini
+ case .alibaba: self = .alibaba
+ case .antigravity: self = .antigravity
+ case .cursor: return nil // Cursor not yet supported in widgets
+ case .opencode: self = .opencode
+ case .zai: self = .zai
+ case .factory: return nil // Factory not yet supported in widgets
+ case .copilot: self = .copilot
+ case .minimax: self = .minimax
+ case .vertexai: return nil // Vertex AI not yet supported in widgets
+ case .kilo: self = .kilo
+ case .kiro: return nil // Kiro not yet supported in widgets
+ case .augment: return nil // Augment not yet supported in widgets
+ case .jetbrains: return nil // JetBrains not yet supported in widgets
+ case .kimi: return nil // Kimi not yet supported in widgets
+ case .kimik2: return nil // Kimi K2 not yet supported in widgets
+ case .amp: return nil // Amp not yet supported in widgets
+ case .ollama: return nil // Ollama not yet supported in widgets
+ case .synthetic: return nil // Synthetic not yet supported in widgets
+ case .openrouter: return nil // OpenRouter not yet supported in widgets
+ case .warp: return nil // Warp not yet supported in widgets
+ case .perplexity: return nil // Perplexity not yet supported in widgets
+ case .abacus: return nil // Abacus AI not yet supported in widgets
}
+ }
}
enum CompactMetric: String, AppEnum {
- case credits
- case todayCost
- case last30DaysCost
+ case credits
+ case todayCost
+ case last30DaysCost
- static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Metric")
+ static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Metric")
- static let caseDisplayRepresentations: [CompactMetric: DisplayRepresentation] = [
- .credits: DisplayRepresentation(title: "Credits left"),
- .todayCost: DisplayRepresentation(title: "Today cost"),
- .last30DaysCost: DisplayRepresentation(title: "30d cost"),
- ]
+ static let caseDisplayRepresentations: [CompactMetric: DisplayRepresentation] = [
+ .credits: DisplayRepresentation(title: "Credits left"),
+ .todayCost: DisplayRepresentation(title: "Today cost"),
+ .last30DaysCost: DisplayRepresentation(title: "30d cost"),
+ ]
}
struct ProviderSelectionIntent: AppIntent, WidgetConfigurationIntent {
- static let title: LocalizedStringResource = "Provider"
- static let description = IntentDescription("Select the provider to display in the widget.")
+ static let title: LocalizedStringResource = "Provider"
+ static let description = IntentDescription("Select the provider to display in the widget.")
- @Parameter(title: "Provider")
- var provider: ProviderChoice
+ @Parameter(title: "Provider")
+ var provider: ProviderChoice
- init() {
- self.provider = .codex
- }
+ init() {
+ self.provider = .codex
+ }
}
struct SwitchWidgetProviderIntent: AppIntent {
- static let title: LocalizedStringResource = "Switch Provider"
- static let description = IntentDescription("Switch the provider shown in the widget.")
+ static let title: LocalizedStringResource = "Switch Provider"
+ static let description = IntentDescription("Switch the provider shown in the widget.")
- @Parameter(title: "Provider")
- var provider: ProviderChoice
+ @Parameter(title: "Provider")
+ var provider: ProviderChoice
- init() {}
+ init() {}
- init(provider: ProviderChoice) {
- self.provider = provider
- }
+ init(provider: ProviderChoice) {
+ self.provider = provider
+ }
- func perform() async throws -> some IntentResult {
- WidgetSelectionStore.saveSelectedProvider(self.provider.provider)
- WidgetCenter.shared.reloadAllTimelines()
- return .result()
- }
+ func perform() async throws -> some IntentResult {
+ WidgetSelectionStore.saveSelectedProvider(self.provider.provider)
+ WidgetCenter.shared.reloadAllTimelines()
+ return .result()
+ }
}
struct CompactMetricSelectionIntent: AppIntent, WidgetConfigurationIntent {
- static let title: LocalizedStringResource = "Provider + Metric"
- static let description = IntentDescription("Select the provider and metric to display.")
+ static let title: LocalizedStringResource = "Provider + Metric"
+ static let description = IntentDescription("Select the provider and metric to display.")
- @Parameter(title: "Provider")
- var provider: ProviderChoice
+ @Parameter(title: "Provider")
+ var provider: ProviderChoice
- @Parameter(title: "Metric")
- var metric: CompactMetric
+ @Parameter(title: "Metric")
+ var metric: CompactMetric
- init() {
- self.provider = .codex
- self.metric = .credits
- }
+ init() {
+ self.provider = .codex
+ self.metric = .credits
+ }
}
struct CodexBarWidgetEntry: TimelineEntry {
- let date: Date
- let provider: UsageProvider
- let snapshot: WidgetSnapshot
+ let date: Date
+ let provider: UsageProvider
+ let snapshot: WidgetSnapshot
}
struct CodexBarCompactEntry: TimelineEntry {
- let date: Date
- let provider: UsageProvider
- let metric: CompactMetric
- let snapshot: WidgetSnapshot
+ let date: Date
+ let provider: UsageProvider
+ let metric: CompactMetric
+ let snapshot: WidgetSnapshot
}
struct CodexBarSwitcherEntry: TimelineEntry {
- let date: Date
- let provider: UsageProvider
- let availableProviders: [UsageProvider]
- let snapshot: WidgetSnapshot
+ let date: Date
+ let provider: UsageProvider
+ let availableProviders: [UsageProvider]
+ let snapshot: WidgetSnapshot
}
struct CodexBarTimelineProvider: AppIntentTimelineProvider {
- func placeholder(in context: Context) -> CodexBarWidgetEntry {
- CodexBarWidgetEntry(
- date: Date(),
- provider: .codex,
- snapshot: WidgetPreviewData.snapshot())
- }
-
- func snapshot(for configuration: ProviderSelectionIntent, in context: Context) async -> CodexBarWidgetEntry {
- let provider = configuration.provider.provider
- return CodexBarWidgetEntry(
- date: Date(),
- provider: provider,
- snapshot: WidgetSnapshotStore.load() ?? WidgetPreviewData.snapshot())
- }
-
- func timeline(
- for configuration: ProviderSelectionIntent,
- in context: Context) async -> Timeline
- {
- let provider = configuration.provider.provider
- let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.snapshot()
- let entry = CodexBarWidgetEntry(date: Date(), provider: provider, snapshot: snapshot)
- let refresh = Date().addingTimeInterval(30 * 60)
- return Timeline(entries: [entry], policy: .after(refresh))
- }
+ func placeholder(in context: Context) -> CodexBarWidgetEntry {
+ CodexBarWidgetEntry(
+ date: Date(),
+ provider: .codex,
+ snapshot: WidgetPreviewData.snapshot())
+ }
+
+ func snapshot(for configuration: ProviderSelectionIntent, in context: Context) async
+ -> CodexBarWidgetEntry
+ {
+ let provider = configuration.provider.provider
+ return CodexBarWidgetEntry(
+ date: Date(),
+ provider: provider,
+ snapshot: WidgetSnapshotStore.load() ?? WidgetPreviewData.snapshot())
+ }
+
+ func timeline(
+ for configuration: ProviderSelectionIntent,
+ in context: Context
+ ) async -> Timeline {
+ let provider = configuration.provider.provider
+ let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.snapshot()
+ let entry = CodexBarWidgetEntry(date: Date(), provider: provider, snapshot: snapshot)
+ let refresh = Date().addingTimeInterval(30 * 60)
+ return Timeline(entries: [entry], policy: .after(refresh))
+ }
}
struct CodexBarSwitcherTimelineProvider: TimelineProvider {
- func placeholder(in context: Context) -> CodexBarSwitcherEntry {
- let snapshot = WidgetPreviewData.snapshot()
- let providers = self.availableProviders(from: snapshot)
- return CodexBarSwitcherEntry(
- date: Date(),
- provider: providers.first ?? .codex,
- availableProviders: providers,
- snapshot: snapshot)
- }
-
- func getSnapshot(in context: Context, completion: @escaping (CodexBarSwitcherEntry) -> Void) {
- completion(self.makeEntry())
- }
-
- func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) {
- let entry = self.makeEntry()
- let refresh = Date().addingTimeInterval(30 * 60)
- completion(Timeline(entries: [entry], policy: .after(refresh)))
- }
-
- private func makeEntry() -> CodexBarSwitcherEntry {
- let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.snapshot()
- let providers = self.availableProviders(from: snapshot)
- let stored = WidgetSelectionStore.loadSelectedProvider()
- let selected = providers.first { $0 == stored } ?? providers.first ?? .codex
- if selected != stored {
- WidgetSelectionStore.saveSelectedProvider(selected)
- }
- return CodexBarSwitcherEntry(
- date: Date(),
- provider: selected,
- availableProviders: providers,
- snapshot: snapshot)
- }
-
- private func availableProviders(from snapshot: WidgetSnapshot) -> [UsageProvider] {
- Self.supportedProviders(from: snapshot)
- }
-
- static func supportedProviders(from snapshot: WidgetSnapshot) -> [UsageProvider] {
- let enabled = snapshot.enabledProviders
- let providers = enabled.isEmpty ? snapshot.entries.map(\.provider) : enabled
- let supported = providers.filter { ProviderChoice(provider: $0) != nil }
- return supported.isEmpty ? [.codex] : supported
+ func placeholder(in context: Context) -> CodexBarSwitcherEntry {
+ let snapshot = WidgetPreviewData.snapshot()
+ let providers = self.availableProviders(from: snapshot)
+ return CodexBarSwitcherEntry(
+ date: Date(),
+ provider: providers.first ?? .codex,
+ availableProviders: providers,
+ snapshot: snapshot)
+ }
+
+ func getSnapshot(in context: Context, completion: @escaping (CodexBarSwitcherEntry) -> Void) {
+ completion(self.makeEntry())
+ }
+
+ func getTimeline(
+ in context: Context, completion: @escaping (Timeline) -> Void
+ ) {
+ let entry = self.makeEntry()
+ let refresh = Date().addingTimeInterval(30 * 60)
+ completion(Timeline(entries: [entry], policy: .after(refresh)))
+ }
+
+ private func makeEntry() -> CodexBarSwitcherEntry {
+ let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.snapshot()
+ let providers = self.availableProviders(from: snapshot)
+ let stored = WidgetSelectionStore.loadSelectedProvider()
+ let selected = providers.first { $0 == stored } ?? providers.first ?? .codex
+ if selected != stored {
+ WidgetSelectionStore.saveSelectedProvider(selected)
}
+ return CodexBarSwitcherEntry(
+ date: Date(),
+ provider: selected,
+ availableProviders: providers,
+ snapshot: snapshot)
+ }
+
+ private func availableProviders(from snapshot: WidgetSnapshot) -> [UsageProvider] {
+ Self.supportedProviders(from: snapshot)
+ }
+
+ static func supportedProviders(from snapshot: WidgetSnapshot) -> [UsageProvider] {
+ let enabled = snapshot.enabledProviders
+ let providers = enabled.isEmpty ? snapshot.entries.map(\.provider) : enabled
+ let supported = providers.filter { ProviderChoice(provider: $0) != nil }
+ return supported.isEmpty ? [.codex] : supported
+ }
}
struct CodexBarCompactTimelineProvider: AppIntentTimelineProvider {
- func placeholder(in context: Context) -> CodexBarCompactEntry {
- CodexBarCompactEntry(
- date: Date(),
- provider: .codex,
- metric: .credits,
- snapshot: WidgetPreviewData.snapshot())
- }
-
- func snapshot(for configuration: CompactMetricSelectionIntent, in context: Context) async -> CodexBarCompactEntry {
- let provider = configuration.provider.provider
- return CodexBarCompactEntry(
- date: Date(),
- provider: provider,
- metric: configuration.metric,
- snapshot: WidgetSnapshotStore.load() ?? WidgetPreviewData.snapshot())
- }
-
- func timeline(
- for configuration: CompactMetricSelectionIntent,
- in context: Context) async -> Timeline
- {
- let provider = configuration.provider.provider
- let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.snapshot()
- let entry = CodexBarCompactEntry(
- date: Date(),
- provider: provider,
- metric: configuration.metric,
- snapshot: snapshot)
- let refresh = Date().addingTimeInterval(30 * 60)
- return Timeline(entries: [entry], policy: .after(refresh))
- }
+ func placeholder(in context: Context) -> CodexBarCompactEntry {
+ CodexBarCompactEntry(
+ date: Date(),
+ provider: .codex,
+ metric: .credits,
+ snapshot: WidgetPreviewData.snapshot())
+ }
+
+ func snapshot(for configuration: CompactMetricSelectionIntent, in context: Context) async
+ -> CodexBarCompactEntry
+ {
+ let provider = configuration.provider.provider
+ return CodexBarCompactEntry(
+ date: Date(),
+ provider: provider,
+ metric: configuration.metric,
+ snapshot: WidgetSnapshotStore.load() ?? WidgetPreviewData.snapshot())
+ }
+
+ func timeline(
+ for configuration: CompactMetricSelectionIntent,
+ in context: Context
+ ) async -> Timeline {
+ let provider = configuration.provider.provider
+ let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.snapshot()
+ let entry = CodexBarCompactEntry(
+ date: Date(),
+ provider: provider,
+ metric: configuration.metric,
+ snapshot: snapshot)
+ let refresh = Date().addingTimeInterval(30 * 60)
+ return Timeline(entries: [entry], policy: .after(refresh))
+ }
}
enum WidgetPreviewData {
- static func snapshot() -> WidgetSnapshot {
- let primary = RateWindow(usedPercent: 35, windowMinutes: nil, resetsAt: nil, resetDescription: "Resets in 4h")
- let secondary = RateWindow(usedPercent: 60, windowMinutes: nil, resetsAt: nil, resetDescription: "Resets in 3d")
- let entry = WidgetSnapshot.ProviderEntry(
- provider: .codex,
- updatedAt: Date(),
- primary: primary,
- secondary: secondary,
- tertiary: nil,
- creditsRemaining: 1243.4,
- codeReviewRemainingPercent: 78,
- tokenUsage: WidgetSnapshot.TokenUsageSummary(
- sessionCostUSD: 12.4,
- sessionTokens: 420_000,
- last30DaysCostUSD: 923.8,
- last30DaysTokens: 12_400_000),
- dailyUsage: [
- WidgetSnapshot.DailyUsagePoint(dayKey: "2025-12-01", totalTokens: 120_000, costUSD: 15.2),
- WidgetSnapshot.DailyUsagePoint(dayKey: "2025-12-02", totalTokens: 80000, costUSD: 10.1),
- WidgetSnapshot.DailyUsagePoint(dayKey: "2025-12-03", totalTokens: 140_000, costUSD: 17.9),
- WidgetSnapshot.DailyUsagePoint(dayKey: "2025-12-04", totalTokens: 90000, costUSD: 11.4),
- WidgetSnapshot.DailyUsagePoint(dayKey: "2025-12-05", totalTokens: 160_000, costUSD: 19.8),
- WidgetSnapshot.DailyUsagePoint(dayKey: "2025-12-06", totalTokens: 70000, costUSD: 8.9),
- WidgetSnapshot.DailyUsagePoint(dayKey: "2025-12-07", totalTokens: 110_000, costUSD: 13.7),
- ])
- return WidgetSnapshot(entries: [entry], generatedAt: Date())
- }
+ static func snapshot() -> WidgetSnapshot {
+ let primary = RateWindow(
+ usedPercent: 35, windowMinutes: nil, resetsAt: nil, resetDescription: "Resets in 4h")
+ let secondary = RateWindow(
+ usedPercent: 60, windowMinutes: nil, resetsAt: nil, resetDescription: "Resets in 3d")
+ let entry = WidgetSnapshot.ProviderEntry(
+ provider: .codex,
+ updatedAt: Date(),
+ primary: primary,
+ secondary: secondary,
+ tertiary: nil,
+ creditsRemaining: 1243.4,
+ codeReviewRemainingPercent: 78,
+ tokenUsage: WidgetSnapshot.TokenUsageSummary(
+ sessionCostUSD: 12.4,
+ sessionTokens: 420_000,
+ last30DaysCostUSD: 923.8,
+ last30DaysTokens: 12_400_000),
+ dailyUsage: [
+ WidgetSnapshot.DailyUsagePoint(dayKey: "2025-12-01", totalTokens: 120_000, costUSD: 15.2),
+ WidgetSnapshot.DailyUsagePoint(dayKey: "2025-12-02", totalTokens: 80000, costUSD: 10.1),
+ WidgetSnapshot.DailyUsagePoint(dayKey: "2025-12-03", totalTokens: 140_000, costUSD: 17.9),
+ WidgetSnapshot.DailyUsagePoint(dayKey: "2025-12-04", totalTokens: 90000, costUSD: 11.4),
+ WidgetSnapshot.DailyUsagePoint(dayKey: "2025-12-05", totalTokens: 160_000, costUSD: 19.8),
+ WidgetSnapshot.DailyUsagePoint(dayKey: "2025-12-06", totalTokens: 70000, costUSD: 8.9),
+ WidgetSnapshot.DailyUsagePoint(dayKey: "2025-12-07", totalTokens: 110_000, costUSD: 13.7),
+ ])
+ return WidgetSnapshot(entries: [entry], generatedAt: Date())
+ }
}
diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift
index e08b23367..d502e1bec 100644
--- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift
+++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift
@@ -3,673 +3,691 @@ import SwiftUI
import WidgetKit
struct CodexBarUsageWidgetView: View {
- @Environment(\.widgetFamily) private var family
- let entry: CodexBarWidgetEntry
-
- var body: some View {
- let providerEntry = self.entry.snapshot.entries.first { $0.provider == self.entry.provider }
- ZStack {
- Color.black.opacity(0.02)
- if let providerEntry {
- self.content(providerEntry: providerEntry)
- } else {
- self.emptyState
- }
- }
- .containerBackground(.fill.tertiary, for: .widget)
- }
-
- @ViewBuilder
- private func content(providerEntry: WidgetSnapshot.ProviderEntry) -> some View {
- switch self.family {
- case .systemSmall:
- SmallUsageView(entry: providerEntry)
- case .systemMedium:
- MediumUsageView(entry: providerEntry)
- default:
- LargeUsageView(entry: providerEntry)
- }
- }
-
- private var emptyState: some View {
- VStack(alignment: .leading, spacing: 6) {
- Text("Open CodexBar")
- .font(.body)
- .fontWeight(.semibold)
- Text("Usage data will appear once the app refreshes.")
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- .padding(12)
- }
+ @Environment(\.widgetFamily) private var family
+ let entry: CodexBarWidgetEntry
+
+ var body: some View {
+ let providerEntry = self.entry.snapshot.entries.first { $0.provider == self.entry.provider }
+ ZStack {
+ Color.black.opacity(0.02)
+ if let providerEntry {
+ self.content(providerEntry: providerEntry)
+ } else {
+ self.emptyState
+ }
+ }
+ .containerBackground(.fill.tertiary, for: .widget)
+ }
+
+ @ViewBuilder
+ private func content(providerEntry: WidgetSnapshot.ProviderEntry) -> some View {
+ switch self.family {
+ case .systemSmall:
+ SmallUsageView(entry: providerEntry)
+ case .systemMedium:
+ MediumUsageView(entry: providerEntry)
+ default:
+ LargeUsageView(entry: providerEntry)
+ }
+ }
+
+ private var emptyState: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ Text("Open CodexBar")
+ .font(.body)
+ .fontWeight(.semibold)
+ Text("Usage data will appear once the app refreshes.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ .padding(12)
+ }
}
struct CodexBarHistoryWidgetView: View {
- @Environment(\.widgetFamily) private var family
- let entry: CodexBarWidgetEntry
-
- var body: some View {
- let providerEntry = self.entry.snapshot.entries.first { $0.provider == self.entry.provider }
- ZStack {
- Color.black.opacity(0.02)
- if let providerEntry {
- HistoryView(entry: providerEntry, isLarge: self.family == .systemLarge)
- } else {
- self.emptyState
- }
- }
- .containerBackground(.fill.tertiary, for: .widget)
- }
-
- private var emptyState: some View {
- VStack(alignment: .leading, spacing: 6) {
- Text("Open CodexBar")
- .font(.body)
- .fontWeight(.semibold)
- Text("Usage history will appear after a refresh.")
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- .padding(12)
- }
+ @Environment(\.widgetFamily) private var family
+ let entry: CodexBarWidgetEntry
+
+ var body: some View {
+ let providerEntry = self.entry.snapshot.entries.first { $0.provider == self.entry.provider }
+ ZStack {
+ Color.black.opacity(0.02)
+ if let providerEntry {
+ HistoryView(entry: providerEntry, isLarge: self.family == .systemLarge)
+ } else {
+ self.emptyState
+ }
+ }
+ .containerBackground(.fill.tertiary, for: .widget)
+ }
+
+ private var emptyState: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ Text("Open CodexBar")
+ .font(.body)
+ .fontWeight(.semibold)
+ Text("Usage history will appear after a refresh.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ .padding(12)
+ }
}
struct CodexBarCompactWidgetView: View {
- let entry: CodexBarCompactEntry
-
- var body: some View {
- let providerEntry = self.entry.snapshot.entries.first { $0.provider == self.entry.provider }
- ZStack {
- Color.black.opacity(0.02)
- if let providerEntry {
- CompactMetricView(entry: providerEntry, metric: self.entry.metric)
- } else {
- self.emptyState
- }
- }
- .containerBackground(.fill.tertiary, for: .widget)
- }
-
- private var emptyState: some View {
- VStack(alignment: .leading, spacing: 6) {
- Text("Open CodexBar")
- .font(.body)
- .fontWeight(.semibold)
- Text("Usage data will appear once the app refreshes.")
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- .padding(12)
- }
+ let entry: CodexBarCompactEntry
+
+ var body: some View {
+ let providerEntry = self.entry.snapshot.entries.first { $0.provider == self.entry.provider }
+ ZStack {
+ Color.black.opacity(0.02)
+ if let providerEntry {
+ CompactMetricView(entry: providerEntry, metric: self.entry.metric)
+ } else {
+ self.emptyState
+ }
+ }
+ .containerBackground(.fill.tertiary, for: .widget)
+ }
+
+ private var emptyState: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ Text("Open CodexBar")
+ .font(.body)
+ .fontWeight(.semibold)
+ Text("Usage data will appear once the app refreshes.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ .padding(12)
+ }
}
struct CodexBarSwitcherWidgetView: View {
- @Environment(\.widgetFamily) private var family
- let entry: CodexBarSwitcherEntry
-
- var body: some View {
- let providerEntry = self.entry.snapshot.entries.first { $0.provider == self.entry.provider }
- ZStack {
- Color.black.opacity(0.02)
- VStack(alignment: .leading, spacing: 10) {
- ProviderSwitcherRow(
- providers: self.entry.availableProviders,
- selected: self.entry.provider,
- updatedAt: providerEntry?.updatedAt ?? Date(),
- compact: self.family == .systemSmall,
- showsTimestamp: self.family != .systemSmall)
- if let providerEntry {
- self.content(providerEntry: providerEntry)
- } else {
- self.emptyState
- }
- }
- .padding(12)
- }
- .containerBackground(.fill.tertiary, for: .widget)
- }
-
- @ViewBuilder
- private func content(providerEntry: WidgetSnapshot.ProviderEntry) -> some View {
- switch self.family {
- case .systemSmall:
- SwitcherSmallUsageView(entry: providerEntry)
- case .systemMedium:
- SwitcherMediumUsageView(entry: providerEntry)
- default:
- SwitcherLargeUsageView(entry: providerEntry)
- }
- }
-
- private var emptyState: some View {
- VStack(alignment: .leading, spacing: 6) {
- Text("Open CodexBar")
- .font(.caption)
- .foregroundStyle(.secondary)
- Text("Usage data appears after a refresh.")
- .font(.caption2)
- .foregroundStyle(.secondary)
+ @Environment(\.widgetFamily) private var family
+ let entry: CodexBarSwitcherEntry
+
+ var body: some View {
+ let providerEntry = self.entry.snapshot.entries.first { $0.provider == self.entry.provider }
+ ZStack {
+ Color.black.opacity(0.02)
+ VStack(alignment: .leading, spacing: 10) {
+ ProviderSwitcherRow(
+ providers: self.entry.availableProviders,
+ selected: self.entry.provider,
+ updatedAt: providerEntry?.updatedAt ?? Date(),
+ compact: self.family == .systemSmall,
+ showsTimestamp: self.family != .systemSmall)
+ if let providerEntry {
+ self.content(providerEntry: providerEntry)
+ } else {
+ self.emptyState
}
- }
+ }
+ .padding(12)
+ }
+ .containerBackground(.fill.tertiary, for: .widget)
+ }
+
+ @ViewBuilder
+ private func content(providerEntry: WidgetSnapshot.ProviderEntry) -> some View {
+ switch self.family {
+ case .systemSmall:
+ SwitcherSmallUsageView(entry: providerEntry)
+ case .systemMedium:
+ SwitcherMediumUsageView(entry: providerEntry)
+ default:
+ SwitcherLargeUsageView(entry: providerEntry)
+ }
+ }
+
+ private var emptyState: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ Text("Open CodexBar")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ Text("Usage data appears after a refresh.")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ }
+ }
}
private struct CompactMetricView: View {
- let entry: WidgetSnapshot.ProviderEntry
- let metric: CompactMetric
-
- var body: some View {
- let display = self.display
- VStack(alignment: .leading, spacing: 8) {
- HeaderView(provider: self.entry.provider, updatedAt: self.entry.updatedAt)
- VStack(alignment: .leading, spacing: 2) {
- Text(display.value)
- .font(.title2)
- .fontWeight(.semibold)
- Text(display.label)
- .font(.caption2)
- .foregroundStyle(.secondary)
- if let detail = display.detail {
- Text(detail)
- .font(.caption2)
- .foregroundStyle(.secondary)
- }
- }
+ let entry: WidgetSnapshot.ProviderEntry
+ let metric: CompactMetric
+
+ var body: some View {
+ let display = self.display
+ VStack(alignment: .leading, spacing: 8) {
+ HeaderView(provider: self.entry.provider, updatedAt: self.entry.updatedAt)
+ VStack(alignment: .leading, spacing: 2) {
+ Text(display.value)
+ .font(.title2)
+ .fontWeight(.semibold)
+ Text(display.label)
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ if let detail = display.detail {
+ Text(detail)
+ .font(.caption2)
+ .foregroundStyle(.secondary)
}
- .padding(12)
- }
-
- private var display: (value: String, label: String, detail: String?) {
- switch self.metric {
- case .credits:
- let value = self.entry.creditsRemaining.map(WidgetFormat.credits) ?? "โ"
- return (value, "Credits left", nil)
- case .todayCost:
- let value = self.entry.tokenUsage?.sessionCostUSD.map(WidgetFormat.usd) ?? "โ"
- let detail = self.entry.tokenUsage?.sessionTokens.map(WidgetFormat.tokenCount)
- return (value, "Today cost", detail)
- case .last30DaysCost:
- let value = self.entry.tokenUsage?.last30DaysCostUSD.map(WidgetFormat.usd) ?? "โ"
- let detail = self.entry.tokenUsage?.last30DaysTokens.map(WidgetFormat.tokenCount)
- return (value, "30d cost", detail)
- }
- }
+ }
+ }
+ .padding(12)
+ }
+
+ private var display: (value: String, label: String, detail: String?) {
+ switch self.metric {
+ case .credits:
+ let value = self.entry.creditsRemaining.map(WidgetFormat.credits) ?? "โ"
+ return (value, "Credits left", nil)
+ case .todayCost:
+ let value = self.entry.tokenUsage?.sessionCostUSD.map(WidgetFormat.usd) ?? "โ"
+ let detail = self.entry.tokenUsage?.sessionTokens.map(WidgetFormat.tokenCount)
+ return (value, "Today cost", detail)
+ case .last30DaysCost:
+ let value = self.entry.tokenUsage?.last30DaysCostUSD.map(WidgetFormat.usd) ?? "โ"
+ let detail = self.entry.tokenUsage?.last30DaysTokens.map(WidgetFormat.tokenCount)
+ return (value, "30d cost", detail)
+ }
+ }
}
private struct ProviderSwitcherRow: View {
- let providers: [UsageProvider]
- let selected: UsageProvider
- let updatedAt: Date
- let compact: Bool
- let showsTimestamp: Bool
-
- var body: some View {
- HStack(spacing: self.compact ? 4 : 6) {
- ForEach(self.providers, id: \.self) { provider in
- ProviderSwitchChip(
- provider: provider,
- selected: provider == self.selected,
- compact: self.compact)
- }
- if self.showsTimestamp {
- Spacer(minLength: 6)
- Text(WidgetFormat.relativeDate(self.updatedAt))
- .font(.caption2)
- .foregroundStyle(.secondary)
- }
- }
- }
+ let providers: [UsageProvider]
+ let selected: UsageProvider
+ let updatedAt: Date
+ let compact: Bool
+ let showsTimestamp: Bool
+
+ var body: some View {
+ HStack(spacing: self.compact ? 4 : 6) {
+ ForEach(self.providers, id: \.self) { provider in
+ ProviderSwitchChip(
+ provider: provider,
+ selected: provider == self.selected,
+ compact: self.compact)
+ }
+ if self.showsTimestamp {
+ Spacer(minLength: 6)
+ Text(WidgetFormat.relativeDate(self.updatedAt))
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
}
private struct ProviderSwitchChip: View {
- let provider: UsageProvider
- let selected: Bool
- let compact: Bool
-
- var body: some View {
- let label = self.compact ? self.shortLabel : self.longLabel
- let background = self.selected
- ? WidgetColors.color(for: self.provider).opacity(0.2)
- : Color.primary.opacity(0.08)
-
- if let choice = ProviderChoice(provider: self.provider) {
- Button(intent: SwitchWidgetProviderIntent(provider: choice)) {
- Text(label)
- .font(self.compact ? .caption2.weight(.semibold) : .caption.weight(.semibold))
- .foregroundStyle(self.selected ? Color.primary : Color.secondary)
- .padding(.horizontal, self.compact ? 6 : 8)
- .padding(.vertical, self.compact ? 3 : 4)
- .background(Capsule().fill(background))
- }
- .buttonStyle(.plain)
- } else {
- Text(label)
- .font(self.compact ? .caption2.weight(.semibold) : .caption.weight(.semibold))
- .foregroundStyle(self.selected ? Color.primary : Color.secondary)
- .padding(.horizontal, self.compact ? 6 : 8)
- .padding(.vertical, self.compact ? 3 : 4)
- .background(Capsule().fill(background))
- }
- }
-
- private var longLabel: String {
- ProviderDefaults.metadata[self.provider]?.displayName ?? self.provider.rawValue.capitalized
- }
-
- private var shortLabel: String {
- switch self.provider {
- case .codex: "Codex"
- case .claude: "Claude"
- case .gemini: "Gemini"
- case .antigravity: "Anti"
- case .cursor: "Cursor"
- case .opencode: "OpenCode"
- case .alibaba: "Alibaba"
- case .zai: "z.ai"
- case .factory: "Droid"
- case .copilot: "Copilot"
- case .minimax: "MiniMax"
- case .vertexai: "Vertex"
- case .kilo: "Kilo"
- case .kiro: "Kiro"
- case .augment: "Augment"
- case .jetbrains: "JetBrains"
- case .kimi: "Kimi"
- case .kimik2: "Kimi K2"
- case .amp: "Amp"
- case .ollama: "Ollama"
- case .synthetic: "Synthetic"
- case .openrouter: "OpenRouter"
- case .warp: "Warp"
- case .perplexity: "Pplx"
- }
- }
+ let provider: UsageProvider
+ let selected: Bool
+ let compact: Bool
+
+ var body: some View {
+ let label = self.compact ? self.shortLabel : self.longLabel
+ let background =
+ self.selected
+ ? WidgetColors.color(for: self.provider).opacity(0.2)
+ : Color.primary.opacity(0.08)
+
+ if let choice = ProviderChoice(provider: self.provider) {
+ Button(intent: SwitchWidgetProviderIntent(provider: choice)) {
+ Text(label)
+ .font(self.compact ? .caption2.weight(.semibold) : .caption.weight(.semibold))
+ .foregroundStyle(self.selected ? Color.primary : Color.secondary)
+ .padding(.horizontal, self.compact ? 6 : 8)
+ .padding(.vertical, self.compact ? 3 : 4)
+ .background(Capsule().fill(background))
+ }
+ .buttonStyle(.plain)
+ } else {
+ Text(label)
+ .font(self.compact ? .caption2.weight(.semibold) : .caption.weight(.semibold))
+ .foregroundStyle(self.selected ? Color.primary : Color.secondary)
+ .padding(.horizontal, self.compact ? 6 : 8)
+ .padding(.vertical, self.compact ? 3 : 4)
+ .background(Capsule().fill(background))
+ }
+ }
+
+ private var longLabel: String {
+ ProviderDefaults.metadata[self.provider]?.displayName ?? self.provider.rawValue.capitalized
+ }
+
+ private var shortLabel: String {
+ switch self.provider {
+ case .codex: "Codex"
+ case .claude: "Claude"
+ case .gemini: "Gemini"
+ case .antigravity: "Anti"
+ case .cursor: "Cursor"
+ case .opencode: "OpenCode"
+ case .alibaba: "Alibaba"
+ case .zai: "z.ai"
+ case .factory: "Droid"
+ case .copilot: "Copilot"
+ case .minimax: "MiniMax"
+ case .vertexai: "Vertex"
+ case .kilo: "Kilo"
+ case .kiro: "Kiro"
+ case .augment: "Augment"
+ case .jetbrains: "JetBrains"
+ case .kimi: "Kimi"
+ case .kimik2: "Kimi K2"
+ case .amp: "Amp"
+ case .ollama: "Ollama"
+ case .synthetic: "Synthetic"
+ case .openrouter: "OpenRouter"
+ case .warp: "Warp"
+ case .perplexity: "Pplx"
+ case .abacus: "Abacus"
+ }
+ }
}
private struct SwitcherSmallUsageView: View {
- let entry: WidgetSnapshot.ProviderEntry
-
- var body: some View {
- VStack(alignment: .leading, spacing: 8) {
- UsageBarRow(
- title: ProviderDefaults.metadata[self.entry.provider]?.sessionLabel ?? "Session",
- percentLeft: self.entry.primary?.remainingPercent,
- color: WidgetColors.color(for: self.entry.provider))
- UsageBarRow(
- title: ProviderDefaults.metadata[self.entry.provider]?.weeklyLabel ?? "Weekly",
- percentLeft: self.entry.secondary?.remainingPercent,
- color: WidgetColors.color(for: self.entry.provider))
- if let codeReview = entry.codeReviewRemainingPercent {
- UsageBarRow(
- title: "Code review",
- percentLeft: codeReview,
- color: WidgetColors.color(for: self.entry.provider))
- }
- }
- }
+ let entry: WidgetSnapshot.ProviderEntry
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ UsageBarRow(
+ title: ProviderDefaults.metadata[self.entry.provider]?.sessionLabel ?? "Session",
+ percentLeft: self.entry.primary?.remainingPercent,
+ color: WidgetColors.color(for: self.entry.provider))
+ UsageBarRow(
+ title: ProviderDefaults.metadata[self.entry.provider]?.weeklyLabel ?? "Weekly",
+ percentLeft: self.entry.secondary?.remainingPercent,
+ color: WidgetColors.color(for: self.entry.provider))
+ if let codeReview = entry.codeReviewRemainingPercent {
+ UsageBarRow(
+ title: "Code review",
+ percentLeft: codeReview,
+ color: WidgetColors.color(for: self.entry.provider))
+ }
+ }
+ }
}
private struct SwitcherMediumUsageView: View {
- let entry: WidgetSnapshot.ProviderEntry
-
- var body: some View {
- VStack(alignment: .leading, spacing: 10) {
- UsageBarRow(
- title: ProviderDefaults.metadata[self.entry.provider]?.sessionLabel ?? "Session",
- percentLeft: self.entry.primary?.remainingPercent,
- color: WidgetColors.color(for: self.entry.provider))
- UsageBarRow(
- title: ProviderDefaults.metadata[self.entry.provider]?.weeklyLabel ?? "Weekly",
- percentLeft: self.entry.secondary?.remainingPercent,
- color: WidgetColors.color(for: self.entry.provider))
- if let credits = entry.creditsRemaining {
- ValueLine(title: "Credits", value: WidgetFormat.credits(credits))
- }
- if let token = entry.tokenUsage {
- ValueLine(
- title: "Today",
- value: WidgetFormat.costAndTokens(cost: token.sessionCostUSD, tokens: token.sessionTokens))
- }
- }
- }
+ let entry: WidgetSnapshot.ProviderEntry
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ UsageBarRow(
+ title: ProviderDefaults.metadata[self.entry.provider]?.sessionLabel ?? "Session",
+ percentLeft: self.entry.primary?.remainingPercent,
+ color: WidgetColors.color(for: self.entry.provider))
+ UsageBarRow(
+ title: ProviderDefaults.metadata[self.entry.provider]?.weeklyLabel ?? "Weekly",
+ percentLeft: self.entry.secondary?.remainingPercent,
+ color: WidgetColors.color(for: self.entry.provider))
+ if let credits = entry.creditsRemaining {
+ ValueLine(title: "Credits", value: WidgetFormat.credits(credits))
+ }
+ if let token = entry.tokenUsage {
+ ValueLine(
+ title: "Today",
+ value: WidgetFormat.costAndTokens(cost: token.sessionCostUSD, tokens: token.sessionTokens)
+ )
+ }
+ }
+ }
}
private struct SwitcherLargeUsageView: View {
- let entry: WidgetSnapshot.ProviderEntry
-
- var body: some View {
- VStack(alignment: .leading, spacing: 12) {
- UsageBarRow(
- title: ProviderDefaults.metadata[self.entry.provider]?.sessionLabel ?? "Session",
- percentLeft: self.entry.primary?.remainingPercent,
- color: WidgetColors.color(for: self.entry.provider))
- UsageBarRow(
- title: ProviderDefaults.metadata[self.entry.provider]?.weeklyLabel ?? "Weekly",
- percentLeft: self.entry.secondary?.remainingPercent,
- color: WidgetColors.color(for: self.entry.provider))
- if let codeReview = entry.codeReviewRemainingPercent {
- UsageBarRow(
- title: "Code review",
- percentLeft: codeReview,
- color: WidgetColors.color(for: self.entry.provider))
- }
- if let credits = entry.creditsRemaining {
- ValueLine(title: "Credits", value: WidgetFormat.credits(credits))
- }
- if let token = entry.tokenUsage {
- VStack(alignment: .leading, spacing: 4) {
- ValueLine(
- title: "Today",
- value: WidgetFormat.costAndTokens(cost: token.sessionCostUSD, tokens: token.sessionTokens))
- ValueLine(
- title: "30d",
- value: WidgetFormat.costAndTokens(
- cost: token.last30DaysCostUSD,
- tokens: token.last30DaysTokens))
- }
- }
- UsageHistoryChart(points: self.entry.dailyUsage, color: WidgetColors.color(for: self.entry.provider))
- .frame(height: 50)
+ let entry: WidgetSnapshot.ProviderEntry
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ UsageBarRow(
+ title: ProviderDefaults.metadata[self.entry.provider]?.sessionLabel ?? "Session",
+ percentLeft: self.entry.primary?.remainingPercent,
+ color: WidgetColors.color(for: self.entry.provider))
+ UsageBarRow(
+ title: ProviderDefaults.metadata[self.entry.provider]?.weeklyLabel ?? "Weekly",
+ percentLeft: self.entry.secondary?.remainingPercent,
+ color: WidgetColors.color(for: self.entry.provider))
+ if let codeReview = entry.codeReviewRemainingPercent {
+ UsageBarRow(
+ title: "Code review",
+ percentLeft: codeReview,
+ color: WidgetColors.color(for: self.entry.provider))
+ }
+ if let credits = entry.creditsRemaining {
+ ValueLine(title: "Credits", value: WidgetFormat.credits(credits))
+ }
+ if let token = entry.tokenUsage {
+ VStack(alignment: .leading, spacing: 4) {
+ ValueLine(
+ title: "Today",
+ value: WidgetFormat.costAndTokens(
+ cost: token.sessionCostUSD, tokens: token.sessionTokens))
+ ValueLine(
+ title: "30d",
+ value: WidgetFormat.costAndTokens(
+ cost: token.last30DaysCostUSD,
+ tokens: token.last30DaysTokens))
}
+ }
+ UsageHistoryChart(
+ points: self.entry.dailyUsage, color: WidgetColors.color(for: self.entry.provider)
+ )
+ .frame(height: 50)
}
+ }
}
private struct SmallUsageView: View {
- let entry: WidgetSnapshot.ProviderEntry
-
- var body: some View {
- VStack(alignment: .leading, spacing: 8) {
- HeaderView(provider: self.entry.provider, updatedAt: self.entry.updatedAt)
- UsageBarRow(
- title: ProviderDefaults.metadata[self.entry.provider]?.sessionLabel ?? "Session",
- percentLeft: self.entry.primary?.remainingPercent,
- color: WidgetColors.color(for: self.entry.provider))
- UsageBarRow(
- title: ProviderDefaults.metadata[self.entry.provider]?.weeklyLabel ?? "Weekly",
- percentLeft: self.entry.secondary?.remainingPercent,
- color: WidgetColors.color(for: self.entry.provider))
- if let codeReview = entry.codeReviewRemainingPercent {
- UsageBarRow(
- title: "Code review",
- percentLeft: codeReview,
- color: WidgetColors.color(for: self.entry.provider))
- }
- }
- .padding(12)
- }
+ let entry: WidgetSnapshot.ProviderEntry
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ HeaderView(provider: self.entry.provider, updatedAt: self.entry.updatedAt)
+ UsageBarRow(
+ title: ProviderDefaults.metadata[self.entry.provider]?.sessionLabel ?? "Session",
+ percentLeft: self.entry.primary?.remainingPercent,
+ color: WidgetColors.color(for: self.entry.provider))
+ UsageBarRow(
+ title: ProviderDefaults.metadata[self.entry.provider]?.weeklyLabel ?? "Weekly",
+ percentLeft: self.entry.secondary?.remainingPercent,
+ color: WidgetColors.color(for: self.entry.provider))
+ if let codeReview = entry.codeReviewRemainingPercent {
+ UsageBarRow(
+ title: "Code review",
+ percentLeft: codeReview,
+ color: WidgetColors.color(for: self.entry.provider))
+ }
+ }
+ .padding(12)
+ }
}
private struct MediumUsageView: View {
- let entry: WidgetSnapshot.ProviderEntry
-
- var body: some View {
- VStack(alignment: .leading, spacing: 10) {
- HeaderView(provider: self.entry.provider, updatedAt: self.entry.updatedAt)
- UsageBarRow(
- title: ProviderDefaults.metadata[self.entry.provider]?.sessionLabel ?? "Session",
- percentLeft: self.entry.primary?.remainingPercent,
- color: WidgetColors.color(for: self.entry.provider))
- UsageBarRow(
- title: ProviderDefaults.metadata[self.entry.provider]?.weeklyLabel ?? "Weekly",
- percentLeft: self.entry.secondary?.remainingPercent,
- color: WidgetColors.color(for: self.entry.provider))
- if let credits = entry.creditsRemaining {
- ValueLine(title: "Credits", value: WidgetFormat.credits(credits))
- }
- if let token = entry.tokenUsage {
- ValueLine(
- title: "Today",
- value: WidgetFormat.costAndTokens(cost: token.sessionCostUSD, tokens: token.sessionTokens))
- }
- }
- .padding(12)
- }
+ let entry: WidgetSnapshot.ProviderEntry
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ HeaderView(provider: self.entry.provider, updatedAt: self.entry.updatedAt)
+ UsageBarRow(
+ title: ProviderDefaults.metadata[self.entry.provider]?.sessionLabel ?? "Session",
+ percentLeft: self.entry.primary?.remainingPercent,
+ color: WidgetColors.color(for: self.entry.provider))
+ UsageBarRow(
+ title: ProviderDefaults.metadata[self.entry.provider]?.weeklyLabel ?? "Weekly",
+ percentLeft: self.entry.secondary?.remainingPercent,
+ color: WidgetColors.color(for: self.entry.provider))
+ if let credits = entry.creditsRemaining {
+ ValueLine(title: "Credits", value: WidgetFormat.credits(credits))
+ }
+ if let token = entry.tokenUsage {
+ ValueLine(
+ title: "Today",
+ value: WidgetFormat.costAndTokens(cost: token.sessionCostUSD, tokens: token.sessionTokens)
+ )
+ }
+ }
+ .padding(12)
+ }
}
private struct LargeUsageView: View {
- let entry: WidgetSnapshot.ProviderEntry
-
- var body: some View {
- VStack(alignment: .leading, spacing: 12) {
- HeaderView(provider: self.entry.provider, updatedAt: self.entry.updatedAt)
- UsageBarRow(
- title: ProviderDefaults.metadata[self.entry.provider]?.sessionLabel ?? "Session",
- percentLeft: self.entry.primary?.remainingPercent,
- color: WidgetColors.color(for: self.entry.provider))
- UsageBarRow(
- title: ProviderDefaults.metadata[self.entry.provider]?.weeklyLabel ?? "Weekly",
- percentLeft: self.entry.secondary?.remainingPercent,
- color: WidgetColors.color(for: self.entry.provider))
- if let codeReview = entry.codeReviewRemainingPercent {
- UsageBarRow(
- title: "Code review",
- percentLeft: codeReview,
- color: WidgetColors.color(for: self.entry.provider))
- }
- if let credits = entry.creditsRemaining {
- ValueLine(title: "Credits", value: WidgetFormat.credits(credits))
- }
- if let token = entry.tokenUsage {
- VStack(alignment: .leading, spacing: 4) {
- ValueLine(
- title: "Today",
- value: WidgetFormat.costAndTokens(cost: token.sessionCostUSD, tokens: token.sessionTokens))
- ValueLine(
- title: "30d",
- value: WidgetFormat.costAndTokens(
- cost: token.last30DaysCostUSD,
- tokens: token.last30DaysTokens))
- }
- }
- UsageHistoryChart(points: self.entry.dailyUsage, color: WidgetColors.color(for: self.entry.provider))
- .frame(height: 50)
+ let entry: WidgetSnapshot.ProviderEntry
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ HeaderView(provider: self.entry.provider, updatedAt: self.entry.updatedAt)
+ UsageBarRow(
+ title: ProviderDefaults.metadata[self.entry.provider]?.sessionLabel ?? "Session",
+ percentLeft: self.entry.primary?.remainingPercent,
+ color: WidgetColors.color(for: self.entry.provider))
+ UsageBarRow(
+ title: ProviderDefaults.metadata[self.entry.provider]?.weeklyLabel ?? "Weekly",
+ percentLeft: self.entry.secondary?.remainingPercent,
+ color: WidgetColors.color(for: self.entry.provider))
+ if let codeReview = entry.codeReviewRemainingPercent {
+ UsageBarRow(
+ title: "Code review",
+ percentLeft: codeReview,
+ color: WidgetColors.color(for: self.entry.provider))
+ }
+ if let credits = entry.creditsRemaining {
+ ValueLine(title: "Credits", value: WidgetFormat.credits(credits))
+ }
+ if let token = entry.tokenUsage {
+ VStack(alignment: .leading, spacing: 4) {
+ ValueLine(
+ title: "Today",
+ value: WidgetFormat.costAndTokens(
+ cost: token.sessionCostUSD, tokens: token.sessionTokens))
+ ValueLine(
+ title: "30d",
+ value: WidgetFormat.costAndTokens(
+ cost: token.last30DaysCostUSD,
+ tokens: token.last30DaysTokens))
}
- .padding(12)
- }
+ }
+ UsageHistoryChart(
+ points: self.entry.dailyUsage, color: WidgetColors.color(for: self.entry.provider)
+ )
+ .frame(height: 50)
+ }
+ .padding(12)
+ }
}
private struct HistoryView: View {
- let entry: WidgetSnapshot.ProviderEntry
- let isLarge: Bool
-
- var body: some View {
- VStack(alignment: .leading, spacing: 12) {
- HeaderView(provider: self.entry.provider, updatedAt: self.entry.updatedAt)
- UsageHistoryChart(points: self.entry.dailyUsage, color: WidgetColors.color(for: self.entry.provider))
- .frame(height: self.isLarge ? 90 : 60)
- if let token = entry.tokenUsage {
- ValueLine(
- title: "Today",
- value: WidgetFormat.costAndTokens(cost: token.sessionCostUSD, tokens: token.sessionTokens))
- ValueLine(
- title: "30d",
- value: WidgetFormat.costAndTokens(cost: token.last30DaysCostUSD, tokens: token.last30DaysTokens))
- }
- }
- .padding(12)
- }
+ let entry: WidgetSnapshot.ProviderEntry
+ let isLarge: Bool
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ HeaderView(provider: self.entry.provider, updatedAt: self.entry.updatedAt)
+ UsageHistoryChart(
+ points: self.entry.dailyUsage, color: WidgetColors.color(for: self.entry.provider)
+ )
+ .frame(height: self.isLarge ? 90 : 60)
+ if let token = entry.tokenUsage {
+ ValueLine(
+ title: "Today",
+ value: WidgetFormat.costAndTokens(cost: token.sessionCostUSD, tokens: token.sessionTokens)
+ )
+ ValueLine(
+ title: "30d",
+ value: WidgetFormat.costAndTokens(
+ cost: token.last30DaysCostUSD, tokens: token.last30DaysTokens))
+ }
+ }
+ .padding(12)
+ }
}
private struct HeaderView: View {
- let provider: UsageProvider
- let updatedAt: Date
-
- var body: some View {
- HStack(alignment: .firstTextBaseline) {
- Text(ProviderDefaults.metadata[self.provider]?.displayName ?? self.provider.rawValue.capitalized)
- .font(.body)
- .fontWeight(.semibold)
- Spacer()
- Text(WidgetFormat.relativeDate(self.updatedAt))
- .font(.caption2)
- .foregroundStyle(.secondary)
- }
- }
+ let provider: UsageProvider
+ let updatedAt: Date
+
+ var body: some View {
+ HStack(alignment: .firstTextBaseline) {
+ Text(
+ ProviderDefaults.metadata[self.provider]?.displayName ?? self.provider.rawValue.capitalized
+ )
+ .font(.body)
+ .fontWeight(.semibold)
+ Spacer()
+ Text(WidgetFormat.relativeDate(self.updatedAt))
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ }
+ }
}
private struct UsageBarRow: View {
- let title: String
- let percentLeft: Double?
- let color: Color
-
- var body: some View {
- VStack(alignment: .leading, spacing: 4) {
- HStack {
- Text(self.title)
- .font(.caption)
- Spacer()
- Text(WidgetFormat.percent(self.percentLeft))
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- GeometryReader { proxy in
- let width = max(0, min(1, (percentLeft ?? 0) / 100)) * proxy.size.width
- ZStack(alignment: .leading) {
- Capsule().fill(Color.primary.opacity(0.08))
- Capsule().fill(self.color).frame(width: width)
- }
- }
- .frame(height: 6)
+ let title: String
+ let percentLeft: Double?
+ let color: Color
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ HStack {
+ Text(self.title)
+ .font(.caption)
+ Spacer()
+ Text(WidgetFormat.percent(self.percentLeft))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ GeometryReader { proxy in
+ let width = max(0, min(1, (percentLeft ?? 0) / 100)) * proxy.size.width
+ ZStack(alignment: .leading) {
+ Capsule().fill(Color.primary.opacity(0.08))
+ Capsule().fill(self.color).frame(width: width)
}
+ }
+ .frame(height: 6)
}
+ }
}
private struct ValueLine: View {
- let title: String
- let value: String
-
- var body: some View {
- HStack(spacing: 6) {
- Text(self.title)
- .font(.caption)
- .foregroundStyle(.secondary)
- Text(self.value)
- .font(.caption)
- }
- }
+ let title: String
+ let value: String
+
+ var body: some View {
+ HStack(spacing: 6) {
+ Text(self.title)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ Text(self.value)
+ .font(.caption)
+ }
+ }
}
private struct UsageHistoryChart: View {
- let points: [WidgetSnapshot.DailyUsagePoint]
- let color: Color
-
- var body: some View {
- let values = self.points.map { point -> Double in
- if let cost = point.costUSD { return cost }
- return Double(point.totalTokens ?? 0)
- }
- let maxValue = values.max() ?? 0
- HStack(alignment: .bottom, spacing: 2) {
- ForEach(values.indices, id: \.self) { index in
- let value = values[index]
- let height = maxValue > 0 ? CGFloat(value / maxValue) : 0
- RoundedRectangle(cornerRadius: 2)
- .fill(self.color.opacity(0.85))
- .frame(maxWidth: .infinity)
- .scaleEffect(x: 1, y: height, anchor: .bottom)
- .animation(.easeOut(duration: 0.2), value: height)
- }
- }
- }
+ let points: [WidgetSnapshot.DailyUsagePoint]
+ let color: Color
+
+ var body: some View {
+ let values = self.points.map { point -> Double in
+ if let cost = point.costUSD { return cost }
+ return Double(point.totalTokens ?? 0)
+ }
+ let maxValue = values.max() ?? 0
+ HStack(alignment: .bottom, spacing: 2) {
+ ForEach(values.indices, id: \.self) { index in
+ let value = values[index]
+ let height = maxValue > 0 ? CGFloat(value / maxValue) : 0
+ RoundedRectangle(cornerRadius: 2)
+ .fill(self.color.opacity(0.85))
+ .frame(maxWidth: .infinity)
+ .scaleEffect(x: 1, y: height, anchor: .bottom)
+ .animation(.easeOut(duration: 0.2), value: height)
+ }
+ }
+ }
}
enum WidgetColors {
- // swiftlint:disable:next cyclomatic_complexity
- static func color(for provider: UsageProvider) -> Color {
- switch provider {
- case .codex:
- Color(red: 73 / 255, green: 163 / 255, blue: 176 / 255)
- case .claude:
- Color(red: 204 / 255, green: 124 / 255, blue: 94 / 255)
- case .gemini:
- Color(red: 171 / 255, green: 135 / 255, blue: 234 / 255)
- case .antigravity:
- Color(red: 96 / 255, green: 186 / 255, blue: 126 / 255)
- case .cursor:
- Color(red: 0 / 255, green: 191 / 255, blue: 165 / 255) // #00BFA5 - Cursor teal
- case .opencode:
- Color(red: 59 / 255, green: 130 / 255, blue: 246 / 255)
- case .alibaba:
- Color(red: 1.0, green: 106 / 255, blue: 0)
- case .zai:
- Color(red: 232 / 255, green: 90 / 255, blue: 106 / 255)
- case .factory:
- Color(red: 255 / 255, green: 107 / 255, blue: 53 / 255) // Factory orange
- case .copilot:
- Color(red: 168 / 255, green: 85 / 255, blue: 247 / 255) // Purple
- case .minimax:
- Color(red: 254 / 255, green: 96 / 255, blue: 60 / 255)
- case .vertexai:
- Color(red: 66 / 255, green: 133 / 255, blue: 244 / 255) // Google Blue
- case .kilo:
- Color(red: 242 / 255, green: 112 / 255, blue: 39 / 255) // Kilo orange
- case .kiro:
- Color(red: 255 / 255, green: 153 / 255, blue: 0 / 255) // AWS orange
- case .augment:
- Color(red: 99 / 255, green: 102 / 255, blue: 241 / 255) // Augment purple
- case .jetbrains:
- Color(red: 255 / 255, green: 51 / 255, blue: 153 / 255) // JetBrains pink
- case .kimi:
- Color(red: 254 / 255, green: 96 / 255, blue: 60 / 255) // Kimi orange
- case .kimik2:
- Color(red: 76 / 255, green: 0 / 255, blue: 255 / 255) // Kimi K2 purple
- case .amp:
- Color(red: 220 / 255, green: 38 / 255, blue: 38 / 255) // Amp red
- case .ollama:
- Color(red: 32 / 255, green: 32 / 255, blue: 32 / 255) // Ollama charcoal
- case .synthetic:
- Color(red: 20 / 255, green: 20 / 255, blue: 20 / 255) // Synthetic charcoal
- case .openrouter:
- Color(red: 111 / 255, green: 66 / 255, blue: 193 / 255) // OpenRouter purple
- case .warp:
- Color(red: 147 / 255, green: 139 / 255, blue: 180 / 255)
- case .perplexity:
- Color(red: 32 / 255, green: 178 / 255, blue: 170 / 255) // Perplexity teal
- }
- }
+ // swiftlint:disable:next cyclomatic_complexity
+ static func color(for provider: UsageProvider) -> Color {
+ switch provider {
+ case .codex:
+ Color(red: 73 / 255, green: 163 / 255, blue: 176 / 255)
+ case .claude:
+ Color(red: 204 / 255, green: 124 / 255, blue: 94 / 255)
+ case .gemini:
+ Color(red: 171 / 255, green: 135 / 255, blue: 234 / 255)
+ case .antigravity:
+ Color(red: 96 / 255, green: 186 / 255, blue: 126 / 255)
+ case .cursor:
+ Color(red: 0 / 255, green: 191 / 255, blue: 165 / 255) // #00BFA5 - Cursor teal
+ case .opencode:
+ Color(red: 59 / 255, green: 130 / 255, blue: 246 / 255)
+ case .alibaba:
+ Color(red: 1.0, green: 106 / 255, blue: 0)
+ case .zai:
+ Color(red: 232 / 255, green: 90 / 255, blue: 106 / 255)
+ case .factory:
+ Color(red: 255 / 255, green: 107 / 255, blue: 53 / 255) // Factory orange
+ case .copilot:
+ Color(red: 168 / 255, green: 85 / 255, blue: 247 / 255) // Purple
+ case .minimax:
+ Color(red: 254 / 255, green: 96 / 255, blue: 60 / 255)
+ case .vertexai:
+ Color(red: 66 / 255, green: 133 / 255, blue: 244 / 255) // Google Blue
+ case .kilo:
+ Color(red: 242 / 255, green: 112 / 255, blue: 39 / 255) // Kilo orange
+ case .kiro:
+ Color(red: 255 / 255, green: 153 / 255, blue: 0 / 255) // AWS orange
+ case .augment:
+ Color(red: 99 / 255, green: 102 / 255, blue: 241 / 255) // Augment purple
+ case .jetbrains:
+ Color(red: 255 / 255, green: 51 / 255, blue: 153 / 255) // JetBrains pink
+ case .kimi:
+ Color(red: 254 / 255, green: 96 / 255, blue: 60 / 255) // Kimi orange
+ case .kimik2:
+ Color(red: 76 / 255, green: 0 / 255, blue: 255 / 255) // Kimi K2 purple
+ case .amp:
+ Color(red: 220 / 255, green: 38 / 255, blue: 38 / 255) // Amp red
+ case .ollama:
+ Color(red: 32 / 255, green: 32 / 255, blue: 32 / 255) // Ollama charcoal
+ case .synthetic:
+ Color(red: 20 / 255, green: 20 / 255, blue: 20 / 255) // Synthetic charcoal
+ case .openrouter:
+ Color(red: 111 / 255, green: 66 / 255, blue: 193 / 255) // OpenRouter purple
+ case .warp:
+ Color(red: 147 / 255, green: 139 / 255, blue: 180 / 255)
+ case .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)
+ }
+ }
}
enum WidgetFormat {
- static func percent(_ value: Double?) -> String {
- guard let value else { return "โ" }
- return String(format: "%.0f%%", value)
- }
-
- static func credits(_ value: Double) -> String {
- let formatter = NumberFormatter()
- formatter.numberStyle = .decimal
- formatter.maximumFractionDigits = 2
- formatter.minimumFractionDigits = 0
- return formatter.string(from: NSNumber(value: value)) ?? String(format: "%.2f", value)
- }
-
- static func costAndTokens(cost: Double?, tokens: Int?) -> String {
- let costText = cost.map(self.usd) ?? "โ"
- if let tokens {
- return "\(costText) ยท \(self.tokenCount(tokens))"
- }
- return costText
- }
-
- static func usd(_ value: Double) -> String {
- let formatter = NumberFormatter()
- formatter.numberStyle = .currency
- formatter.currencyCode = "USD"
- formatter.maximumFractionDigits = 2
- formatter.minimumFractionDigits = 2
- return formatter.string(from: NSNumber(value: value)) ?? String(format: "$%.2f", value)
- }
-
- static func tokenCount(_ value: Int) -> String {
- let formatter = NumberFormatter()
- formatter.numberStyle = .decimal
- formatter.maximumFractionDigits = 0
- let raw = formatter.string(from: NSNumber(value: value)) ?? "\(value)"
- return "\(raw) tokens"
- }
-
- static func relativeDate(_ date: Date) -> String {
- let formatter = RelativeDateTimeFormatter()
- formatter.unitsStyle = .short
- return formatter.localizedString(for: date, relativeTo: Date())
- }
+ static func percent(_ value: Double?) -> String {
+ guard let value else { return "โ" }
+ return String(format: "%.0f%%", value)
+ }
+
+ static func credits(_ value: Double) -> String {
+ let formatter = NumberFormatter()
+ formatter.numberStyle = .decimal
+ formatter.maximumFractionDigits = 2
+ formatter.minimumFractionDigits = 0
+ return formatter.string(from: NSNumber(value: value)) ?? String(format: "%.2f", value)
+ }
+
+ static func costAndTokens(cost: Double?, tokens: Int?) -> String {
+ let costText = cost.map(self.usd) ?? "โ"
+ if let tokens {
+ return "\(costText) ยท \(self.tokenCount(tokens))"
+ }
+ return costText
+ }
+
+ static func usd(_ value: Double) -> String {
+ let formatter = NumberFormatter()
+ formatter.numberStyle = .currency
+ formatter.currencyCode = "USD"
+ formatter.maximumFractionDigits = 2
+ formatter.minimumFractionDigits = 2
+ return formatter.string(from: NSNumber(value: value)) ?? String(format: "$%.2f", value)
+ }
+
+ static func tokenCount(_ value: Int) -> String {
+ let formatter = NumberFormatter()
+ formatter.numberStyle = .decimal
+ formatter.maximumFractionDigits = 0
+ let raw = formatter.string(from: NSNumber(value: value)) ?? "\(value)"
+ return "\(raw) tokens"
+ }
+
+ static func relativeDate(_ date: Date) -> String {
+ let formatter = RelativeDateTimeFormatter()
+ formatter.unitsStyle = .short
+ return formatter.localizedString(for: date, relativeTo: Date())
+ }
}
diff --git a/Tests/CodexBarTests/AbacusProviderTests.swift b/Tests/CodexBarTests/AbacusProviderTests.swift
new file mode 100644
index 000000000..b256ffdc2
--- /dev/null
+++ b/Tests/CodexBarTests/AbacusProviderTests.swift
@@ -0,0 +1,233 @@
+import Foundation
+import Testing
+@testable import CodexBarCore
+
+// MARK: - Descriptor Tests
+
+struct AbacusDescriptorTests {
+ @Test
+ func `descriptor has correct identity`() {
+ let descriptor = AbacusProviderDescriptor.descriptor
+ #expect(descriptor.id == .abacus)
+ #expect(descriptor.metadata.displayName == "Abacus AI")
+ #expect(descriptor.metadata.cliName == "abacusai")
+ }
+
+ @Test
+ func `descriptor supports credits not opus`() {
+ let meta = AbacusProviderDescriptor.descriptor.metadata
+ #expect(meta.supportsCredits == true)
+ #expect(meta.supportsOpus == false)
+ }
+
+ @Test
+ func `descriptor is not primary provider`() {
+ let meta = AbacusProviderDescriptor.descriptor.metadata
+ #expect(meta.isPrimaryProvider == false)
+ #expect(meta.defaultEnabled == false)
+ }
+
+ @Test
+ func `descriptor supports auto and web source modes`() {
+ let descriptor = AbacusProviderDescriptor.descriptor
+ #expect(descriptor.fetchPlan.sourceModes.contains(.auto))
+ #expect(descriptor.fetchPlan.sourceModes.contains(.web))
+ }
+
+ @Test
+ func `descriptor has no version detector`() {
+ let descriptor = AbacusProviderDescriptor.descriptor
+ #expect(descriptor.cli.versionDetector == nil)
+ }
+
+ @Test
+ func `descriptor does not support token cost`() {
+ let descriptor = AbacusProviderDescriptor.descriptor
+ #expect(descriptor.tokenCost.supportsTokenCost == false)
+ }
+
+ @Test
+ func `cli aliases include abacus-ai`() {
+ let descriptor = AbacusProviderDescriptor.descriptor
+ #expect(descriptor.cli.aliases.contains("abacus-ai"))
+ }
+
+ @Test
+ func `dashboard url points to compute points page`() {
+ let meta = AbacusProviderDescriptor.descriptor.metadata
+ #expect(meta.dashboardURL?.contains("compute-points") == true)
+ }
+}
+
+// MARK: - Usage Snapshot Conversion Tests
+
+struct AbacusUsageSnapshotTests {
+ @Test
+ func `converts full snapshot to usage snapshot`() {
+ let resetDate = Date(timeIntervalSince1970: 1_700_000_000)
+ let snapshot = AbacusUsageSnapshot(
+ creditsUsed: 250,
+ creditsTotal: 1000,
+ resetsAt: resetDate,
+ planName: "Pro")
+
+ let usage = snapshot.toUsageSnapshot()
+
+ #expect(usage.primary != nil)
+ #expect(abs((usage.primary?.usedPercent ?? 0) - 25.0) < 0.01)
+ #expect(usage.primary?.resetDescription == "250 / 1,000 credits")
+ #expect(usage.primary?.resetsAt == resetDate)
+ #expect(usage.primary?.windowMinutes == 30 * 24 * 60)
+ #expect(usage.secondary == nil)
+ #expect(usage.tertiary == nil)
+ #expect(usage.identity?.providerID == .abacus)
+ #expect(usage.identity?.loginMethod == "Pro")
+ }
+
+ @Test
+ func `handles zero usage`() {
+ let snapshot = AbacusUsageSnapshot(
+ creditsUsed: 0,
+ creditsTotal: 500,
+ resetsAt: nil,
+ planName: "Basic")
+
+ let usage = snapshot.toUsageSnapshot()
+ #expect(usage.primary?.usedPercent == 0.0)
+ #expect(usage.primary?.resetDescription == "0 / 500 credits")
+ }
+
+ @Test
+ func `handles full usage`() {
+ let snapshot = AbacusUsageSnapshot(
+ creditsUsed: 1000,
+ creditsTotal: 1000,
+ resetsAt: nil,
+ planName: nil)
+
+ let usage = snapshot.toUsageSnapshot()
+ #expect(abs((usage.primary?.usedPercent ?? 0) - 100.0) < 0.01)
+ #expect(usage.primary?.resetDescription == "1,000 / 1,000 credits")
+ }
+
+ @Test
+ func `handles nil credits gracefully`() {
+ let snapshot = AbacusUsageSnapshot(
+ creditsUsed: nil,
+ creditsTotal: nil,
+ resetsAt: nil,
+ planName: nil)
+
+ let usage = snapshot.toUsageSnapshot()
+ #expect(usage.primary?.usedPercent == 0.0)
+ #expect(usage.primary?.resetDescription == nil)
+ }
+
+ @Test
+ func `handles nil total with non-nil used`() {
+ let snapshot = AbacusUsageSnapshot(
+ creditsUsed: 100,
+ creditsTotal: nil,
+ resetsAt: nil,
+ planName: nil)
+
+ let usage = snapshot.toUsageSnapshot()
+ #expect(usage.primary?.usedPercent == 0.0)
+ }
+
+ @Test
+ func `handles zero total credits`() {
+ let snapshot = AbacusUsageSnapshot(
+ creditsUsed: 0,
+ creditsTotal: 0,
+ resetsAt: nil,
+ planName: nil)
+
+ let usage = snapshot.toUsageSnapshot()
+ #expect(usage.primary?.usedPercent == 0.0)
+ }
+
+ @Test
+ func `formats large credit values with comma grouping`() {
+ let snapshot = AbacusUsageSnapshot(
+ creditsUsed: 12345,
+ creditsTotal: 50000,
+ resetsAt: nil,
+ planName: nil)
+
+ let usage = snapshot.toUsageSnapshot()
+ #expect(usage.primary?.resetDescription == "12,345 / 50,000 credits")
+ }
+
+ @Test
+ func `formats fractional credit values`() {
+ let snapshot = AbacusUsageSnapshot(
+ creditsUsed: 42.5,
+ creditsTotal: 100,
+ resetsAt: nil,
+ planName: nil)
+
+ let usage = snapshot.toUsageSnapshot()
+ #expect(usage.primary?.resetDescription == "42.5 / 100 credits")
+ }
+
+ @Test
+ func `window minutes represents monthly cycle`() {
+ let snapshot = AbacusUsageSnapshot(
+ creditsUsed: 0,
+ creditsTotal: 100,
+ resetsAt: nil,
+ planName: nil)
+
+ let usage = snapshot.toUsageSnapshot()
+ // 30 days * 24 hours * 60 minutes = 43200
+ #expect(usage.primary?.windowMinutes == 43200)
+ }
+
+ @Test
+ func `identity has no email or organization`() {
+ let snapshot = AbacusUsageSnapshot(
+ creditsUsed: 0,
+ creditsTotal: 100,
+ resetsAt: nil,
+ planName: "Pro")
+
+ let usage = snapshot.toUsageSnapshot()
+ #expect(usage.identity?.accountEmail == nil)
+ #expect(usage.identity?.accountOrganization == nil)
+ }
+}
+
+// MARK: - Error Description Tests
+
+struct AbacusErrorTests {
+ @Test
+ func `noSessionCookie error mentions login`() {
+ let error = AbacusUsageError.noSessionCookie
+ #expect(error.errorDescription?.contains("log in") == true)
+ }
+
+ @Test
+ func `sessionExpired error mentions expired`() {
+ let error = AbacusUsageError.sessionExpired
+ #expect(error.errorDescription?.contains("expired") == true)
+ }
+
+ @Test
+ func `networkError includes message`() {
+ let error = AbacusUsageError.networkError("HTTP 500")
+ #expect(error.errorDescription?.contains("HTTP 500") == true)
+ }
+
+ @Test
+ func `parseFailed includes message`() {
+ let error = AbacusUsageError.parseFailed("Invalid JSON")
+ #expect(error.errorDescription?.contains("Invalid JSON") == true)
+ }
+
+ @Test
+ func `unauthorized error mentions login`() {
+ let error = AbacusUsageError.unauthorized
+ #expect(error.errorDescription?.contains("log in") == true)
+ }
+}
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 63f3aeaa0..2f4932934 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 (when enabled): `https://chatgpt.com/codex/settings/usage` via WebView + browser cookies.
@@ -182,4 +183,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.