Files
Jae Hyun 90e6651cd7 Detect Google's live Gemini consumer-tier shutdown response (#3139)
* Detect Google's live Gemini consumer-tier shutdown

Google answers the June 2026 consumer shutdown with an HTTP 200
loadCodeAssist body — no currentTier, the consumer tier listed under
ineligibleTiers with UNSUPPORTED_CLIENT — and then fails
retrieveUserQuota with a 403 SUBSCRIPTION_REQUIRED that carries no
migration wording. The probe only scanned non-200 bodies for the
migration signal, so the shutdown surfaced as a bare "HTTP 403", the
Antigravity migration guidance never appeared, and the login action
still deleted ~/.gemini/oauth_creds.json to launch a Gemini CLI
sign-in that Google rejects.

Read ineligibleTiers on the 200 body, and map the quota 403 to
consumerTierDeprecated only when that flag was set and the account is
not on standard-tier, so licensed Standard/Enterprise accounts keep
their generic error. Record which sentinel fired in
UsageStore.geminiMigrationObservation: either one keeps driving the
settings action, while only Google's own response guards the login
action — the local Antigravity handoff must still be able to relaunch
Gemini CLI, which is the fix for that case.

Claude-Session: https://claude.ai/code/session_019jCyMcSJBKdDW9UQ7xxqc6

* Let a named paid tier outrank the unsupported-client listing

resolveAccountPlan treats paidTier.name as authoritative even when
currentTier is missing, and GeminiStatusProbePlanTests pins that with
`paid tier name survives missing current tier`. The new
unsupported-client branch ran before that resolver, so a response
carrying paidTier.name, no currentTier, and the ineligible consumer
tier would have been read as a consumer shutdown — clearing the paid
snapshot and arming the login guard.

Fold the paid signal into isConsumerClientUnsupported so it gates the
deprecation throw and the quota-403 mapping alike. Google's captured
shutdown response carries no paidTier, so the consumer path is
unchanged.

Claude-Session: https://claude.ai/code/session_01SjKShndyQTKankQekQdBcJ

* Keep Workspace accounts out of the consumer shutdown path

resolveAccountPlan reads free-tier plus an `hd` claim as Workspace, and
docs/gemini.md states Workspace and education accounts stay on Gemini.
The unsupported-client branch runs before that resolver and never saw
the claim, so a hosted-domain account carrying Google's ineligible
consumer-tier entry could be classified as shut down — clearing its
snapshot and arming the login guard against a sign-in that still works.

Fold the hosted domain into isConsumerClientUnsupported next to the
paid-tier signal, so both the loadCodeAssist throw and the quota-403
mapping skip accounts the June 2026 shutdown does not cover. The claim
is already extracted before loadCodeAssistStatus runs, so it only needed
threading through. Move the response parsing into the file's parsing
extension to stay under type_body_length.

Claude-Session: https://claude.ai/code/session_01SjKShndyQTKankQekQdBcJ

* Warn instead of block when Gemini login hits the shutdown

The login guard returned before the credential-clearing account switch,
and the observation only clears on a successful Gemini refresh or a
provider reset — neither reachable from the blocked action. A user
moving off an affected consumer account to a Workspace, education, or
Code Assist Standard account was stuck with migration guidance for the
rest of the session, and the alert never said so.

Offer "Switch Account…" beside Cancel, with Cancel as the default since
confirming clears the stored credentials. Confirming re-runs the
ordinary login without the guard. The observation is left alone, so the
settings action stays available and the next attempt warns again.

Claude-Session: https://claude.ai/code/session_01SjKShndyQTKankQekQdBcJ

* Take main changelog; add #3139 entry

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-08-23 21:03:53 -07:00

1694 lines
83 KiB
Swift

