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. CodexBar menu screenshot @@ -47,6 +47,7 @@ Linux support via Omarchy: community Waybar module and TUI, driven by the `codex - [Amp](docs/amp.md) โ€” Browser cookie-based authentication with Amp Free usage tracking. - [JetBrains AI](docs/jetbrains.md) โ€” Local XML-based quota from JetBrains IDE configuration; monthly credits tracking. - [OpenRouter](docs/openrouter.md) โ€” API token for credit-based usage tracking across multiple AI providers. +- [Abacus AI](docs/abacus.md) โ€” Browser cookie auth for ChatLLM/RouteLLM compute credit tracking. - Open to new providers: [provider authoring guide](docs/provider.md). ## Icon & Screenshot diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 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.