import AppKit import CodexBarCore import Foundation import Observation import SweetCookieKit // MARK: - Observation helpers @MainActor extension UsageStore { var menuObservationToken: Int { _ = self.snapshots _ = self.errors _ = self.knownLimitsAvailabilityByProvider _ = self.lastSourceLabels _ = self.lastFetchAttempts _ = (self.accountSnapshots, self.tokenAccountLiveStateProviders, self.codexAccountSnapshots) _ = self.kiloScopeSnapshots _ = self.claudeSwapAccountSnapshots _ = self.claudeSwapLastError _ = self.claudeSwapRevision _ = self.tokenSnapshots _ = self.tokenErrors _ = self.tokenRefreshInFlight _ = self.credits _ = self.lastCreditsError _ = self.openAIDashboard _ = self.lastOpenAIDashboardError _ = self.openAIDashboardRequiresLogin _ = self.openAIDashboardAttachmentRevision _ = self.versions _ = self.isRefreshing _ = self.hasForcedRefreshEnrichmentInFlight _ = self.refreshingProviders _ = self.pathDebugInfo _ = self.statuses _ = self.probeLogs _ = self.historicalPaceRevision _ = self.planUtilizationHistoryRevision _ = self.providerStorageFootprints return 0 } var iconObservationToken: Int { _ = self.snapshots _ = self.errors _ = self.knownLimitsAvailabilityByProvider _ = self.credits _ = self.lastCreditsError _ = self.openAIDashboard _ = self.lastOpenAIDashboardError _ = self.openAIDashboardRequiresLogin _ = self.refreshingProviders _ = self.statuses _ = self.historicalPaceRevision return 0 } func observeSettingsChanges() { withObservationTracking { _ = self.backgroundWorkSettingsObservationToken } onChange: { [weak self] in Task { @MainActor [weak self] in guard let self else { return } self.observeSettingsChanges() self.invalidateProviderAvailabilityCache() self.probeLogs = [:] guard self.startupBehavior.automaticallyStartsBackgroundWork else { return } self.startTimer() self.updateProviderRuntimes() await self.refreshHistoricalDatasetIfNeeded() await self.refreshForSettingsChange() } } } var backgroundWorkSettingsObservationToken: Int { _ = self.settings.backgroundWorkSettingsRevision return 0 } var attachedOpenAIDashboardSnapshot: OpenAIDashboardSnapshot? { guard self.openAIDashboardAttachmentAuthorized else { return nil } return self.openAIDashboard } private static func isRunningTestsProcess() -> Bool { let environment = ProcessInfo.processInfo.environment let testKeys = ["XCTestConfigurationFilePath", "XCTestSessionIdentifier", "SWIFT_TESTING_ENABLED"] return testKeys.contains(where: { environment[$0] != nil }) || CommandLine.arguments.contains { argument in argument.contains("xctest") || argument.contains("swift-testing") } } /// Returns the login method (plan type) for the specified provider, if available. private func loginMethod(for provider: UsageProvider) -> String? { self.snapshots[provider]?.loginMethod(for: provider) } /// Returns true if the Claude account appears to be a subscription (Max, Pro, Ultra, Team). /// Returns false for API users or when plan cannot be determined. func isClaudeSubscription() -> Bool { Self.isSubscriptionPlan(self.loginMethod(for: .claude)) } /// Determines if a login method string indicates a Claude subscription plan. /// Known subscription indicators: Max, Pro, Ultra, Team (case-insensitive). nonisolated static func isSubscriptionPlan(_ loginMethod: String?) -> Bool { ClaudePlan.isSubscriptionLoginMethod(loginMethod) } var preferredSnapshot: UsageSnapshot? { for provider in self.enabledProviders() { if let snap = self.snapshots[provider] { return snap } } return nil } } @MainActor @Observable final class UsageStore { nonisolated static let resetBoundaryRefreshGraceSeconds: TimeInterval = 30 nonisolated static let resetBoundaryRefreshMinimumDelaySeconds: TimeInterval = 5 private struct ProviderAvailabilityCacheEntry { let available: Bool let configRevision: Int let expiresAt: Date func isValid(now: Date, configRevision: Int) -> Bool { self.configRevision == configRevision && self.expiresAt > now } } struct AccountInfoCacheEntry { let account: AccountInfo let configRevision: Int let expiresAt: Date func isValid(now: Date, configRevision: Int) -> Bool { self.configRevision == configRevision && self.expiresAt > now } } enum CodexCreditsSource { case none case api case dashboardWeb } var snapshots: [UsageProvider: UsageSnapshot] = [:] var errors: [UsageProvider: String] = [:] var geminiObservedConsumerTierDeprecation = false var knownLimitsAvailabilityByProvider: [UsageProvider: UsageLimitsAvailability] = [:] var lastSourceLabels: [UsageProvider: String] = [:] var lastFetchAttempts: [UsageProvider: [ProviderFetchAttempt]] = [:] var accountSnapshots: [UsageProvider: [TokenAccountUsageSnapshot]] = [:] var tokenAccountLiveStateProviders: Set = [] var codexAccountSnapshots: [CodexAccountUsageSnapshot] = [] var kiloScopeSnapshots: [KiloScopeSnapshot] = [] var claudeSwapAccountSnapshots: [ProviderAccountUsageSnapshot] = [] var claudeSwapLastRefreshAt: Date? var claudeSwapLastError: String? var claudeSwapDetectedVersion: String? var claudeSwapRevision: UInt64 = 0 @ObservationIgnored var claudeSwapRefreshTask: Task? @ObservationIgnored var claudeSwapTransientState = ClaudeSwapTransientState() var tokenSnapshots: [UsageProvider: CostUsageTokenSnapshot] = [:] var tokenErrors: [UsageProvider: String] = [:] var tokenRefreshInFlight: Set = [] var credits: CreditsSnapshot? var lastCreditsError: String? var openAIDashboard: OpenAIDashboardSnapshot? var lastOpenAIDashboardError: String? var openAIDashboardRequiresLogin: Bool = false var openAIDashboardCookieImportStatus: String? var openAIDashboardCookieImportDebugLog: String? var versions: [UsageProvider: String] = [:] var isRefreshing = false var hasForcedRefreshEnrichmentInFlight = false var refreshingProviders: Set = [] var debugForceAnimation = false var pathDebugInfo: PathDebugSnapshot = .empty var statuses: [UsageProvider: ProviderStatus] = [:] var statusComponents: [UsageProvider: [ProviderStatusComponent]] = [:] var probeLogs: [UsageProvider: String] = [:] var historicalPaceRevision: Int = 0 var planUtilizationHistoryRevision: Int = 0 var providerStorageFootprints: [UsageProvider: ProviderStorageFootprint] = [:] @ObservationIgnored var lastCreditsSnapshot: CreditsSnapshot? @ObservationIgnored var lastCreditsSnapshotAccountKey: String? @ObservationIgnored var lastCreditsSource: CodexCreditsSource = .none @ObservationIgnored var creditsFailureStreak: Int = 0 @ObservationIgnored var openAIDashboardAttachmentAuthorized: Bool = false { didSet { guard self.openAIDashboardAttachmentAuthorized != oldValue else { return } self.openAIDashboardAttachmentRevision &+= 1 } } var openAIDashboardAttachmentRevision = 0 @ObservationIgnored var lastOpenAIDashboardSnapshot: OpenAIDashboardSnapshot? @ObservationIgnored var lastOpenAIDashboardAttachmentAuthorized: Bool = false @ObservationIgnored var lastOpenAIDashboardTargetEmail: String? @ObservationIgnored var lastOpenAIDashboardTargetIsolationKey: String? @ObservationIgnored var lastOpenAIDashboardAttemptAt: Date? @ObservationIgnored var lastOpenAIDashboardCookieImportAttemptAt: Date? @ObservationIgnored var lastOpenAIDashboardCookieImportEmail: String? @ObservationIgnored var lastCodexAccountScopedRefreshGuard: CodexAccountScopedRefreshGuard? @ObservationIgnored var lastCodexUsagePublicationGuard: CodexAccountScopedRefreshGuard? @ObservationIgnored var lastKnownLiveSystemCodexEmail: String? @ObservationIgnored var openAIWebAccountDidChange: Bool = false @ObservationIgnored var creditsRefreshTask: Task? @ObservationIgnored var creditsRefreshTaskKey: String? @ObservationIgnored var openAIDashboardBackgroundRefreshTask: Task? @ObservationIgnored var openAIDashboardBackgroundRefreshTaskKey: String? @ObservationIgnored var openAIDashboardRefreshTask: Task? @ObservationIgnored var openAIDashboardRefreshTaskKey: String? @ObservationIgnored var openAIDashboardRefreshTaskToken: UUID? @ObservationIgnored var _test_openAIDashboardCookieImportOverride: (@MainActor ( String?, Bool, ProviderCookieSource, CookieHeaderCache.Scope?, @escaping (String) -> Void) async throws -> OpenAIDashboardBrowserCookieImporter.ImportResult)? @ObservationIgnored var _test_openAIDashboardLoaderOverride: (@MainActor ( String?, @escaping (String) -> Void, Bool, TimeInterval) async throws -> OpenAIDashboardSnapshot)? @ObservationIgnored var _test_codexCreditsLoaderOverride: (@MainActor () async throws -> CreditsSnapshot)? @ObservationIgnored var _test_codexResetCreditsFetcherOverride: CodexResetCreditsFetcher? @ObservationIgnored var _test_widgetSnapshotSaveOverride: (@MainActor (WidgetSnapshot) async -> Void)? @ObservationIgnored var _test_providerRefreshOverride: (@MainActor (UsageProvider) async -> Void)? @ObservationIgnored var _test_providerFetchOutcomeOverride: (@MainActor ( UsageProvider) async -> ProviderFetchOutcome)? @ObservationIgnored var _test_tokenUsageRefreshOverride: (@MainActor (UsageProvider, Bool) async -> Void)? @ObservationIgnored var _test_tokenUsageSnapshotLoaderOverride: (@MainActor ( UsageProvider, Bool, Date, String?, Int) async throws -> CostUsageTokenSnapshot)? @ObservationIgnored var _test_cachedCodexTokenSnapshotLoaderOverride: (@MainActor ( Date, String?, Int) async -> (snapshot: CostUsageTokenSnapshot, lastRefreshAt: Date?)?)? @ObservationIgnored var _test_providerStatusFetchOverride: (@MainActor ( UsageProvider) async throws -> ProviderStatus)? @ObservationIgnored var _test_forcedRefreshEnrichmentWaitObserver: (@MainActor () -> Void)? @ObservationIgnored var _test_startupConnectivityRetryScheduled: (@MainActor (Int, TimeInterval) -> Void)? @ObservationIgnored var _test_startupConnectivityRetrySleepOverride: (@MainActor ( TimeInterval) async throws -> Void)? @ObservationIgnored var widgetSnapshotPersistTask: Task? @ObservationIgnored let codexFetcher: UsageFetcher @ObservationIgnored let claudeFetcher: any ClaudeUsageFetching @ObservationIgnored let costUsageFetcher: CostUsageFetcher @ObservationIgnored let browserDetection: BrowserDetection @ObservationIgnored private let registry: ProviderRegistry @ObservationIgnored let settings: SettingsStore @ObservationIgnored let environmentBase: [String: String] @ObservationIgnored private let sessionQuotaNotifier: any SessionQuotaNotifying @ObservationIgnored private let sessionQuotaLogger = CodexBarLog.logger(LogCategories.sessionQuota) @ObservationIgnored let openAIWebLogger = CodexBarLog.logger(LogCategories.openAIWeb) @ObservationIgnored private let tokenCostLogger = CodexBarLog.logger(LogCategories.tokenCost) @ObservationIgnored let augmentLogger = CodexBarLog.logger(LogCategories.augment) @ObservationIgnored let providerLogger = CodexBarLog.logger(LogCategories.providers) @ObservationIgnored let adaptiveRefreshLogger = CodexBarLog.logger(LogCategories.adaptiveRefresh) @ObservationIgnored var openAIWebDebugLines: [String] = [] @ObservationIgnored var failureGates: [UsageProvider: ConsecutiveFailureGate] = [:] @ObservationIgnored var tokenFailureGates: [UsageProvider: ConsecutiveFailureGate] = [:] @ObservationIgnored var providerSpecs: [UsageProvider: ProviderSpec] = [:] @ObservationIgnored let providerMetadata: [UsageProvider: ProviderMetadata] @ObservationIgnored var providerRuntimes: [UsageProvider: any ProviderRuntime] = [:] @ObservationIgnored var providerRefreshCoordinator = ProviderRefreshCoordinator() @ObservationIgnored var providerRefreshPublicationContexts: [UsageProvider: ( generation: UInt64, enablementRevision: UInt64, configRevision: UInt64, allowDisabled: Bool)] = [:] @ObservationIgnored var providerCleanupRevisions: [UsageProvider: UInt64] = [:] @ObservationIgnored private var providerAvailabilityCache: [UsageProvider: ProviderAvailabilityCacheEntry] = [:] @ObservationIgnored var accountInfoCache: [UsageProvider: AccountInfoCacheEntry] = [:] @ObservationIgnored private var timerTask: Task? /// In-memory only; resets on every launch. @ObservationIgnored private(set) var lastMenuOpenAt: Date? @ObservationIgnored var adaptiveRefreshScheduledAt: Date? @ObservationIgnored var tokenTimerTask: Task? @ObservationIgnored var tokenRefreshSequenceTask: Task? @ObservationIgnored var tokenRefreshSequenceToken: UUID? @ObservationIgnored var tokenRefreshSequenceProvider: UsageProvider? @ObservationIgnored var tokenRefreshRetryProviders: Set = [] @ObservationIgnored var forcedRefreshEnrichmentTask: Task? @ObservationIgnored var forcedRefreshEnrichmentToken: UUID? @ObservationIgnored var pendingForcedRefreshEnrichmentTask: Task? @ObservationIgnored var pendingForcedRefreshEnrichmentToken: UUID? @ObservationIgnored var forcedRefreshEnrichmentGeneration: UInt64 = 0 @ObservationIgnored var requiredRefreshTask: Task? @ObservationIgnored var requiredRefreshTaskToken: UUID? @ObservationIgnored var pendingRequiredRefreshRequest: RequiredRefreshRequest? @ObservationIgnored var requiredRefreshRequestGeneration: UInt64 = 0 @ObservationIgnored var requiredRefreshCompletedGeneration: UInt64 = 0 @ObservationIgnored var memoryPressureReliefTask: Task? @ObservationIgnored var startupConnectivityRetryTask: Task? @ObservationIgnored var startupConnectivityRetryNeeded = false @ObservationIgnored var startupConnectivityRetryRefreshActive = false @ObservationIgnored var storageRefreshTask: Task? @ObservationIgnored var storageRefreshGeneration: UInt64 = 0 @ObservationIgnored var storageRefreshInFlightSignature: String? @ObservationIgnored var storageRefreshInFlightRequestKey: String? @ObservationIgnored var lastStorageRefreshSignature: String? @ObservationIgnored var lastStorageRefreshRequestKey: String? @ObservationIgnored var lastStorageRefreshAt: Date? @ObservationIgnored var managedCodexAccountsForStorageOverride: [ManagedCodexAccount]? @ObservationIgnored private var pathDebugRefreshTask: Task? @ObservationIgnored var resetBoundaryRefreshTask: Task? @ObservationIgnored var scheduledResetBoundaryRefreshAt: Date? @ObservationIgnored var attemptedResetBoundaryRefreshes: Set = [] @ObservationIgnored var codexPlanHistoryBackfillTask: Task? @ObservationIgnored let historicalUsageHistoryStore: HistoricalUsageHistoryStore @ObservationIgnored let planUtilizationHistoryStore: PlanUtilizationHistoryStore @ObservationIgnored let codexAccountUsageSnapshotStore: (any CodexAccountUsageSnapshotStoring)? @ObservationIgnored var codexHistoricalDataset: CodexHistoricalDataset? @ObservationIgnored var codexHistoricalDatasetAccountKey: String? @ObservationIgnored var lastKnownResetSnapshots: [UsageProvider: UsageSnapshot] = [:] @ObservationIgnored var sessionQuotaTransitionStates: [UsageProvider: SessionQuotaTransitionState] = [:] @ObservationIgnored var codexSessionQuotaBaselineRequirement: CodexSessionQuotaBaselineRequirement? var codexSessionQuotaBaselineRequired: Bool { self.codexSessionQuotaBaselineRequirement != nil } @ObservationIgnored var quotaWarningState: [QuotaWarningStateKey: QuotaWarningState] = [:] @ObservationIgnored var predictivePaceWarningNotifiedKeys: Set = [] @ObservationIgnored var lastPermissionPromptNotificationAt: [UsageProvider: Date] = [:] @ObservationIgnored var lastTokenFetchAt: [UsageProvider: Date] = [:] @ObservationIgnored var lastTokenFetchScope: [UsageProvider: String] = [:] @ObservationIgnored var planUtilizationHistory: [UsageProvider: PlanUtilizationHistoryBuckets] = [:] /// Background load task; cleared on deinit and on the cancel test seam. @ObservationIgnored var planUtilizationHistoryLoadTask: Task? /// Set once after the load completes. Gates mutation paths and sync menu /// accessors so they cannot race the decode or write empty history back to disk. @ObservationIgnored var planUtilizationHistoryLoaded: Bool = false @ObservationIgnored var sessionLimitResetDetectorStates: [String: LimitResetDetectorState] = [:] @ObservationIgnored var weeklyLimitResetDetectorStates: [String: LimitResetDetectorState] = [:] @ObservationIgnored private var hasCompletedInitialRefresh: Bool = false @ObservationIgnored private let providerAvailabilityCacheTTL: TimeInterval = 1 @ObservationIgnored let accountInfoCacheTTL: TimeInterval = 30 @ObservationIgnored let tokenFetchTTL: TimeInterval = 60 * 60 @ObservationIgnored let tokenFetchTimeout: TimeInterval = 10 * 60 @ObservationIgnored let startupBehavior: StartupBehavior @ObservationIgnored let planUtilizationPersistenceCoordinator: PlanUtilizationHistoryPersistenceCoordinator init( fetcher: UsageFetcher, browserDetection: BrowserDetection, claudeFetcher: (any ClaudeUsageFetching)? = nil, costUsageFetcher: CostUsageFetcher = CostUsageFetcher(), settings: SettingsStore, registry: ProviderRegistry = .shared, historicalUsageHistoryStore: HistoricalUsageHistoryStore = HistoricalUsageHistoryStore(), planUtilizationHistoryStore: PlanUtilizationHistoryStore? = nil, codexAccountUsageSnapshotStore: (any CodexAccountUsageSnapshotStoring)? = nil, sessionQuotaNotifier: any SessionQuotaNotifying = SessionQuotaNotifier(), startupBehavior: StartupBehavior = .automatic, environmentBase: [String: String] = ProcessInfo.processInfo.environment, planUtilizationHistoryLoadGateForTesting: PlanUtilizationHistoryLoadGate? = nil) { self.codexFetcher = fetcher self.browserDetection = browserDetection self.claudeFetcher = claudeFetcher ?? ClaudeUsageFetcher(browserDetection: browserDetection) self.costUsageFetcher = costUsageFetcher self.settings = settings self.registry = registry self.environmentBase = environmentBase self.historicalUsageHistoryStore = historicalUsageHistoryStore self.startupBehavior = startupBehavior.resolved(isRunningTests: Self.isRunningTestsProcess()) let planHistoryStore = Self.resolvedPlanHistoryStore(planUtilizationHistoryStore, startup: self.startupBehavior) self.planUtilizationHistoryStore = planHistoryStore self.sessionQuotaNotifier = sessionQuotaNotifier self.codexAccountUsageSnapshotStore = codexAccountUsageSnapshotStore ?? (self.startupBehavior.automaticallyStartsBackgroundWork ? FileCodexAccountUsageSnapshotStore() : nil) self.planUtilizationPersistenceCoordinator = PlanUtilizationHistoryPersistenceCoordinator( store: planHistoryStore) self.providerMetadata = registry.metadata self .failureGates = Dictionary( uniqueKeysWithValues: UsageProvider.allCases .map { ($0, ConsecutiveFailureGate()) }) self.tokenFailureGates = Dictionary( uniqueKeysWithValues: UsageProvider.allCases .map { ($0, ConsecutiveFailureGate()) }) self.providerSpecs = registry.specs( settings: settings, metadata: self.providerMetadata, codexFetcher: fetcher, claudeFetcher: self.claudeFetcher, browserDetection: browserDetection, environmentBase: environmentBase) self.providerRuntimes = Dictionary(uniqueKeysWithValues: ProviderCatalog.all.compactMap { implementation in implementation.makeRuntime().map { (implementation.id, $0) } }) self.startPlanUtilizationHistoryLoad( gate: planUtilizationHistoryLoadGateForTesting, enabled: self.startupBehavior.automaticallyStartsBackgroundWork) self.sessionLimitResetDetectorStates = Self.loadLimitResetDetectorStates( from: settings.userDefaults, defaultsKey: Self.sessionLimitResetDetectorDefaultsKey, logName: "session") self.weeklyLimitResetDetectorStates = Self.loadWeeklyLimitResetDetectorStates(from: settings.userDefaults) if let codexAccountUsageSnapshotStore = self.codexAccountUsageSnapshotStore { self.codexAccountSnapshots = codexAccountUsageSnapshotStore.load( for: self.freshCodexVisibleAccountsForSnapshotHydration()) } self.logStartupState() self.bindSettings() self.pathDebugInfo = PathDebugSnapshot( codexBinary: nil, claudeBinary: nil, geminiBinary: nil, effectivePATH: PathBuilder.effectivePATH(purposes: [.rpc, .tty, .nodeTooling]), loginShellPATH: LoginShellPathCache.shared.current?.joined(separator: ":")) guard self.startupBehavior.automaticallyStartsBackgroundWork else { return } self.hydrateCachedTokenSnapshots() self.detectVersions() self.updateProviderRuntimes() Task { @MainActor [weak self] in self?.schedulePathDebugInfoRefresh() } LoginShellPathCache.shared.captureOnce { [weak self] _ in Task { @MainActor [weak self] in self?.schedulePathDebugInfoRefresh() } } Task { @MainActor [weak self] in await self?.refreshHistoricalDatasetIfNeeded() } Task { await self.refresh(enrichmentMode: .automatic) } self.startTimer() self.startTokenTimer() } var iconStyle: IconStyle { let enabled = self.enabledProviders() if enabled.count > 1 { return .combined } if let provider = enabled.first { return self.style(for: provider) } return .codex } var isStale: Bool { for provider in self.enabledProviders() where self.errors[provider] != nil { return true } return false } func enabledProviders() -> [UsageProvider] { // Use cached enablement to avoid repeated UserDefaults lookups in animation ticks. let enabled = self.settings.enabledProvidersOrdered(metadataByProvider: self.providerMetadata) let now = Date() return enabled.filter { self.isProviderAvailable($0, now: now) } } /// Enabled providers without availability filtering. Used for display (switcher, merge-icons). func enabledProvidersForDisplay() -> [UsageProvider] { self.settings.enabledProvidersOrdered(metadataByProvider: self.providerMetadata) } /// Providers that should actually participate in background refresh/status/token work. func enabledProvidersForBackgroundWork() -> [UsageProvider] { self.enabledProviders() } var statusChecksEnabled: Bool { self.settings.statusChecksEnabled } func metadata(for provider: UsageProvider) -> ProviderMetadata { self.providerMetadata[provider]! } var codexBrowserCookieOrder: BrowserCookieImportOrder { self.metadata(for: .codex).browserCookieOrder ?? Browser.defaultImportOrder } func snapshot(for provider: UsageProvider) -> UsageSnapshot? { self.snapshots[provider] } func sourceLabel(for provider: UsageProvider) -> String { var label = self.lastSourceLabels[provider] ?? "" if label.isEmpty { let descriptor = ProviderDescriptorRegistry.descriptor(for: provider) let modes = descriptor.fetchPlan.sourceModes if modes.count == 1, let mode = modes.first { label = mode.rawValue } else { let context = ProviderSourceLabelContext( provider: provider, settings: self.settings, store: self, descriptor: descriptor) label = ProviderCatalog.implementation(for: provider)? .defaultSourceLabel(context: context) ?? "auto" } } let context = ProviderSourceLabelContext( provider: provider, settings: self.settings, store: self, descriptor: ProviderDescriptorRegistry.descriptor(for: provider)) return ProviderCatalog.implementation(for: provider)? .decorateSourceLabel(context: context, baseLabel: label) ?? label } func fetchAttempts(for provider: UsageProvider) -> [ProviderFetchAttempt] { self.lastFetchAttempts[provider] ?? [] } func style(for provider: UsageProvider) -> IconStyle { self.providerSpecs[provider]?.style ?? .codex } func isStale(provider: UsageProvider) -> Bool { self.errors[provider] != nil } func knownLimitsAvailability(for provider: UsageProvider) -> UsageLimitsAvailability? { self.knownLimitsAvailabilityByProvider[provider] } func hasSatisfiedUsageFetch(for provider: UsageProvider) -> Bool { self.snapshot(for: provider) != nil || self.knownLimitsAvailability(for: provider)?.isUnavailable == true } func needsUsageRefreshRetry(for provider: UsageProvider) -> Bool { self.isStale(provider: provider) || !self.hasSatisfiedUsageFetch(for: provider) } func isEnabled(_ provider: UsageProvider) -> Bool { let enabled = self.settings.isProviderEnabledCached( provider: provider, metadataByProvider: self.providerMetadata) guard enabled else { return false } return self.isProviderAvailable(provider) } func isProviderAvailable(_ provider: UsageProvider) -> Bool { self.isProviderAvailable(provider, now: Date()) } private func isProviderAvailable(_ provider: UsageProvider, now: Date) -> Bool { guard provider != .codex else { return true } let configRevision = self.settings.configRevision if let cached = self.providerAvailabilityCache[provider], cached.isValid(now: now, configRevision: configRevision) { return cached.available } // Availability should mirror the effective fetch environment, including token-account overrides. // Otherwise providers (notably token-account-backed API providers) can fetch successfully but be // hidden from the menu because their credentials are not in ProcessInfo's environment. let environment = ProviderRegistry.makeEnvironment( base: self.environmentBase, provider: provider, settings: self.settings, tokenOverride: nil) let context = ProviderAvailabilityContext( provider: provider, settings: self.settings, environment: environment) let available = ProviderCatalog.implementation(for: provider)? .isAvailable(context: context) ?? true self.providerAvailabilityCache[provider] = ProviderAvailabilityCacheEntry( available: available, configRevision: configRevision, expiresAt: now.addingTimeInterval(self.providerAvailabilityCacheTTL)) return available } private func invalidateProviderAvailabilityCache() { self.providerAvailabilityCache.removeAll(keepingCapacity: true) } #if DEBUG @ObservationIgnored private(set) var completedRefreshCountForTesting = 0 #endif @discardableResult func runRefresh( enrichmentMode: RefreshEnrichmentMode = .automatic, startupConnectivityRetryAttempt: Int?, coalesceProviderRefreshesOverride: Bool? = nil, waitForRefreshAvailability: Bool = false) async -> Bool { if enrichmentMode == .automatic, waitForRefreshAvailability { return await self.enqueueRequiredRefresh( startupConnectivityRetryAttempt: startupConnectivityRetryAttempt, coalesceProviderRefreshesOverride: coalesceProviderRefreshesOverride) } guard !self.isRefreshing else { return false } guard enrichmentMode != .automatic || !self.hasForcedRefreshEnrichmentInFlight else { return false } let forcedBackgroundGeneration: UInt64? if enrichmentMode == .forcedBackground { self.forcedRefreshEnrichmentGeneration &+= 1 forcedBackgroundGeneration = self.forcedRefreshEnrichmentGeneration } else { forcedBackgroundGeneration = nil } self.prepareRefreshState() let refreshPhase = Self.refreshPhase(hasCompletedInitialRefresh: self.hasCompletedInitialRefresh) let openAIWebRefreshPhase = Self.openAIWebRefreshPhase( providerRefreshPhase: refreshPhase, startupConnectivityRetryAttempt: startupConnectivityRetryAttempt) let allowsStartupConnectivityRetry = refreshPhase == .startup || startupConnectivityRetryAttempt != nil self.startupConnectivityRetryRefreshActive = allowsStartupConnectivityRetry self.startupConnectivityRetryNeeded = false let displayEnabledProviders = self.enabledProvidersForDisplay() let enabledProviderSet = Set(displayEnabledProviders) let refreshProviders = self.enabledProvidersForBackgroundWork() let availableRefreshProviders = Set(self.enabledProviders()) let refreshStartedAt = Date() let completedRefresh = await ProviderRefreshContext.$current.withValue(refreshPhase) { self.isRefreshing = true defer { self.isRefreshing = false self.hasCompletedInitialRefresh = true self.startupConnectivityRetryRefreshActive = false } self.clearDisabledProviderState(enabledProviders: enabledProviderSet) self.clearUnavailableProviderState( displayEnabledProviders: enabledProviderSet, availableProviders: availableRefreshProviders) self.scheduleStorageFootprintRefresh(for: displayEnabledProviders) await withTaskGroup(of: Void.self) { group in for provider in refreshProviders { group.addTask { await self.refreshProvider( provider, coalesceIfRefreshing: coalesceProviderRefreshesOverride ?? (ProviderInteractionContext.current == .background)) } if availableRefreshProviders.contains(provider) { group.addTask { await self.refreshProviderStatus(provider) } } } if enrichmentMode == .forcedForeground { group.addTask { await self.refreshCreditsNow(minimumSnapshotUpdatedAt: refreshStartedAt) } } } guard !Task.isCancelled else { return false } if enrichmentMode == .automatic { self.scheduleCreditsRefreshIfNeeded(minimumSnapshotUpdatedAt: refreshStartedAt) } if enrichmentMode == .forcedForeground { await self.refreshTokenUsageSequenceNow(force: true) } else if enrichmentMode == .automatic { // Token-cost usage can be slow; run it outside regular/menu-open refreshes so we don't block UI. self.scheduleTokenRefresh() } // OpenAI web scrape depends on the current Codex account email (which can change after login/account // switch). Run this after Codex usage refresh so we don't accidentally scrape with stale credentials. if enrichmentMode == .forcedBackground { // Account ownership must fail closed before the responsive foreground pass returns; // only the expensive dashboard fetch belongs in the deferred enrichment tail. self.syncOpenAIWebState() } else { await self.refreshOpenAIWebAfterProviderRefresh( force: enrichmentMode == .forcedForeground, refreshPhase: openAIWebRefreshPhase) } if enrichmentMode == .forcedForeground, self.openAIDashboardRequiresLogin { await self.refreshProvider(.codex) await self.refreshCreditsNow(minimumSnapshotUpdatedAt: refreshStartedAt) } self.persistWidgetSnapshot(reason: "refresh") if let forcedBackgroundGeneration { self.enqueueForcedRefreshEnrichment( generation: forcedBackgroundGeneration, refreshStartedAt: refreshStartedAt, openAIWebRefreshPhase: openAIWebRefreshPhase) } return true } guard completedRefresh else { return false } self.scheduleResetBoundaryRefreshIfNeeded( normalRefreshInterval: self.normalRefreshIntervalForHeuristics()) if allowsStartupConnectivityRetry { self.completeStartupConnectivityRetryPass(currentAttempt: startupConnectivityRetryAttempt ?? 0) } if refreshPhase == .startup { self.scheduleMemoryPressureRelief() } #if DEBUG self.completedRefreshCountForTesting += 1 #endif return true } /// For demo/testing: drop the snapshot so the loading animation plays, then restore the last snapshot. func replayLoadingAnimation(duration: TimeInterval = 3) { let current = self.preferredSnapshot self.snapshots.removeAll() self.debugForceAnimation = true Task { @MainActor in try? await Task.sleep(for: .seconds(duration)) if let current, let provider = self.enabledProviders().first { self.snapshots[provider] = current } self.debugForceAnimation = false } } // MARK: - Private private func bindSettings() { self.observeSettingsChanges() } #if DEBUG @ObservationIgnored private(set) var refreshTimerSleepOverrideForTesting: Duration? /// Sets this store's timer sleep override and restarts the timer with it applied, so tests can /// observe multiple fixed/adaptive ticks without waiting real minutes. The reason/delay a tick /// computes and logs is unaffected; only how long it sleeps before acting on that decision /// changes. Instance-scoped (not a shared global) so concurrently running tests, each with their /// own `UsageStore`, cannot clobber one another's override. func restartTimerWithSleepOverrideForTesting(_ duration: Duration?) { self.refreshTimerSleepOverrideForTesting = duration self.startTimer() } #endif private func startTimer(preservingResetBoundaryRefresh: Bool = false) { self.timerTask?.cancel() self.adaptiveRefreshScheduledAt = nil if !preservingResetBoundaryRefresh { self.cancelResetBoundaryRefresh() } let frequency = self.settings.refreshFrequency guard frequency != .manual else { return } if frequency == .adaptive { // Background poller so the menu stays responsive; canceled when settings change or store // deallocates. Delay is recomputed before every tick from live power/thermal state and the // in-memory menu-open signal; the policy itself stays pure (Input is built here). `self` is // only strongly held for the brief, synchronous decision computation below, never across // the sleep — a weak reference lets the store deallocate mid-sleep, same as fixed mode. self.timerTask = Task.detached(priority: .utility) { [weak self] in while !Task.isCancelled { guard let sleepDuration = await Self.nextAdaptiveTimerSleepDuration(for: self) else { return } try? await Task.sleep(for: sleepDuration) guard !Task.isCancelled else { return } await self?.refresh(enrichmentMode: .automatic) } } return } guard let wait = frequency.seconds else { return } #if DEBUG let fixedTimerSleepOverride = self.refreshTimerSleepOverrideForTesting #else let fixedTimerSleepOverride: Duration? = nil #endif // Background poller so the menu stays responsive; canceled when settings change or store deallocates. // Fixed cadence is anchored to the scheduled tick time, not refresh completion, so slow provider // work doesn't permanently stretch a two-minute interval into "refresh duration + two minutes". self.timerTask = Task.detached(priority: .utility) { [weak self] in await Self.runFixedRefreshTimer( interval: .seconds(wait), sleepOverride: fixedTimerSleepOverride, refresh: { [weak self] in await self?.refresh(enrichmentMode: .automatic) }) } } deinit { self.timerTask?.cancel() self.tokenTimerTask?.cancel() self.tokenRefreshSequenceTask?.cancel() self.forcedRefreshEnrichmentTask?.cancel() self.pendingForcedRefreshEnrichmentTask?.cancel() self.requiredRefreshTask?.cancel() self.creditsRefreshTask?.cancel() self.openAIDashboardBackgroundRefreshTask?.cancel() self.openAIDashboardRefreshTask?.cancel() self.memoryPressureReliefTask?.cancel() self.startupConnectivityRetryTask?.cancel() self.storageRefreshTask?.cancel() self.codexPlanHistoryBackfillTask?.cancel() self.resetBoundaryRefreshTask?.cancel() self.planUtilizationHistoryLoadTask?.cancel() } enum SessionQuotaWindowSource: String { case primary case copilotSecondaryFallback case zaiTertiary case antigravityQuotaSummary case antigravityLegacy } struct QuotaWarningStateKey: Hashable { let provider: UsageProvider let window: QuotaWarningWindow /// Distinguishes independent extra rate windows that share a provider/window lane /// (e.g. multiple `claude-weekly-scoped-*` windows) so their fired-threshold state /// does not clobber each other or the primary session/weekly lanes. `nil` for the /// primary session and weekly lanes. let windowID: String? init(provider: UsageProvider, window: QuotaWarningWindow, windowID: String? = nil) { self.provider = provider self.window = window self.windowID = windowID } } struct QuotaWarningState { var lastRemaining: Double? var firedThresholds: Set = [] var source: SessionQuotaWindowSource? } func postQuotaWarning(_ event: QuotaWarningEvent, provider: UsageProvider) { self.sessionQuotaNotifier.postQuotaWarning( event: event, provider: provider, soundEnabled: self.settings.quotaWarningSoundEnabled, onScreenAlertEnabled: self.settings.quotaWarningOnScreenAlertEnabled) } func postPredictivePaceWarning(_ event: PredictivePaceWarningEvent, provider: UsageProvider, now: Date) { self.sessionQuotaNotifier.postPredictivePaceWarning( event: event, provider: provider, soundEnabled: self.settings.quotaWarningSoundEnabled, onScreenAlertEnabled: self.settings.quotaWarningOnScreenAlertEnabled, now: now) } func handleSessionQuotaTransition( provider: UsageProvider, snapshot: UsageSnapshot, codexOwnerKey: CodexSessionQuotaOwnerKey? = nil, now: Date = Date()) { // Session quota notifications are tied to the primary session window. Copilot free plans can // expose only chat quota, so allow Copilot to fall back to secondary for transition tracking. // Command Code synthesizes a depleted primary while subscription enrichment is unavailable. // Preserve the prior notification state for that placeholder, but accept positive credit data. if provider == .commandcode, snapshot.commandCodeSubscriptionEnrichmentUnavailable, SessionQuotaNotificationLogic.isDepleted(snapshot.primary?.remainingPercent) { return } if provider == .codex, !self.settings.sessionQuotaNotificationsEnabled { self.requireFreshCodexSessionQuotaBaseline(observedAt: snapshot.updatedAt) self.sessionQuotaLogger.debug("Codex session notifications disabled; cleared notification baseline") return } if provider == .codex, codexOwnerKey == nil { self.requireFreshCodexSessionQuotaBaseline(observedAt: snapshot.updatedAt) self.sessionQuotaLogger.debug("missing Codex session owner; cleared notification baseline") return } guard let sessionWindow = self.sessionQuotaWindow(provider: provider, snapshot: snapshot) else { if provider == .commandcode, snapshot.commandCodeSubscriptionEnrichmentUnavailable { return } if provider == .codex { if let previous = self.sessionQuotaTransitionStates[.codex] { if previous.codexOwnerKey != codexOwnerKey { self.requireFreshCodexSessionQuotaBaseline(observedAt: snapshot.updatedAt) } else { self.sessionQuotaTransitionStates[.codex] = previous.advancingObservationWatermark( to: snapshot.updatedAt) } } else if self.codexSessionQuotaBaselineRequirement != nil { self.requireFreshCodexSessionQuotaBaseline(observedAt: snapshot.updatedAt) } self.sessionQuotaLogger.debug("missing Codex session window; retained notification baseline") } else { self.clearSessionQuotaTransitionState(provider: provider) } return } guard !sessionWindow.window.isSyntheticPlaceholder else { return } let currentRemaining = sessionWindow.window.remainingPercent let currentSource = sessionWindow.source let currentResetBoundary = sessionWindow.window.resetsAt if provider == .codex, let requirement = self.codexSessionQuotaBaselineRequirement, !requirement.admits(observedAt: snapshot.updatedAt) { self.sessionQuotaLogger.debug("ignored stale session observation while awaiting a fresh Codex baseline") return } let previousState = self.sessionQuotaTransitionStates[provider] let forceBaseline = provider == .codex && self.codexSessionQuotaBaselineRequirement != nil let evaluation = SessionQuotaTransitionReducer.evaluate( previous: previousState, observation: SessionQuotaTransitionObservation( provider: provider, remaining: currentRemaining, source: currentSource, resetBoundary: currentResetBoundary, observedAt: snapshot.updatedAt, evaluationTime: now, codexOwnerKey: codexOwnerKey), notificationsEnabled: self.settings.sessionQuotaNotificationsEnabled, forceBaseline: forceBaseline) self.sessionQuotaTransitionStates[provider] = evaluation.state if provider == .codex { self.codexSessionQuotaBaselineRequirement = nil } let providerText = provider.rawValue let previousRemaining = previousState?.remaining switch evaluation.outcome { case .none: if SessionQuotaNotificationLogic.isDepleted(currentRemaining) || SessionQuotaNotificationLogic.isDepleted(previousRemaining) { let reason = self.settings.sessionQuotaNotificationsEnabled ? "no transition" : "notifications disabled" self.sessionQuotaLogger.debug( "\(reason): provider=\(providerText) " + "prev=\(previousRemaining ?? -1) curr=\(currentRemaining)") } case .baselineChanged: self.sessionQuotaLogger.debug( "session notification baseline changed: provider=\(providerText) curr=\(currentRemaining)") case .staleCodexObservation: self.sessionQuotaLogger.debug( "ignored stale session observation: provider=\(providerText) curr=\(currentRemaining)") case .suppressedCodexRestore: self.sessionQuotaLogger.info( "suppressed transient restore: provider=\(providerText) " + "prev=\(previousRemaining ?? -1) curr=\(currentRemaining)") case .awaitingCodexRestoreConfirmation: self.sessionQuotaLogger.info( "awaiting restore confirmation: provider=\(providerText) " + "prev=\(previousRemaining ?? -1) curr=\(currentRemaining)") case .depleted, .restored: let transition = evaluation.outcome.transition self.sessionQuotaLogger.info( "transition \(String(describing: transition)): provider=\(providerText) " + "prev=\(previousRemaining ?? -1) curr=\(currentRemaining)") self.sessionQuotaNotifier.post(transition: transition, provider: provider, badge: nil) } } } extension UsageStore { func debugDumpClaude() async { let fetcher = ClaudeUsageFetcher( browserDetection: self.browserDetection, keepCLISessionsAlive: self.settings.debugKeepCLISessionsAlive) let output = await fetcher.debugRawProbe(model: "sonnet") let url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("codexbar-claude-probe.txt") try? output.write(to: url, atomically: true, encoding: .utf8) await MainActor.run { let snippet = String(output.prefix(180)).replacingOccurrences(of: "\n", with: " ") self.knownLimitsAvailabilityByProvider.removeValue(forKey: .claude) self.errors[.claude] = "[Claude] \(snippet) (saved: \(url.path))" NSWorkspace.shared.open(url) } } func dumpLog(toFileFor provider: UsageProvider) async -> URL? { let text = await self.debugLog(for: provider) let filename = "codexbar-\(provider.rawValue)-probe.txt" let url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(filename) do { try text.write(to: url, atomically: true, encoding: .utf8) _ = await MainActor.run { NSWorkspace.shared.open(url) } return url } catch { await MainActor.run { self.knownLimitsAvailabilityByProvider.removeValue(forKey: provider) self.errors[provider] = "Failed to save log: \(error.localizedDescription)" } return nil } } func debugAugmentDump() async -> String { await AugmentStatusProbe.latestDumps() } // swiftlint:disable:next function_body_length cyclomatic_complexity func debugLog(for provider: UsageProvider) async -> String { if let cached = self.probeLogs[provider], !cached.isEmpty { return cached } let claudeWebExtrasEnabled = self.settings.claudeWebExtrasEnabled let claudeUsageDataSource = self.settings.claudeUsageDataSource let claudeCookieSource = self.settings.claudeCookieSource let claudeCookieHeader = self.settings.claudeCookieHeader let claudeDebugConfiguration: ClaudeDebugLogConfiguration? = if provider == .claude { await self.makeClaudeDebugConfiguration( fallbackUsageDataSource: claudeUsageDataSource, fallbackWebExtrasEnabled: claudeWebExtrasEnabled, fallbackCookieSource: claudeCookieSource, fallbackCookieHeader: claudeCookieHeader) } else { nil } let cursorCookieSource = self.settings.cursorCookieSource let cursorCookieHeader = self.settings.cursorCookieHeader let ampCookieSource = self.settings.ampCookieSource let ampCookieHeader = self.settings.ampCookieHeader let ollamaCookieSource = self.settings.ollamaCookieSource let ollamaCookieHeader = self.settings.ollamaCookieHeader let processEnvironment = self.environmentBase let openAIDebugContext = self.openAIAPIKeyDebugContext(processEnvironment: processEnvironment) let azureOpenAIDebugContext = self.azureOpenAIAPIKeyDebugContext(processEnvironment: processEnvironment) let openRouterDebugContext = self.openRouterAPIKeyDebugContext(processEnvironment: processEnvironment) let crossModelDebugContext = self.crossModelAPIKeyDebugContext(processEnvironment: processEnvironment) let elevenLabsDebugContext = self.elevenLabsAPIKeyDebugContext(processEnvironment: processEnvironment) let deepSeekHasEnvToken = DeepSeekSettingsReader.apiKey(environment: processEnvironment) != nil let deepSeekHasTokenAccount = self.settings.selectedTokenAccount(for: .deepseek) != nil let deepSeekEnvironment = ProviderRegistry.makeEnvironment( base: processEnvironment, provider: .deepseek, settings: self.settings, tokenOverride: nil) let codexFetcher = self.codexFetcher let browserDetection = self.browserDetection let claudeDebugExecutionContext = self.currentClaudeDebugExecutionContext() let text = await Task.detached(priority: .utility) { () -> String in let unimplementedDebugLogMessages: [UsageProvider: String] = [ .gemini: "Gemini debug log not yet implemented", .antigravity: "Antigravity debug log not yet implemented", .opencode: "OpenCode debug log not yet implemented", .alibaba: "Alibaba Coding Plan debug log not yet implemented", .alibabatokenplan: "Alibaba Token Plan debug log not yet implemented", .factory: "Droid debug log not yet implemented", .copilot: "Copilot debug log not yet implemented", .manus: "Manus debug log not yet implemented", .vertexai: "Vertex AI debug log not yet implemented", .kilo: "Kilo debug log not yet implemented", .kiro: "Kiro debug log not yet implemented", .kimi: "Kimi debug log not yet implemented", .kimik2: "Kimi K2 debug log not yet implemented", .jetbrains: "JetBrains AI debug log not yet implemented", .mimo: "Xiaomi MiMo debug log not yet implemented", .doubao: "Doubao debug log not yet implemented", .sakana: "Sakana AI debug log not yet implemented", .venice: "Venice debug log not yet implemented", .commandcode: "Command Code debug log not yet implemented", .qoder: "Qoder debug log not yet implemented", .stepfun: "StepFun debug log not yet implemented", .bedrock: "Bedrock debug log not yet implemented", .grok: "Grok debug log not yet implemented", .groq: "Groq debug log not yet implemented", .t3chat: "T3 Chat debug log not yet implemented", .llmproxy: "LLM Proxy debug log not yet implemented", .litellm: "LiteLLM debug log not yet implemented", .deepgram: "Deepgram debug log not yet implemented", .chutes: "Chutes debug log not yet implemented", .clawrouter: "ClawRouter debug log not yet implemented", .wayfinder: "Wayfinder debug log not yet implemented", .sub2api: "sub2api debug log not yet implemented", ] let buildText = { switch provider { case .codex: return await codexFetcher.debugRawRateLimits() case .openai: return Self.apiKeyDebugLine(openAIDebugContext) case .azureopenai: return Self.apiKeyDebugLine(azureOpenAIDebugContext) case .claude: guard let claudeDebugConfiguration else { return "Claude debug log configuration unavailable" } return await claudeDebugExecutionContext.apply { await Self.debugClaudeLog( browserDetection: browserDetection, configuration: claudeDebugConfiguration) } case .zai: let resolution = ProviderTokenResolver.zaiResolution() let hasAny = resolution != nil let source = resolution?.source.rawValue ?? "none" return "Z_AI_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" case .synthetic: let resolution = ProviderTokenResolver.syntheticResolution() let hasAny = resolution != nil let source = resolution?.source.rawValue ?? "none" return "SYNTHETIC_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" case .cursor: return await Self.debugCursorLog( browserDetection: browserDetection, cursorCookieSource: cursorCookieSource, cursorCookieHeader: cursorCookieHeader) case .minimax: let tokenResolution = ProviderTokenResolver.minimaxTokenResolution() let cookieResolution = ProviderTokenResolver.minimaxCookieResolution() let tokenSource = tokenResolution?.source.rawValue ?? "none" let cookieSource = cookieResolution?.source.rawValue ?? "none" return "MINIMAX_API_KEY=\(tokenResolution == nil ? "missing" : "present") " + "source=\(tokenSource) MINIMAX_COOKIE=\(cookieResolution == nil ? "missing" : "present") " + "source=\(cookieSource)" case .alibaba: let resolution = ProviderTokenResolver.alibabaTokenResolution() let hasAny = resolution != nil let source = resolution?.source.rawValue ?? "none" return "ALIBABA_CODING_PLAN_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" case .augment: return await Self.debugAugmentLog() case .amp: return await Self.debugAmpLog( browserDetection: browserDetection, ampCookieSource: ampCookieSource, ampCookieHeader: ampCookieHeader) case .ollama: return await Self.debugOllamaLog( browserDetection: browserDetection, ollamaCookieSource: ollamaCookieSource, ollamaCookieHeader: ollamaCookieHeader) case .openrouter: return Self.apiKeyDebugLine(openRouterDebugContext) case .crossmodel: return Self.apiKeyDebugLine(crossModelDebugContext) case .elevenlabs: return Self.apiKeyDebugLine(elevenLabsDebugContext) case .warp: let resolution = ProviderTokenResolver.warpResolution() let hasAny = resolution != nil let source = resolution?.source.rawValue ?? "none" return "WARP_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" case .deepseek: return Self.apiKeyDebugLine( label: "DEEPSEEK_API_KEY", resolution: ProviderTokenResolver.deepseekResolution(environment: deepSeekEnvironment), configToken: nil, hasEnvToken: deepSeekHasEnvToken, hasTokenAccount: deepSeekHasTokenAccount) case .gemini, .antigravity, .opencode, .opencodego, .alibabatokenplan, .factory, .copilot, .devin, .vertexai, .kilo, .kiro, .kimi, .kimik2, .moonshot, .jetbrains, .perplexity, .mimo, .doubao, .sakana, .abacus, .mistral, .codebuff, .crof, .windsurf, .venice, .manus, .commandcode, .qoder, .stepfun, .bedrock, .grok, .groq, .t3chat, .llmproxy, .litellm, .zed, .deepgram, .poe, .chutes, .clawrouter, .wayfinder, .sub2api: return unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented" } } return await claudeDebugExecutionContext.apply { await buildText() } }.value self.probeLogs[provider] = text return text } private func makeClaudeDebugConfiguration( fallbackUsageDataSource: ClaudeUsageDataSource, fallbackWebExtrasEnabled: Bool, fallbackCookieSource: ProviderCookieSource, fallbackCookieHeader: String) async -> ClaudeDebugLogConfiguration { await MainActor.run { let sourceMode = self.sourceMode(for: .claude) let snapshot = ProviderRegistry.makeSettingsSnapshot(settings: self.settings, tokenOverride: nil) let environment = ProviderRegistry.makeEnvironment( base: self.environmentBase, provider: .claude, settings: self.settings, tokenOverride: nil) let claudeSettings = snapshot.claude ?? ProviderSettingsSnapshot.ClaudeProviderSettings( usageDataSource: fallbackUsageDataSource, webExtrasEnabled: fallbackWebExtrasEnabled, cookieSource: fallbackCookieSource, manualCookieHeader: fallbackCookieHeader) return ClaudeDebugLogConfiguration( runtime: CodexBarCore.ProviderRuntime.app, sourceMode: sourceMode, environment: environment, webExtrasEnabled: claudeSettings.webExtrasEnabled, usageDataSource: claudeSettings.usageDataSource, cookieSource: claudeSettings.cookieSource, cookieHeader: claudeSettings.manualCookieHeader ?? "", keepCLISessionsAlive: snapshot.debugKeepCLISessionsAlive) } } private struct ClaudeDebugExecutionContext { let interaction: ProviderInteraction let refreshPhase: ProviderRefreshPhase #if DEBUG let keychainServiceOverride: String? let credentialsURLOverride: URL? let testingOverrides: ClaudeOAuthCredentialsStore.TestingOverridesSnapshot let keychainDeniedUntilStoreOverride: ClaudeOAuthKeychainAccessGate.DeniedUntilStore? let keychainPromptModeOverride: ClaudeOAuthKeychainPromptMode? let keychainReadStrategyOverride: ClaudeOAuthKeychainReadStrategy? let cliPathOverride: String? let statusFetchOverride: ClaudeStatusProbe.FetchOverride? #endif func apply(_ operation: () async -> T) async -> T { await ProviderInteractionContext.$current.withValue(self.interaction) { await ProviderRefreshContext.$current.withValue(self.refreshPhase) { #if DEBUG return await KeychainCacheStore.withServiceOverrideForTesting(self.keychainServiceOverride) { await ClaudeOAuthCredentialsStore .withCredentialsURLOverrideForTesting(self.credentialsURLOverride) { await ClaudeOAuthCredentialsStore .withTestingOverridesSnapshotForTask(self.testingOverrides) { await ClaudeOAuthKeychainAccessGate .withDeniedUntilStoreOverrideForTesting(self .keychainDeniedUntilStoreOverride) { await ClaudeOAuthKeychainPromptPreference .withTaskOverrideForTesting(self.keychainPromptModeOverride) { await ClaudeOAuthKeychainReadStrategyPreference .withTaskOverrideForTesting(self .keychainReadStrategyOverride) { await ClaudeCLIResolver .withResolvedBinaryPathOverrideForTesting(self .cliPathOverride) { await ClaudeStatusProbe .withFetchOverrideForTesting(self .statusFetchOverride) { await operation() } } } } } } } } #else return await operation() #endif } } } } private func currentClaudeDebugExecutionContext() -> ClaudeDebugExecutionContext { #if DEBUG ClaudeDebugExecutionContext( interaction: ProviderInteractionContext.current, refreshPhase: ProviderRefreshContext.current, keychainServiceOverride: KeychainCacheStore.currentServiceOverrideForTesting, credentialsURLOverride: ClaudeOAuthCredentialsStore.currentCredentialsURLOverrideForTesting, testingOverrides: ClaudeOAuthCredentialsStore.currentTestingOverridesSnapshotForTask, keychainDeniedUntilStoreOverride: ClaudeOAuthKeychainAccessGate.currentDeniedUntilStoreOverrideForTesting, keychainPromptModeOverride: ClaudeOAuthKeychainPromptPreference.currentTaskOverrideForTesting, keychainReadStrategyOverride: ClaudeOAuthKeychainReadStrategyPreference.currentTaskOverrideForTesting, cliPathOverride: ClaudeCLIResolver.currentResolvedBinaryPathOverrideForTesting, statusFetchOverride: ClaudeStatusProbe.currentFetchOverrideForTesting) #else ClaudeDebugExecutionContext( interaction: ProviderInteractionContext.current, refreshPhase: ProviderRefreshContext.current) #endif } private static func debugCursorLog( browserDetection: BrowserDetection, cursorCookieSource: ProviderCookieSource, cursorCookieHeader: String) async -> String { await runWithTimeout(seconds: 15) { var lines: [String] = [] do { let probe = CursorStatusProbe(browserDetection: browserDetection) let snapshot: CursorStatusSnapshot = if cursorCookieSource == .manual, let normalizedHeader = CookieHeaderNormalizer .normalize(cursorCookieHeader) { try await probe.fetchWithManualCookies(normalizedHeader) } else { try await probe.fetch { msg in lines.append("[cursor-cookie] \(msg)") } } lines.append("") lines.append("Cursor Status Summary:") lines.append("membershipType=\(snapshot.membershipType ?? "nil")") lines.append("accountEmail=\(snapshot.accountEmail ?? "nil")") lines.append("planPercentUsed=\(snapshot.planPercentUsed)%") lines.append("planUsedUSD=$\(snapshot.planUsedUSD)") lines.append("planLimitUSD=$\(snapshot.planLimitUSD)") lines.append("onDemandUsedUSD=$\(snapshot.onDemandUsedUSD)") lines.append("onDemandLimitUSD=\(snapshot.onDemandLimitUSD.map { "$\($0)" } ?? "nil")") if let teamUsed = snapshot.teamOnDemandUsedUSD { lines.append("teamOnDemandUsedUSD=$\(teamUsed)") } if let teamLimit = snapshot.teamOnDemandLimitUSD { lines.append("teamOnDemandLimitUSD=$\(teamLimit)") } lines.append("billingCycleEnd=\(snapshot.billingCycleEnd?.description ?? "nil")") if let rawJSON = snapshot.rawJSON { lines.append("") lines.append("Raw API Response:") lines.append(rawJSON) } return lines.joined(separator: "\n") } catch { lines.append("") lines.append("Cursor probe failed: \(error.localizedDescription)") return lines.joined(separator: "\n") } } } private static func debugAugmentLog() async -> String { await runWithTimeout(seconds: 15) { let probe = AugmentStatusProbe() return await probe.debugRawProbe() } } private static func debugAmpLog( browserDetection: BrowserDetection, ampCookieSource: ProviderCookieSource, ampCookieHeader: String) async -> String { await runWithTimeout(seconds: 15) { let fetcher = AmpUsageFetcher(browserDetection: browserDetection) let manualHeader = ampCookieSource == .manual ? CookieHeaderNormalizer.normalize(ampCookieHeader) : nil return await fetcher.debugRawProbe(cookieHeaderOverride: manualHeader) } } private static func debugOllamaLog( browserDetection: BrowserDetection, ollamaCookieSource: ProviderCookieSource, ollamaCookieHeader: String) async -> String { await runWithTimeout(seconds: 15) { let fetcher = OllamaUsageFetcher(browserDetection: browserDetection) let manualHeader = ollamaCookieSource == .manual ? CookieHeaderNormalizer.normalize(ollamaCookieHeader) : nil return await fetcher.debugRawProbe( cookieHeaderOverride: manualHeader, manualCookieMode: ollamaCookieSource == .manual) } } private func detectVersions() { let implementations = ProviderCatalog.all let browserDetection = self.browserDetection Task { @MainActor [weak self] in let resolved = await Task.detached { () -> [UsageProvider: String] in var resolved: [UsageProvider: String] = [:] await withTaskGroup(of: (UsageProvider, String?).self) { group in for implementation in implementations { let context = ProviderVersionContext( provider: implementation.id, browserDetection: browserDetection) group.addTask { await (implementation.id, implementation.detectVersion(context: context)) } } for await (provider, version) in group { guard let version, !version.isEmpty else { continue } resolved[provider] = version } } return resolved }.value self?.versions = resolved } } @MainActor private func schedulePathDebugInfoRefresh() { self.pathDebugRefreshTask?.cancel() self.pathDebugRefreshTask = Task { [weak self] in do { try await Task.sleep(nanoseconds: 150_000_000) } catch { return } await self?.refreshPathDebugInfo() } } private func runBackgroundSnapshot( _ snapshot: @escaping @Sendable () async -> PathDebugSnapshot) async { let result = await snapshot() await MainActor.run { self.pathDebugInfo = result } } private func refreshPathDebugInfo() async { await self.runBackgroundSnapshot { await PathBuilder.debugSnapshotAsync(purposes: [.rpc, .tty, .nodeTooling]) } } func refreshTokenUsage(_ provider: UsageProvider, force: Bool) async { guard ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.supportsTokenCost else { self.tokenSnapshots.removeValue(forKey: provider) self.tokenErrors[provider] = nil self.tokenFailureGates[provider]?.reset() self.lastTokenFetchAt.removeValue(forKey: provider) self.lastTokenFetchScope.removeValue(forKey: provider) return } if Self.tokenCostRequiresProviderSnapshot(provider) { if let snapshot = self.tokenSnapshot(fromProviderSnapshot: self.snapshots[provider], provider: provider) { self.tokenSnapshots[provider] = snapshot self.tokenErrors[provider] = nil self.tokenFailureGates[provider]?.recordSuccess() self.persistWidgetSnapshot(reason: "token-usage") } else { self.tokenSnapshots.removeValue(forKey: provider) self.tokenErrors[provider] = nil self.tokenFailureGates[provider]?.reset() } return } guard self.settings.costUsageEnabled else { self.tokenSnapshots.removeValue(forKey: provider) self.tokenErrors[provider] = nil self.tokenFailureGates[provider]?.reset() self.lastTokenFetchAt.removeValue(forKey: provider) self.lastTokenFetchScope.removeValue(forKey: provider) return } guard self.isEnabled(provider) else { self.tokenSnapshots.removeValue(forKey: provider) self.tokenErrors[provider] = nil self.tokenFailureGates[provider]?.reset() self.lastTokenFetchAt.removeValue(forKey: provider) self.lastTokenFetchScope.removeValue(forKey: provider) return } guard !self.tokenRefreshInFlight.contains(provider) else { return } let now = Date() let historyDays = self.settings.costUsageHistoryDays let costScope = self.tokenCostScope(for: provider) let costScopeSignature = "\(costScope.signature)|historyDays=\(historyDays)" let publicationRevision = self.providerPublicationRevision(for: provider) if !force, let last = self.lastTokenFetchAt[provider], self.lastTokenFetchScope[provider] == costScopeSignature, now.timeIntervalSince(last) < self.tokenFetchTTL { return } self.lastTokenFetchAt[provider] = now self.lastTokenFetchScope[provider] = costScopeSignature self.tokenRefreshInFlight.insert(provider) defer { self.tokenRefreshInFlight.remove(provider) } if let override = self._test_tokenUsageRefreshOverride { await override(provider, force) if Task.isCancelled { self.lastTokenFetchAt.removeValue(forKey: provider) self.lastTokenFetchScope.removeValue(forKey: provider) } return } let startedAt = Date() let providerText = provider.rawValue self.tokenCostLogger .debug("cost usage start provider=\(providerText) force=\(force)") do { // Codex cost usage scans local session logs from this machine. That data is // intentionally presented as provider-level local telemetry rather than managed-account // remote state, so managed Codex account selection does not retarget that fetch. // If the UI later needs account-scoped token history, it should label and source that // separately instead of silently changing the meaning of this section. let snapshot = try await self.loadTokenUsageSnapshot( provider: provider, force: force, now: now, codexHomePath: costScope.codexHomePath, historyDays: historyDays) try Task.checkCancellation() guard self.tokenRefreshPublicationIsCurrent( provider: provider, publicationRevision: publicationRevision, costScopeSignature: costScopeSignature) else { self.clearTokenFetchMetadataIfMatching( provider: provider, attemptedAt: now, costScopeSignature: costScopeSignature) self.requestTokenRefreshAfterStaleCompletion(for: provider) return } guard !snapshot.daily.isEmpty else { self.tokenSnapshots.removeValue(forKey: provider) self.tokenErrors[provider] = Self.tokenCostNoDataMessage(for: provider) self.tokenFailureGates[provider]?.recordSuccess() return } let duration = Date().timeIntervalSince(startedAt) let sessionCost = snapshot.sessionCostUSD .map { UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) } ?? "—" let monthCost = snapshot.last30DaysCostUSD .map { UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) } ?? "—" let durationText = String(format: "%.2f", duration) let message = "cost usage success provider=\(providerText) " + "duration=\(durationText)s " + "today=\(sessionCost) " + "historyDays=\(historyDays) windowCost=\(monthCost)" self.tokenCostLogger.info(message) self.tokenSnapshots[provider] = snapshot self.tokenErrors[provider] = nil self.tokenFailureGates[provider]?.recordSuccess() self.persistWidgetSnapshot(reason: "token-usage") } catch { guard self.tokenRefreshPublicationIsCurrent( provider: provider, publicationRevision: publicationRevision, costScopeSignature: costScopeSignature) else { self.clearTokenFetchMetadataIfMatching( provider: provider, attemptedAt: now, costScopeSignature: costScopeSignature) self.requestTokenRefreshAfterStaleCompletion(for: provider) return } if error is CancellationError { self.clearTokenFetchMetadataIfMatching( provider: provider, attemptedAt: now, costScopeSignature: costScopeSignature) return } let duration = Date().timeIntervalSince(startedAt) let msg = error.localizedDescription let durationText = String(format: "%.2f", duration) let message = "cost usage failed provider=\(providerText) duration=\(durationText)s error=\(msg)" self.tokenCostLogger.error(message) if Self.tokenFetchFailureAllowsEarlyRetry(error) { self.clearTokenFetchMetadataIfMatching( provider: provider, attemptedAt: now, costScopeSignature: costScopeSignature) } let hadPriorData = self.tokenSnapshots[provider] != nil let shouldSurface = self.tokenFailureGates[provider]? .shouldSurfaceError(onFailureWithPriorData: hadPriorData) ?? true if shouldSurface { self.tokenErrors[provider] = error.localizedDescription self.tokenSnapshots.removeValue(forKey: provider) } else { self.tokenErrors[provider] = nil } } } private func tokenRefreshPublicationIsCurrent( provider: UsageProvider, publicationRevision: ProviderPublicationRevision, costScopeSignature: String) -> Bool { guard self.providerPublicationRevisionIsCurrent(publicationRevision, for: provider), self.settings.costUsageEnabled, self.isEnabled(provider) else { return false } let scope = self.tokenCostScope(for: provider) let currentSignature = "\(scope.signature)|historyDays=\(self.settings.costUsageHistoryDays)" return currentSignature == costScopeSignature } private func clearTokenFetchMetadataIfMatching( provider: UsageProvider, attemptedAt: Date, costScopeSignature: String) { guard self.lastTokenFetchAt[provider] == attemptedAt, self.lastTokenFetchScope[provider] == costScopeSignature else { return } self.lastTokenFetchAt.removeValue(forKey: provider) self.lastTokenFetchScope.removeValue(forKey: provider) } /// Fast failures may retry on the next scheduled pass instead of waiting out the fetch /// TTL; timed-out scans keep the TTL so a slow corpus cannot thrash back-to-back rescans. nonisolated static func tokenFetchFailureAllowsEarlyRetry(_ error: Error) -> Bool { if case CostUsageError.timedOut = error { return false } return true } } extension UsageStore { func noteMenuOpened(at date: Date = Date()) { self.lastMenuOpenAt = date guard self.settings.refreshFrequency == .adaptive else { return } let decision = Self.adaptiveRefreshDecision( now: date, lastMenuOpenAt: date, lowPowerModeEnabled: ProcessInfo.processInfo.isLowPowerModeEnabled, thermalState: ProcessInfo.processInfo.thermalState) let candidate = date.addingTimeInterval(TimeInterval(decision.delay.components.seconds)) guard Self.shouldAdvanceAdaptiveTimer( scheduledAt: self.adaptiveRefreshScheduledAt, candidate: candidate) else { return } self.startTimer(preservingResetBoundaryRefresh: true) } }