import AppKit
import CodexBarCore
import Foundation
import Observation
import SweetCookieKit
// MARK: - Observation helpers
@MainActor
extension UsageStore {
var menuObservationToken: Int {
_ = self.snapshots
_ = self.errors
_ = self.diagnostics
_ = 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.codexCostCatchUpActivity
_ = 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.claudeSwapAccountSnapshots
_ = self.claudeSwapRevision
_ = self.errors
_ = self.diagnostics
_ = self.knownLimitsAvailabilityByProvider
_ = self.credits
_ = self.lastCreditsError
_ = self.openAIDashboard
_ = self.lastOpenAIDashboardError
_ = self.openAIDashboardRequiresLogin
_ = self.refreshingProviders
_ = self.statuses
_ = self.tokenSnapshotPublications
_ = self.spendDashboardTokenPublications
_ = self.spendDashboardPublication.revision
_ = 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()
let enabledNow = Set(self.settings.enabledProvidersOrdered(
metadataByProvider: self.providerMetadata))
if enabledNow != self.versionDetectionProviders {
self.detectVersions()
}
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.instanceID]?.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 {
// Provider-specific by design: Claude subscription plans choose its consumer dashboard account action.
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: [ProviderInstanceID: UsageSnapshot] = [:]
var errors: [ProviderInstanceID: String] = [:]
var diagnostics: [ProviderInstanceID: String] = [:]
var geminiMigrationObservation: GeminiMigrationObservation = .none
var knownLimitsAvailabilityByProvider: [ProviderInstanceID: UsageLimitsAvailability] = [:]
var lastSourceLabels: [ProviderInstanceID: String] = [:]
var lastFetchAttempts: [ProviderInstanceID: [ProviderFetchAttempt]] = [:]
var accountSnapshots: [ProviderInstanceID: [TokenAccountUsageSnapshot]] = [:]
var tokenAccountLiveStateProviders: Set<ProviderInstanceID> = []
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<Void, Never>?
@ObservationIgnored var claudeSwapTransientState = ClaudeSwapTransientState()
var tokenSnapshots: [ProviderInstanceID: CostUsageTokenSnapshot] = [:]
var tokenSnapshotPublications: [ProviderInstanceID: TokenSnapshotPublication] = [:]
var tokenSnapshotPublicationRevisions: [ProviderInstanceID: UInt64] = [:]
var spendDashboardTokenPublications: [ProviderInstanceID: TokenSnapshotPublication] = [:]
var spendDashboardTokenPublicationRevisions: [ProviderInstanceID: UInt64] = [:]
var spendDashboardPublication = SpendDashboardPublication.empty
@ObservationIgnored var sharedSpendDashboardControllerStorage: SpendDashboardController?
@ObservationIgnored var sharedSpendDashboardObservationStarted = false
@ObservationIgnored var sharedSpendDashboardObservationDebounceTask: Task<Void, Never>?
@ObservationIgnored var sharedSpendDashboardTokenPublicationDebounceTask: Task<Void, Never>?
var tokenErrors: [ProviderInstanceID: String] = [:]
var tokenRefreshInFlight: Set<ProviderInstanceID> = []
var codexCostCatchUpActivity: CodexCostCatchUpActivity?
var spendDashboardCodexCostCatchUpActivity: CodexCostCatchUpActivity?
var spendDashboardCodexCostCatchUpRevision: UInt64 = 0
var credits: CreditsSnapshot?
var lastCreditsError: String?
var openAIDashboard: OpenAIDashboardSnapshot?
var lastOpenAIDashboardError: String?
var openAIDashboardRequiresLogin: Bool = false
var openAIDashboardCookieImportStatus: String?
var openAIDashboardCookieImportDebugLog: String?
var versions: [ProviderInstanceID: String] = [:]
@ObservationIgnored var versionDetectionProviders: Set<ProviderInstanceID> = []
@ObservationIgnored private(set) var versionDetectionTask: Task<Void, Never>?
@ObservationIgnored var claudeVersionRefreshTask: Task<Void, Never>?
var isRefreshing = false
var hasForcedRefreshEnrichmentInFlight = false
var refreshingProviders: Set<ProviderInstanceID> = []
var debugForceAnimation = false
var pathDebugInfo: PathDebugSnapshot = .empty
var statuses: [ProviderInstanceID: ProviderStatus] = [:]
var statusComponents: [ProviderInstanceID: [ProviderStatusComponent]] = [:]
var probeLogs: [ProviderInstanceID: String] = [:]
var historicalPaceRevision: Int = 0
var planUtilizationHistoryRevision: Int = 0
var providerStorageFootprints: [ProviderInstanceID: 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 lastOpenAIDashboardPageScrapeAt: 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<Void, Never>?
@ObservationIgnored var creditsRefreshTaskKey: String?
@ObservationIgnored var openAIDashboardBackgroundRefreshTask: Task<Void, Never>?
@ObservationIgnored var openAIDashboardBackgroundRefreshTaskKey: String?
@ObservationIgnored var openAIDashboardRefreshTask: Task<Void, Never>?
@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?,
staleSnapshotUpdatedAt: Date?)?)?
@ObservationIgnored var _test_codexCostCatchUpStatusOverride: (@MainActor (
String?) async -> CostUsageFetcher.CodexScanCatchUpStatus)?
@ObservationIgnored var _test_codexCostCatchUpAdvanceOverride: (@MainActor (
Date,
String?,
Int) async throws -> CostUsageFetcher.CodexScanCatchUpStatus)?
@ObservationIgnored var _test_codexCostCatchUpSleepOverride: (@MainActor (
TimeInterval) async throws -> Void)?
@ObservationIgnored var _test_codexCostCatchUpResourceStateOverride: (@MainActor () -> (
powerSource: CodexCostCatchUpPowerSource,
lowPowerModeEnabled: Bool,
thermalState: ProcessInfo.ThermalState))?
@ObservationIgnored var _test_spendDashboardCodexCostCatchUpStatusOverride: (@MainActor (
CodexSpendScanRequest) async -> CostUsageFetcher.CodexScanCatchUpStatus)?
@ObservationIgnored var _test_spendDashboardCodexCostCatchUpAdvanceOverride: (@MainActor (
CodexSpendScanRequest,
Date,
Int) async throws -> CostUsageFetcher.CodexScanCatchUpStatus)?
@ObservationIgnored var _test_spendDashboardCodexCostCatchUpSleepOverride: (@MainActor (
TimeInterval) async throws -> Void)?
@ObservationIgnored var _test_spendDashboardCodexCostCatchUpResourceStateOverride: (@MainActor () -> (
powerSource: CodexCostCatchUpPowerSource,
lowPowerModeEnabled: Bool,
thermalState: ProcessInfo.ThermalState))?
@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<Void, Never>?
@ObservationIgnored var lastQueuedWidgetSnapshot: WidgetSnapshot?
@ObservationIgnored let widgetSnapshotURL: URL?
@ObservationIgnored var widgetUsagePreservationBlockedProviders: Set<ProviderInstanceID> = []
@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 let pluginApprovalStore = ProviderPluginApprovalStore()
@ObservationIgnored let sessionQuotaNotifier: any SessionQuotaNotifying
@ObservationIgnored let sessionQuotaLogger = CodexBarLog.logger(LogCategories.sessionQuota)
// Provider-specific by design: OpenAI web and Augment runtime diagnostics have dedicated app-owned log streams.
@ObservationIgnored let openAIWebLogger = CodexBarLog.logger(LogCategories.provider(.openai, scope: "web"))
@ObservationIgnored private let tokenCostLogger = CodexBarLog.logger(LogCategories.tokenCost)
@ObservationIgnored let augmentLogger = CodexBarLog.logger(LogCategories.provider(.augment))
@ObservationIgnored let providerLogger = CodexBarLog.logger(LogCategories.providers)
@ObservationIgnored let adaptiveRefreshLogger = CodexBarLog.logger(LogCategories.adaptiveRefresh)
@ObservationIgnored var openAIWebDebugLines: [String] = []
@ObservationIgnored var failureGates: [ProviderInstanceID: ConsecutiveFailureGate] = [:]
@ObservationIgnored var tokenFailureGates: [ProviderInstanceID: ConsecutiveFailureGate] = [:]
@ObservationIgnored var providerSpecs: [UsageProvider: ProviderSpec] = [:]
@ObservationIgnored let providerMetadata: [UsageProvider: ProviderMetadata]
@ObservationIgnored var providerRuntimes: [ProviderInstanceID: any ProviderRuntime] = [:]
@ObservationIgnored var providerRefreshCoordinator = ProviderRefreshCoordinator<ProviderInstanceID>()
@ObservationIgnored var providerRefreshPublicationContexts:
[ProviderInstanceID: ProviderRefreshPublicationContext] = [:]
@ObservationIgnored var providerCleanupRevisions: [ProviderInstanceID: UInt64] = [:]
@ObservationIgnored private var providerAvailabilityCache:
[ProviderInstanceID: ProviderAvailabilityCacheEntry] = [:]
@ObservationIgnored var accountInfoCache: [ProviderInstanceID: AccountInfoCacheEntry] = [:]
@ObservationIgnored private var timerTask: Task<Void, Never>?
/// In-memory only; resets on every launch.
@ObservationIgnored private(set) var lastMenuOpenAt: Date?
/// Latest local Codex/Claude transcript activity observed by the existing session scanner.
/// In-memory only; paths and session identities never enter the refresh policy.
@ObservationIgnored private(set) var lastCodingActivityAt: Date?
@ObservationIgnored var adaptiveRefreshScheduledAt: Date?
@ObservationIgnored var tokenRefreshSequenceTask: Task<Void, Never>?
@ObservationIgnored var tokenRefreshSequenceToken: UUID?
@ObservationIgnored var tokenRefreshSequenceProvider: ProviderInstanceID?
@ObservationIgnored var tokenRefreshSequenceIsForcedAllPass = false
@ObservationIgnored var pendingForcedTokenRefresh = false
@ObservationIgnored var lastForcedTokenRefreshStartedAt: Date?
@ObservationIgnored var tokenRefreshRetryProviders: Set<ProviderInstanceID> = []
@ObservationIgnored var codexCostCatchUpTask: Task<Void, Never>?
@ObservationIgnored var codexCostCatchUpToken: UUID?
@ObservationIgnored var codexCostCatchUpScopeSignature: String?
@ObservationIgnored var codexCostCatchUpMode: CodexCostCatchUpMode = .automatic
@ObservationIgnored var codexCostCatchUpStopRequested = false
@ObservationIgnored var codexCostCatchUpPassIsRunning = false
@ObservationIgnored var codexCostCatchUpRestartRequested = false
@ObservationIgnored var spendDashboardCodexCostCatchUpTask: Task<Void, Never>?
@ObservationIgnored var spendDashboardCodexCostCatchUpToken: UUID?
@ObservationIgnored var spendDashboardCodexCostCatchUpScopeSignature: String?
@ObservationIgnored var spendDashboardCodexCostCatchUpMode: CodexCostCatchUpMode = .automatic
@ObservationIgnored var spendDashboardCodexCostCatchUpStopRequested = false
@ObservationIgnored var spendDashboardCodexCostCatchUpPassIsRunning = false
@ObservationIgnored var spendDashboardCodexCostCatchUpRestartRequested = false
@ObservationIgnored var forcedRefreshEnrichmentTask: Task<Void, Never>?
@ObservationIgnored var forcedRefreshEnrichmentToken: UUID?
@ObservationIgnored var pendingForcedRefreshEnrichmentTask: Task<Void, Never>?
@ObservationIgnored var pendingForcedRefreshEnrichmentToken: UUID?
@ObservationIgnored var forcedRefreshEnrichmentGeneration: UInt64 = 0
@ObservationIgnored var requiredRefreshTask: Task<Bool, Never>?
@ObservationIgnored var requiredRefreshTaskToken: UUID?
@ObservationIgnored var pendingRequiredRefreshRequest: RequiredRefreshRequest?
@ObservationIgnored var requiredRefreshRequestGeneration: UInt64 = 0
@ObservationIgnored var requiredRefreshCompletedGeneration: UInt64 = 0
@ObservationIgnored var memoryPressureReliefTask: Task<Void, Never>?
@ObservationIgnored var startupConnectivityRetryTask: Task<Void, Never>?
@ObservationIgnored var startupConnectivityRetryNeeded = false
@ObservationIgnored var startupConnectivityRetryRefreshActive = false
@ObservationIgnored var storageRefreshTask: Task<Void, Never>?
@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<Void, Never>?
@ObservationIgnored var resetBoundaryRefreshTask: Task<Void, Never>?
@ObservationIgnored var scheduledResetBoundaryRefreshAt: Date?
@ObservationIgnored var attemptedResetBoundaryRefreshes: Set<Date> = []
@ObservationIgnored var codexPlanHistoryBackfillTask: Task<Void, Never>?
@ObservationIgnored let historicalUsageHistoryStore: HistoricalUsageHistoryStore
@ObservationIgnored let planUtilizationHistoryStore: PlanUtilizationHistoryStore
@ObservationIgnored let codexAccountUsageSnapshotStore: (any CodexAccountUsageSnapshotStoring)?
@ObservationIgnored var codexHistoricalDataset: CodexHistoricalDataset?
@ObservationIgnored var codexHistoricalDatasetAccountKey: String?
@ObservationIgnored var lastKnownResetSnapshots: [ProviderInstanceID: UsageSnapshot] = [:]
/// A stable ambient Auto refresh failed after every live Claude source was exhausted, so persisted
/// plan-utilization history may safely supply a stale presentation snapshot for the same profile.
@ObservationIgnored var claudeHistoryFallbackEligible = false
@ObservationIgnored var deepseekProfileTransition: DeepSeekProfileTransition?
@ObservationIgnored var sessionQuotaTransitionStates: [ProviderInstanceID: SessionQuotaTransitionState] = [:]
@ObservationIgnored var codexSessionQuotaBaselineRequirement: CodexSessionQuotaBaselineRequirement?
var codexSessionQuotaBaselineRequired: Bool {
self.codexSessionQuotaBaselineRequirement != nil
}
@ObservationIgnored var quotaWarningState: [QuotaWarningStateKey: QuotaWarningState] = [:]
@ObservationIgnored let hookRateLimiter = HookRateLimiter()
@ObservationIgnored var providerStatusHadIssue: [ProviderInstanceID: Bool] = [:]
/// Last observed usage fraction (0...1) per account and quota-warning lane, used
/// to detect upward crossings of a quota_low hook rule's own threshold.
@ObservationIgnored var quotaLowHookUsage: [QuotaWarningStateKey: Double] = [:]
@ObservationIgnored var quotaLowHookConfigRevision: Int?
@ObservationIgnored var predictivePaceWarningNotifiedKeys: Set<PredictivePaceWarningStateKey> = []
@ObservationIgnored var lastPermissionPromptNotificationAt: [ProviderInstanceID: Date] = [:]
@ObservationIgnored var lastTokenFetchAt: [ProviderInstanceID: Date] = [:]
@ObservationIgnored var lastTokenFetchScope: [ProviderInstanceID: String] = [:]
@ObservationIgnored var lastSpendDashboardTokenFetchAt: [ProviderInstanceID: Date] = [:]
@ObservationIgnored var lastSpendDashboardTokenFetchScope: [ProviderInstanceID: String] = [:]
@ObservationIgnored var spendDashboardTokenRefreshInFlight: Set<ProviderInstanceID> = []
@ObservationIgnored var planUtilizationHistory: [ProviderInstanceID: PlanUtilizationHistoryBuckets] = [:]
@ObservationIgnored var sessionEquivalentBurnCache: [ProviderInstanceID: SessionEquivalentBurnCacheEntry] = [:]
@ObservationIgnored var sessionEquivalentHistoryScanCount: Int = 0
/// Background load task; cleared on deinit and on the cancel test seam.
@ObservationIgnored var planUtilizationHistoryLoadTask: Task<Void, Never>?
/// 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
/// Energy/WidgetKit floor for expensive local-history scans and their additional snapshot publications.
/// Faster provider refreshes still update quota/status normally, but reuse token-cost history within this TTL.
static let minimumTokenFetchTTL: TimeInterval = 15 * 60
var tokenFetchTTL: TimeInterval? {
Self.tokenFetchTTL(
for: self.settings.refreshFrequency,
lowPowerModeEnabled: self.settings.backgroundWorkLowPowerModeEnabled)
}
static func tokenFetchTTL(
for frequency: RefreshFrequency,
lowPowerModeEnabled: Bool = false) -> TimeInterval?
{
let interval = frequency.usesAdaptivePolicy
? AdaptiveRefreshPolicy.nominalIntervalForHeuristics
: frequency.seconds
let widgetSafeInterval = interval.map { max($0, Self.minimumTokenFetchTTL) }
return BackgroundWorkPowerPolicy.automaticInterval(
widgetSafeInterval,
lowPowerModeEnabled: lowPowerModeEnabled)
}
@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,
widgetSnapshotURL: URL? = nil,
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.widgetSnapshotURL = widgetSnapshotURL
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.instanceID, ConsecutiveFailureGate()) })
self.tokenFailureGates = Dictionary(
uniqueKeysWithValues: UsageProvider.allCases
.map { ($0.instanceID, 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.instanceID, $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.startSharedSpendDashboardPublication()
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()
}
var iconStyle: IconStyle {
let enabled = self.enabledProviders()
if enabled.count > 1 {
return .combined
}
if let provider = enabled.first?.firstPartyProvider {
return self.style(for: provider)
}
// Provider-specific by design: Codex is the historical empty-enabled-set icon fallback.
return .codex
}
var isStale: Bool {
for provider in self.enabledProviders() where self.errors[provider] != nil {
return true
}
return false
}
func enabledProviders() -> [ProviderInstanceID] {
// 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.isEnabledProviderInstance($0, now: now) }
}
/// Enabled providers without availability filtering. Used for display (switcher, merge-icons).
func enabledProvidersForDisplay() -> [ProviderInstanceID] {
self.settings.enabledProvidersOrdered(metadataByProvider: self.providerMetadata)
}
/// Providers that should actually participate in background refresh/status/token work.
func enabledProvidersForBackgroundWork() -> [ProviderInstanceID] {
self.enabledProviders()
}
func metadata(for provider: UsageProvider) -> ProviderMetadata {
self.providerMetadata[provider]!
}
var codexBrowserCookieOrder: BrowserCookieImportOrder {
self.metadata(for: .codex).browserCookieOrder ?? Browser.defaultImportOrder
}
func snapshot(for instanceID: ProviderInstanceID) -> UsageSnapshot? {
self.snapshots[instanceID]
}
/// The snapshot the menu-bar indicator should render for a provider instance.
/// When the claude-swap adapter owns Claude account presentation, the active
/// account's snapshot drives the bar so the indicator agrees with the account
/// cards shown in the menu; the ambient provider snapshot remains the fallback
/// whenever the adapter is disabled, below its presentation threshold, or the
/// active account has no usable usage windows.
func menuBarSnapshot(for instanceID: ProviderInstanceID) -> UsageSnapshot? {
// Provider-specific by design: claude-swap adapter owns Claude menu-bar presentation; see #2731.
self.claudeSwapMenuBarSnapshotOverride(for: instanceID) ?? self.snapshot(for: instanceID)
}
func sourceLabel(for provider: UsageProvider) -> String {
var label = self.lastSourceLabels[provider.instanceID] ?? ""
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.instanceID] ?? []
}
func style(for provider: UsageProvider) -> IconStyle {
self.providerSpecs[provider]?.style ?? .codex
}
func isStale(provider: UsageProvider) -> Bool {
self.errors[provider.instanceID] != nil
}
func knownLimitsAvailability(for provider: UsageProvider) -> UsageLimitsAvailability? {
self.knownLimitsAvailabilityByProvider[provider.instanceID]
}
func hasSatisfiedUsageFetch(for provider: UsageProvider) -> Bool {
self.snapshot(for: provider.instanceID) != 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())
}
func isProviderAvailable(_ provider: UsageProvider, now: Date) -> Bool {
guard provider != .codex else { return true }
let configRevision = self.settings.configRevision
if let cached = self.providerAvailabilityCache[provider.instanceID],
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.instanceID] = 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.compactMap(\.firstPartyProvider))
await withTaskGroup(of: Void.self) { group in
for instanceID in refreshProviders {
guard let provider = instanceID.firstPartyProvider else {
group.addTask { await self.refreshUserPlugin(instanceID) }
continue
}
group.addTask {
await self.refreshProvider(
provider,
coalesceIfRefreshing: coalesceProviderRefreshesOverride ??
(ProviderInteractionContext.current == .background))
}
if availableRefreshProviders.contains(provider.instanceID) {
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 {
// Provider-specific by design: failed OpenAI attachment retries Codex usage before credits enrichment.
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?
@ObservationIgnored private(set) var fixedRefreshIntervalForTesting: TimeInterval?
@ObservationIgnored var adaptiveRefreshComputedIntervalForTesting: TimeInterval?
/// 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 DEBUG
self.fixedRefreshIntervalForTesting = nil
self.adaptiveRefreshComputedIntervalForTesting = nil
#endif
if !preservingResetBoundaryRefresh {
self.cancelResetBoundaryRefresh()
}
let frequency = self.settings.refreshFrequency
guard frequency != .manual else { return }
if frequency.usesAdaptivePolicy {
// 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 = Self.effectiveAutomaticRefreshInterval(
frequency.seconds,
lowPowerModeEnabled: self.settings.backgroundWorkLowPowerModeEnabled)
else { return }
#if DEBUG
self.fixedRefreshIntervalForTesting = wait
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.tokenRefreshSequenceTask?.cancel()
self.codexCostCatchUpTask?.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 antigravityQuotaSummary
case antigravityLegacy
}
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)
}
}
extension UsageStore {
func debugDumpClaude() async {
// Provider-specific by design: Claude's debug command owns a raw CLI/web probe artifact and error lane.
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.instanceID)
self.errors[provider.instanceID] = "Failed to save log: \(error.localizedDescription)"
}
return nil
}
}
func debugAugmentDump() async -> String {
await AugmentStatusProbe.latestDumps()
}
func debugLog(for provider: UsageProvider) async -> String {
if let cached = self.probeLogs[provider.instanceID], !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 notionCookieSource = self.settings.notionCookieSource
let notionCookieHeader = self.settings.notionCookieHeader
let notionWorkspaceID = self.settings.notionWorkspaceID
let processEnvironment = self.environmentBase
let apiKeyDebugContext = self.apiKeyDebugContext(
provider: provider,
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
// Provider-specific by design: implemented logs capture app-only settings and execution contexts.
let buildText = {
if let apiKeyDebugContext {
return Self.apiKeyDebugLine(apiKeyDebugContext)
}
switch provider {
case .codex:
return await codexFetcher.debugRawRateLimits()
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.resolution(for: .zai)
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.resolution(for: .synthetic)
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.resolution(for: .minimax)
let cookieResolution = ProviderTokenResolver.resolution(for: .minimax, kind: .secondary)
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.resolution(for: .alibaba)
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 .notion:
return await Self.debugNotionLog(
browserDetection: browserDetection,
notionCookieSource: notionCookieSource,
notionCookieHeader: notionCookieHeader,
notionWorkspaceID: notionWorkspaceID)
case .warp:
let resolution = ProviderTokenResolver.resolution(for: .warp)
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.resolution(for: .deepseek, environment: deepSeekEnvironment),
configToken: nil,
hasEnvToken: deepSeekHasEnvToken,
hasTokenAccount: deepSeekHasTokenAccount)
default:
return ProviderDescriptorRegistry.descriptor(for: provider).metadata.debugLogUnavailableMessage
?? "Debug log not yet implemented"
}
}
return await claudeDebugExecutionContext.apply {
await buildText()
}
}.value
self.probeLogs[provider.instanceID] = 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<T>(_ 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)
}
}
/// Version probes can spawn subprocesses (Antigravity's `ps` scan trips a TCC
/// prompt, CLI providers exec their binaries), so disabled providers must not
/// be probed (#2267). Settings changes re-run this when the enabled set changes.
static func versionDetectionImplementations(
enabled: Set<UsageProvider>) -> [any ProviderImplementation]
{
ProviderCatalog.all.filter { enabled.contains($0.id) }
}
func detectVersions() {
let enabled = Set(self.settings.enabledProvidersOrdered(metadataByProvider: self.providerMetadata))
self.versionDetectionProviders = enabled
let implementations = Self.versionDetectionImplementations(
enabled: Set(enabled.compactMap(\.firstPartyProvider)))
// Provider-specific by design: only Claude's version probe is background-gated and needs recovery handling.
let probesClaude = implementations.contains { $0.id == .claude }
let browserDetection = self.browserDetection
self.versionDetectionTask = Task { @MainActor [weak self] in
let detection = await Task.detached { () -> (
resolved: [UsageProvider: String],
claudeBinaryResolvable: Bool) 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
}
}
// Provider-specific by design: disabled providers must not be probed (#2267), so the
// Claude binary resolves only when Claude was in this run and its probe returned nil.
let claudeBinaryResolvable = probesClaude
&& resolved[.claude] == nil
&& ProviderVersionDetector.claudeBinaryResolvable()
return (resolved, claudeBinaryResolvable)
}.value
guard let self else { return }
let resolved = detection.resolved
var versions = Dictionary(uniqueKeysWithValues: resolved.map { ($0.key.instanceID, $0.value) })
let claudeID = UsageProvider.claude.instanceID
// A gated or failed Claude probe preserves a user-initiated recovery while the binary still resolves.
// A missing/uninstalled binary clears the version so stale data does not survive CLI removal.
if probesClaude,
resolved[.claude] == nil,
detection.claudeBinaryResolvable,
let recoveredClaudeVersion = self.versions[claudeID]
{
versions[claudeID] = recoveredClaudeVersion
}
self.versions = versions
}
}
@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.resetTokenUsageState(for: provider)
return
}
if Self.tokenCostRequiresProviderSnapshot(provider) {
if self.tokenSnapshotPublicationForCurrentProviderConfig(for: provider) != nil {
self.tokenErrors[provider.instanceID] = nil
self.tokenFailureGates[provider.instanceID]?.recordSuccess()
self.persistWidgetSnapshot(reason: "token-usage")
} else {
self.clearTokenSnapshot(for: provider)
self.tokenErrors[provider.instanceID] = nil
self.tokenFailureGates[provider.instanceID]?.reset()
}
return
}
guard self.settings.isCostUsageEffectivelyEnabled(for: provider) else {
self.resetTokenUsageState(for: provider)
return
}
guard self.isEnabled(provider) else {
self.resetTokenUsageState(for: provider)
return
}
// Provider-specific by design: Cursor cost shares the dashboard-cookie source policy with status fetching.
// Cursor cost honors the same cookie policy as status: when the user set the cookie source
// to Off, skip the network fetch entirely (mirrors CursorProviderDescriptor.checkStatus).
if provider == .cursor, self.settings.cursorCookieSource == .off {
self.resetTokenUsageState(for: provider)
return
}
guard !self.tokenRefreshInFlight.contains(provider.instanceID) else { return }
let now = Date()
let historyDays = self.settings.costUsageHistoryDays
// Cursor cost reuses the status cookie policy: a Manual source forwards the manual header so
// cost and status share the same session; other sources fall back to auto resolution.
guard case let .proceed(cursorCookieHeaderOverride) = self.prepareCursorCostCookie(for: provider) else {
return
}
let costScope = self.tokenCostScope(for: provider)
let costScopeSignature = self.tokenSnapshotScopeSignature(for: provider)
let publicationRevision = self.providerPublicationRevision(for: provider)
let providerConfigRevision = self.settings.providerConfigRevision(for: provider)
if !force, self.tokenRefreshCanReuseCurrentSnapshot(
provider: provider,
now: now,
costScopeSignature: costScopeSignature)
{
return
}
self.lastTokenFetchAt[provider.instanceID] = now
self.lastTokenFetchScope[provider.instanceID] = costScopeSignature
self.tokenRefreshInFlight.insert(provider.instanceID)
defer { self.tokenRefreshInFlight.remove(provider.instanceID) }
if let override = self._test_tokenUsageRefreshOverride {
await override(provider, force)
if Task.isCancelled {
self.lastTokenFetchAt.removeValue(forKey: provider.instanceID)
self.lastTokenFetchScope.removeValue(forKey: provider.instanceID)
}
return
}
let startedAt = Date()
self.tokenCostLogger
.debug("cost usage start provider=\(provider.rawValue) force=\(force)")
do {
// Codex cost usage scans the explicit token-cost scope: selected managed account by
// default, or this Mac's ambient Codex home when the local ledger is enabled.
let snapshot = try await self.loadTokenUsageSnapshot(
provider: provider,
force: force,
now: now,
codexHomePath: costScope.codexHomePath,
historyDays: historyDays,
cursorCookieHeaderOverride: cursorCookieHeaderOverride)
try Task.checkCancellation()
let completedCostScopeSignature = self.completedTokenCostScopeSignature(
provider: provider,
historyDays: historyDays,
initialSignature: costScopeSignature,
snapshot: snapshot)
guard self.tokenRefreshPublicationIsCurrent(
provider: provider,
publicationRevision: publicationRevision,
providerConfigRevision: providerConfigRevision,
historyDays: historyDays,
costScopeSignature: costScopeSignature,
fetchedCredentialScopeFingerprint: snapshot.credentialScopeFingerprint)
else {
self.clearTokenFetchMetadataIfMatching(
provider: provider,
attemptedAt: now,
costScopeSignature: costScopeSignature)
self.requestTokenRefreshAfterStaleCompletion(for: provider)
return
}
self.lastTokenFetchScope[provider.instanceID] = completedCostScopeSignature
self.startCodexCostCatchUpIfNeeded(afterRefreshing: provider)
guard !snapshot.daily.isEmpty || snapshot.meteredCostUSD != nil else {
self.publishConfirmedEmptyTokenSnapshot(for: provider)
self.tokenErrors[provider.instanceID] = Self.tokenCostNoDataMessage(for: provider)
self.tokenFailureGates[provider.instanceID]?.recordSuccess()
return
}
self.logTokenUsageSuccess(
provider: provider,
snapshot: snapshot,
historyDays: historyDays,
startedAt: startedAt)
self.publishTokenSnapshot(snapshot, for: provider)
self.tokenErrors[provider.instanceID] = nil
self.tokenFailureGates[provider.instanceID]?.recordSuccess()
self.persistWidgetSnapshot(reason: "token-usage")
} catch {
guard self.tokenRefreshPublicationIsCurrent(
provider: provider,
publicationRevision: publicationRevision,
providerConfigRevision: providerConfigRevision,
historyDays: historyDays,
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=\(provider.rawValue) 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.instanceID] != nil
let shouldSurface = self.tokenFailureGates[provider.instanceID]?
.shouldSurfaceError(onFailureWithPriorData: hadPriorData) ?? true
if shouldSurface {
self.tokenErrors[provider.instanceID] = error.localizedDescription
self.clearTokenSnapshot(for: provider)
} else {
self.tokenErrors[provider.instanceID] = nil
}
}
}
private func resetTokenUsageState(for provider: UsageProvider) {
// Provider-specific by design: resetting Codex token state also cancels its two ledger catch-up workflows.
if provider == .codex {
self.cancelCodexCostCatchUp()
self.cancelSpendDashboardCodexCostCatchUp()
}
self.clearTokenSnapshot(for: provider)
self.clearSpendDashboardTokenSnapshot(for: provider)
self.tokenErrors[provider.instanceID] = nil
self.tokenFailureGates[provider.instanceID]?.reset()
self.lastTokenFetchAt.removeValue(forKey: provider.instanceID)
self.lastTokenFetchScope.removeValue(forKey: provider.instanceID)
self.lastSpendDashboardTokenFetchAt.removeValue(forKey: provider.instanceID)
self.lastSpendDashboardTokenFetchScope.removeValue(forKey: provider.instanceID)
}
private func logTokenUsageSuccess(
provider: UsageProvider,
snapshot: CostUsageTokenSnapshot,
historyDays: Int,
startedAt: Date)
{
let durationText = String(format: "%.2f", 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 message =
"cost usage success provider=\(provider.rawValue) " +
"duration=\(durationText)s " +
"today=\(sessionCost) " +
"historyDays=\(historyDays) windowCost=\(monthCost)"
self.tokenCostLogger.info(message)
}
private func clearTokenFetchMetadataIfMatching(
provider: UsageProvider,
attemptedAt: Date,
costScopeSignature: String)
{
guard self.lastTokenFetchAt[provider.instanceID] == attemptedAt,
self.lastTokenFetchScope[provider.instanceID] == costScopeSignature
else {
return
}
self.lastTokenFetchAt.removeValue(forKey: provider.instanceID)
self.lastTokenFetchScope.removeValue(forKey: provider.instanceID)
}
/// 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 retainCodingActivityIfNewer(_ date: Date) {
if self.lastCodingActivityAt.map({ date > $0 }) ?? true {
self.lastCodingActivityAt = date
}
}
func clearCodingActivityObservation() {
self.lastCodingActivityAt = nil
}
func restartAdaptiveTimerPreservingResetBoundary() {
self.startTimer(preservingResetBoundaryRefresh: true)
}
func noteMenuOpened(at date: Date = Date()) {
self.lastMenuOpenAt = date
self.advanceAdaptiveTimerIfEarlier(at: date)
}
}