d2ef26296a
* fix(codex): show Business monthly credit on stacked cards Stacked account fetches skipped the spend-controls credits path, so Enterprise/Business cards showed Limits not available instead of used vs cap. * docs: credit #3112 changelog entry * fix(codex): keep cached monthly limits after spend-controls failures Transient enrichment errors were persisted as missing credits, so stacked Business cards fell back to “Limits not available.” Restore the unreleased changelog heading as well. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(codex): clear published credits when the monthly cap is gone Successful spend-controls absence skipped publication and left the old cap on the selected account. Retarget gatekeeper fingerprints after that cluster split. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(codex): keep monthly-only usage out of reset-credit rescue A transient spend-controls failure still produced a success with no windows, and OAuth reset-credit rescue converted that into noRateLimitsFound before the cached monthly cap could be preserved. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(codex): treat unmappable monthly limits as enrichment failures A nested spend-controls limit that is present but not numeric is no longer treated as confirmed absence, so a cached Business cap is preserved. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(codex): honor disabled caps and unmappable monthly usage Inactive enforcement still confirms absence even if limit JSON is malformed, and a non-numeric current_month_usage is treated as enrichment failure instead of zero used. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(codex): let an absent monthly cap win over malformed usage Malformed current_month_usage only counts as enrichment failure when an active numeric limit actually needs that used amount. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(codex): hide monthly credit when optional credits are off Override-derived monthly lanes now follow Show credits + extra usage, so stacked Business cards no longer keep a credit meter the user asked to hide. * fix(codex): include monthly credits in compact account planning Inactive compact rows now see per-account monthly caps so an exhausted Business limit cannot hide behind a window-less usage snapshot. * fix(codex): preserve only the monthly cap after enrichment failure When spend-controls fails and usage confirmed there is no generic credit balance, keep the cached monthly limit without republishing stale remaining/events. * fix(codex): fail monthly enrichment when account id or enforcement is unknown A gated spend-controls fetch without an account id, or a non-string enforcement_mode, is now inconclusive so a cached Business cap is not cleared or published as active. * fix(codex): hide menu-bar monthly credit when optional credits are off Menu-bar projections now omit the optional monthly cap when the credits toggle is off, and a mappable nonpositive limit confirms cap removal even if enforcement is unreadable. * fix(codex): reject non-finite monthly credit values NaN and Infinity usage or limit strings are treated as mapping failures so they cannot zero out cached usage or persist invalid credit snapshots. * fix(codex): keep monthly cap through standalone credits refresh When spend-controls enrichment fails, the follow-up credits refresh now merges the prior monthly limit instead of publishing a generic-only snapshot. * fix(codex): clear generic credits when monthly enrichment fails A standalone credits refresh that confirms the generic balance is gone now publishes nil instead of throwing, so stale credits are not restored while a monthly cap is still preserved when one exists. * fix(codex): retarget snapshot-store architecture suppressions The monthly-cap helper shifted the Codex identity anchors, so the provider-architecture allowlist now points at the new lines. * fix(codex): fetch monthly credits when plan type is unknown Spend-control accounts with a missing or unmapped plan type now query the monthly endpoint, so a cached Business cap is not treated as confirmed removal. * fix(codex): hydrate cached monthly credits and clear stale generic balance Offline startup left the menu-bar projection without the disk-cached cap, and enrichment failure kept a generic balance when the monthly cap was already absent. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(codex): persist standalone credits onto account snapshots Usage refresh stored the selected account snapshot before the credits pass, so a later cap add or removal never reached disk and offline hydration replayed the stale value. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(codex): persist credits using workspace identity Same-email workspace accounts made the snapshot write a no-op; match the published cap with the scoped refresh identity so only the selected workspace row is stored. Co-authored-by: Cursor <cursoragent@cursor.com> * chore: retrigger CI Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Peter Steinberger <steipete@gmail.com>
285 lines
14 KiB
Swift
285 lines
14 KiB
Swift
import CodexBarCore
|
|
import Foundation
|
|
|
|
extension StatusItemController {
|
|
func makeMenuCardRefreshMonitor() -> MenuCardRefreshMonitor {
|
|
MenuCardRefreshMonitor(
|
|
resolveModel: { [weak self] provider in
|
|
self?.menuCardModel(for: provider)
|
|
},
|
|
isProviderRefreshActive: { [weak self] provider in
|
|
self?.store.refreshingProviders.contains(provider.instanceID) == true
|
|
})
|
|
}
|
|
|
|
func menuCardModel(
|
|
for provider: UsageProvider?,
|
|
snapshotOverride: UsageSnapshot? = nil,
|
|
errorOverride: String? = nil,
|
|
forceOverrideCard: Bool = false,
|
|
accountOverride: AccountInfo? = nil,
|
|
historySelectionOverride: PlanUtilizationHistorySelection? = nil,
|
|
planOverride: String? = nil,
|
|
subtitleOverride: String? = nil,
|
|
sourceLabelOverride: String? = nil,
|
|
creditsOverride: CreditsSnapshot? = nil) -> UsageMenuCardView.Model?
|
|
{
|
|
// Provider-specific by design: Codex is the historical card fallback when no enabled provider is available.
|
|
let target = provider ?? self.store.enabledFirstPartyProvidersForDisplay().first ?? .codex
|
|
let metadata = self.store.metadata(for: target)
|
|
|
|
let usesOverrideCard = forceOverrideCard || snapshotOverride != nil || errorOverride != nil
|
|
let surface: CodexConsumerProjection.Surface = if usesOverrideCard {
|
|
.overrideCard
|
|
} else {
|
|
.liveCard
|
|
}
|
|
// Override cards belong to a specific account/context. Never fall back to
|
|
// provider-level live data here; that can belong to a different account.
|
|
let snapshot = self.menuCardSnapshot(
|
|
provider: target,
|
|
surface: surface,
|
|
override: snapshotOverride)
|
|
let projectedTokenSnapshot = self.store.tokenSnapshot(fromProviderSnapshot: snapshot, provider: target)
|
|
let storedTokenSnapshot = UsageStore.tokenCostRequiresProviderSnapshot(target)
|
|
? nil
|
|
: self.store.tokenSnapshot(for: target)
|
|
let now = Date()
|
|
let codexProjection = self.store.codexConsumerProjectionIfNeeded(
|
|
for: target,
|
|
surface: surface,
|
|
snapshotOverride: snapshotOverride,
|
|
errorOverride: errorOverride,
|
|
creditsOverride: surface == .overrideCard ? creditsOverride : nil,
|
|
now: now)
|
|
let credits: CreditsSnapshot?
|
|
let creditsError: String?
|
|
let dashboard: OpenAIDashboardSnapshot?
|
|
let dashboardError: String?
|
|
let tokenSnapshot: CostUsageTokenSnapshot?
|
|
let tokenError: String?
|
|
if let codexProjection {
|
|
credits = codexProjection.credits?.snapshot
|
|
// Credits and dashboard collection are optional adjuncts. Keep their setup diagnostics in
|
|
// provider Settings so a signed-out browser does not dominate the glanceable menu card.
|
|
creditsError = nil
|
|
dashboard = nil
|
|
dashboardError = nil
|
|
if surface == .liveCard {
|
|
tokenSnapshot = projectedTokenSnapshot ?? storedTokenSnapshot
|
|
tokenError = self.store.tokenError(for: target)
|
|
} else {
|
|
tokenSnapshot = projectedTokenSnapshot
|
|
tokenError = nil
|
|
}
|
|
} else if ProviderDescriptorRegistry.descriptor(for: target).tokenCost.supportsTokenCost,
|
|
surface == .liveCard
|
|
{
|
|
credits = nil
|
|
creditsError = nil
|
|
dashboard = nil
|
|
dashboardError = nil
|
|
tokenSnapshot = projectedTokenSnapshot ?? storedTokenSnapshot
|
|
tokenError = self.store.tokenError(for: target)
|
|
} else {
|
|
credits = nil
|
|
creditsError = nil
|
|
dashboard = nil
|
|
dashboardError = nil
|
|
tokenSnapshot = projectedTokenSnapshot
|
|
tokenError = nil
|
|
}
|
|
|
|
let sourceLabel = sourceLabelOverride ?? (surface == .liveCard ? self.store.sourceLabel(for: target) : nil)
|
|
// Provider-specific by design: Kilo's automatic source mode is surfaced as card fallback context.
|
|
let kiloAutoMode = target == .kilo && self.settings.kiloUsageDataSource == .auto
|
|
let (weeklyPace, sessionEquivalentForecast) = self.resolvePaceAndForecast(
|
|
target: target,
|
|
snapshot: snapshot,
|
|
codexProjection: codexProjection,
|
|
usesOverrideCard: surface == .overrideCard,
|
|
historySelectionOverride: historySelectionOverride,
|
|
now: now)
|
|
let fallbackAccount = accountOverride
|
|
?? (metadata.usesAccountFallback
|
|
? self.store.accountInfo(for: target)
|
|
: AccountInfo(email: nil, plan: nil))
|
|
let input = UsageMenuCardView.Model.Input(
|
|
provider: target,
|
|
metadata: metadata,
|
|
snapshot: snapshot,
|
|
codexProjection: codexProjection,
|
|
credits: credits,
|
|
creditsError: creditsError,
|
|
dashboard: dashboard,
|
|
dashboardError: dashboardError,
|
|
tokenSnapshot: tokenSnapshot,
|
|
tokenError: tokenError,
|
|
account: fallbackAccount,
|
|
accountIsAuthoritative: accountOverride != nil,
|
|
planOverride: planOverride,
|
|
isRefreshing: self.store.shouldShowRefreshingMenuCardIndicator(for: target),
|
|
// Provider-level errors can belong to a different account, so
|
|
// override cards never inherit them (same rule as the snapshot,
|
|
// token-cost, and source-label fallbacks above).
|
|
lastError: errorOverride
|
|
?? codexProjection?.userFacingErrors.usage
|
|
?? (surface == .liveCard ? self.store.userFacingError(for: target) : nil),
|
|
limitsAvailability: self.store.knownLimitsAvailability(for: target),
|
|
usageBarsShowUsed: self.settings.usageBarsShowUsed,
|
|
resetTimeDisplayStyle: self.settings.resetTimeDisplayStyle,
|
|
tokenCostUsageEnabled: self.settings.isCostUsageEffectivelyEnabled(for: target),
|
|
tokenCostIsRefreshing: self.store.tokenCostRefreshIsActive(for: target),
|
|
codexLocalSessionCostLedgerEnabled: self.settings.codexLocalSessionCostLedgerEnabled,
|
|
costSummaryInlineEnabled: self.settings.costSummaryShowsInline(for: target),
|
|
// openai/mistral's cost history always surfaces via the inline dashboard or a
|
|
// dedicated top-pane submenu (see `makeUsageSubmenu`), so they skip the generic
|
|
// "Cost" row. This must stay an explicit provider check rather than reusing
|
|
// `usesProviderCostHistoryAsPrimaryDashboard` (or `tokenCostRequiresProviderSnapshot`):
|
|
// both of those sets are shared with unrelated concerns (inline-dashboard eligibility,
|
|
// provider-derived snapshot sourcing) and gain members for reasons that have nothing to
|
|
// do with whether this row should show, silently disabling the Cost row for those
|
|
// providers too (e.g. groq's addition to the inline-dashboard set previously did this).
|
|
tokenCostMenuSectionEnabled: ProviderDescriptorRegistry.descriptor(for: target).tokenCost
|
|
.showsCostMenuSection &&
|
|
self.settings.costSummaryShowsSubmenu(for: target),
|
|
costComparisonPeriodsEnabled: self.settings.costComparisonPeriodsEnabled,
|
|
showOptionalCreditsAndExtraUsage: self.settings.showOptionalCreditsAndExtraUsage,
|
|
claudeDailyRoutinesUsageVisible: self.settings.claudeDailyRoutinesUsageVisible,
|
|
codexSparkUsageVisible: self.settings.codexSparkUsageVisible,
|
|
copilotBudgetExtrasEnabled: self.settings.copilotBudgetExtrasEnabled,
|
|
sourceLabel: sourceLabel,
|
|
subtitleOverride: subtitleOverride,
|
|
kiloAutoMode: kiloAutoMode,
|
|
hidePersonalInfo: self.settings.hidePersonalInfo,
|
|
weeklyPace: weeklyPace,
|
|
sessionEquivalentForecast: sessionEquivalentForecast,
|
|
quotaWarningThresholds: [
|
|
.session: self.quotaWarningMarkerThresholds(provider: target, window: .session),
|
|
.weekly: self.quotaWarningMarkerThresholds(provider: target, window: .weekly),
|
|
],
|
|
workDaysPerWeek: self.settings.weeklyProgressWorkDays,
|
|
workdayTickAppearance: self.settings.workdayTickAppearance,
|
|
paceVisible: self.settings.paceVisible,
|
|
usesLiveSubtitle: surface == .liveCard,
|
|
preferredCurrencyCode: self.settings.preferredCurrencyCode,
|
|
now: now)
|
|
return UsageMenuCardView.Model.make(input)
|
|
}
|
|
|
|
private func menuCardSnapshot(
|
|
provider: UsageProvider,
|
|
surface: CodexConsumerProjection.Surface,
|
|
override: UsageSnapshot?) -> UsageSnapshot?
|
|
{
|
|
let baseSnapshot: UsageSnapshot? = if surface == .overrideCard {
|
|
override
|
|
} else {
|
|
override ?? self.store.presentationSnapshot(for: provider)
|
|
}
|
|
return self.subscriptionMetadataSnapshot(baseSnapshot, provider: provider, surface: surface)
|
|
}
|
|
|
|
private func subscriptionMetadataSnapshot(
|
|
_ snapshot: UsageSnapshot?,
|
|
provider: UsageProvider,
|
|
surface: CodexConsumerProjection.Surface) -> UsageSnapshot?
|
|
{
|
|
// Provider-specific by design: OpenAI dashboard cache metadata attaches only to the live Codex account.
|
|
guard provider == .codex,
|
|
surface == .liveCard,
|
|
let snapshot,
|
|
let cache = OpenAIDashboardCacheStore.load(),
|
|
cache.snapshot.subscriptionRenewsAt != nil || cache.snapshot.subscriptionExpiresAt != nil,
|
|
let cacheEmail = CodexIdentityResolver.normalizeEmail(cache.accountEmail),
|
|
let currentEmail = CodexIdentityResolver.normalizeEmail(
|
|
snapshot.accountEmail(for: .codex) ?? self.store.accountInfo(for: .codex).email),
|
|
cacheEmail == currentEmail
|
|
else { return snapshot }
|
|
return snapshot.withSubscriptionMetadata(
|
|
expiresAt: cache.snapshot.subscriptionExpiresAt,
|
|
renewsAt: cache.snapshot.subscriptionRenewsAt)
|
|
}
|
|
|
|
// swiftlint:disable:next function_parameter_count
|
|
private func resolvePaceAndForecast(
|
|
target: UsageProvider,
|
|
snapshot: UsageSnapshot?,
|
|
codexProjection: CodexConsumerProjection?,
|
|
usesOverrideCard: Bool,
|
|
historySelectionOverride: PlanUtilizationHistorySelection?,
|
|
now: Date)
|
|
-> (weeklyPace: UsagePace?, sessionEquivalentForecast: SessionEquivalentForecast?)
|
|
{
|
|
let paceWindow = snapshot.flatMap {
|
|
ProviderDescriptorRegistry.descriptor(for: target).presentation.semanticWindows(snapshot: $0).weekly
|
|
}
|
|
let historySelection = self.sessionEquivalentHistorySelection(
|
|
provider: target,
|
|
snapshot: snapshot,
|
|
usesOverrideCard: usesOverrideCard,
|
|
override: historySelectionOverride)
|
|
let weeklyPace = if let codexProjection,
|
|
let weekly = codexProjection.rateWindow(for: .weekly)
|
|
{
|
|
self.store.weeklyPace(provider: target, window: weekly, now: now)
|
|
} else {
|
|
paceWindow.flatMap { window in
|
|
self.store.weeklyPace(provider: target, window: window, now: now)
|
|
}
|
|
}
|
|
let forecast: SessionEquivalentForecast? = if let codexProjection,
|
|
let session = codexProjection
|
|
.rateWindow(for: .session),
|
|
let weekly = codexProjection
|
|
.rateWindow(for: .weekly)
|
|
{
|
|
self.store.sessionEquivalentForecast(
|
|
provider: target,
|
|
sessionWindow: session,
|
|
weeklyWindow: weekly,
|
|
historySelection: historySelection,
|
|
now: now)
|
|
} else if let snapshot,
|
|
let windows = self.store.sessionEquivalentWindows(
|
|
provider: target, snapshot: snapshot)
|
|
{
|
|
self.store.sessionEquivalentForecast(
|
|
provider: target,
|
|
sessionWindow: windows.session,
|
|
weeklyWindow: windows.weekly,
|
|
weeklyWindowID: windows.weeklyWindowID,
|
|
historyIdentity: windows.historyIdentity,
|
|
historySelection: historySelection,
|
|
now: now)
|
|
} else {
|
|
nil
|
|
}
|
|
return (weeklyPace, forecast)
|
|
}
|
|
|
|
private func sessionEquivalentHistorySelection(
|
|
provider: UsageProvider,
|
|
snapshot: UsageSnapshot?,
|
|
usesOverrideCard: Bool,
|
|
override: PlanUtilizationHistorySelection?) -> PlanUtilizationHistorySelection?
|
|
{
|
|
guard usesOverrideCard else { return nil }
|
|
if let override {
|
|
return override
|
|
}
|
|
guard let snapshot else { return .unavailable }
|
|
return self.store.planUtilizationHistorySelection(for: provider, snapshotOverride: snapshot)
|
|
}
|
|
|
|
func accountInfo(for account: CodexVisibleAccount) -> AccountInfo {
|
|
AccountInfo(email: account.email, plan: account.workspaceLabel)
|
|
}
|
|
|
|
private func quotaWarningMarkerThresholds(provider: UsageProvider, window: QuotaWarningWindow) -> [Int] {
|
|
guard self.settings.quotaWarningMarkersVisible else { return [] }
|
|
guard self.settings.quotaWarningEnabled(provider: provider, window: window) else { return [] }
|
|
return self.settings.resolvedQuotaWarningThresholds(provider: provider, window: window)
|
|
}
|
|
}
|