diff --git a/README.md b/README.md index cf2190827b..d267bc0010 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,7 @@ show an incident indicator. - Optional Codex web dashboard enrichments (code review remaining, usage breakdown, credits history). - Inline spend and usage charts for API-backed providers such as OpenAI, Claude Admin API, OpenRouter, LiteLLM, z.ai, MiniMax, Mistral, and AWS Bedrock. - Configurable cost-usage scans for Codex + Claude, plus reused chart UI for supported provider histories. +- A persistent Settings → Usage & Spend view for local 7/30-day estimates, grouped by native currency and limited to providers that expose cost history. - Provider status polling with incident badges in the menu and icon overlay. - Merge Icons mode to combine providers into one status item + switcher. - Display controls for provider icons, labels, bars, reset-time style, and highest-usage auto-selection. diff --git a/Sources/CodexBar/Localization.swift b/Sources/CodexBar/Localization.swift index d7fb7bde30..38a6918e8b 100644 --- a/Sources/CodexBar/Localization.swift +++ b/Sources/CodexBar/Localization.swift @@ -34,9 +34,15 @@ private func appLanguageDefaults() -> UserDefaults { private let isRunningTestsProcessAtStartup: Bool = { let env = ProcessInfo.processInfo.environment - if env["XCTestConfigurationFilePath"] != nil { return true } - if env["TESTING_LIBRARY_VERSION"] != nil { return true } - if env["SWIFT_TESTING"] != nil { return true } + if env["XCTestConfigurationFilePath"] != nil { + return true + } + if env["TESTING_LIBRARY_VERSION"] != nil { + return true + } + if env["SWIFT_TESTING"] != nil { + return true + } return NSClassFromString("XCTestCase") != nil }() @@ -210,7 +216,11 @@ func L(_ key: String, language: String) -> String { func codexBarLocalizedLocale() -> Locale { let language = resolvedAppLanguage() guard !language.isEmpty else { return .current } - switch language.lowercased() { + let normalized = language.lowercased() + if normalized == "ar" || normalized.hasPrefix("ar-") { + return Locale(identifier: "\(language)@numbers=arab") + } + switch normalized { case "zh-hans": return Locale(identifier: "zh-Hans") case "zh-hant": @@ -222,6 +232,10 @@ func codexBarLocalizedLocale() -> Locale { } } +func codexBarLocalizedInteger(_ value: Int) -> String { + value.formatted(.number.locale(codexBarLocalizedLocale())) +} + func codexBarLocalizedString(_ key: String, bundle: Bundle, resourceBundle: Bundle) -> String { let value = bundle.localizedString(forKey: key, value: nil, table: nil) let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Sources/CodexBar/MenuCardView+Costs.swift b/Sources/CodexBar/MenuCardView+Costs.swift index adb1e6bbee..fc587dd330 100644 --- a/Sources/CodexBar/MenuCardView+Costs.swift +++ b/Sources/CodexBar/MenuCardView+Costs.swift @@ -134,7 +134,16 @@ extension UsageMenuCardView.Model { let fallbackTokens = snapshot.daily.compactMap(\.totalTokens).reduce(0, +) let monthTokensValue = snapshot.last30DaysTokens ?? (fallbackTokens > 0 ? fallbackTokens : nil) let monthTokens = monthTokensValue.map { UsageFormatter.tokenCountString($0) } - let windowLabel = snapshot.historyLabel ?? Self.costHistoryWindowLabel(days: snapshot.historyDays) + let windowLabel = if let historyLabel = snapshot.historyLabel { + historyLabel + } else if provider == .mistral, + snapshot.historyDays == 1, + Self.bedrockLatestBillingDay(from: snapshot.daily) != nil + { + L("Latest billing day") + } else { + Self.costHistoryWindowLabel(days: snapshot.historyDays) + } let monthLine: String = { if let monthTokens { return String(format: L("%@: %@ · %@ tokens"), windowLabel, monthCost, monthTokens) diff --git a/Sources/CodexBar/PreferencesSelection.swift b/Sources/CodexBar/PreferencesSelection.swift index 15d5331647..9e3ea46499 100644 --- a/Sources/CodexBar/PreferencesSelection.swift +++ b/Sources/CodexBar/PreferencesSelection.swift @@ -7,6 +7,7 @@ extension SettingsPane { var persistenceToken: String { switch self { case .general: "general" + case .usageSpend: "usageSpend" case .notifications: "notifications" case .menuBar: "menuBar" case .menu: "menu" @@ -20,6 +21,7 @@ extension SettingsPane { init?(persistenceToken: String) { switch persistenceToken { case "general": self = .general + case "usageSpend": self = .usageSpend case "notifications": self = .notifications case "menuBar": self = .menuBar // Pre-0.41.1 releases persisted the retired Display pane; its contents moved to Menu Bar. diff --git a/Sources/CodexBar/PreferencesSidebar.swift b/Sources/CodexBar/PreferencesSidebar.swift index cf375eeb1f..a02f91f06e 100644 --- a/Sources/CodexBar/PreferencesSidebar.swift +++ b/Sources/CodexBar/PreferencesSidebar.swift @@ -33,6 +33,7 @@ struct SettingsSidebarView: View { private var appPanesSection: some View { Section { SettingsSidebarPaneRow(pane: .general, systemImage: "gearshape.fill", color: .gray) + SettingsSidebarPaneRow(pane: .usageSpend, systemImage: "chart.bar.fill", color: .green) SettingsSidebarPaneRow(pane: .notifications, systemImage: "bell.badge.fill", color: .red) SettingsSidebarPaneRow(pane: .menuBar, systemImage: "menubar.rectangle", color: .blue) SettingsSidebarPaneRow(pane: .menu, systemImage: "filemenu.and.selection", color: .teal) diff --git a/Sources/CodexBar/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift new file mode 100644 index 0000000000..800f909bd7 --- /dev/null +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -0,0 +1,429 @@ +import AppKit +import Charts +import CodexBarCore +import SwiftUI + +func spendDashboardDayRangeText(_ days: Int) -> String { + let template: String + switch days { + case 7: template = L("7d") + case 30: template = L("30d") + default: return codexBarLocalizedInteger(days) + } + return template.replacingOccurrences( + of: String(days), + with: codexBarLocalizedInteger(days)) +} + +func spendDashboardRankText(_ rank: Int) -> String { + "#\(codexBarLocalizedInteger(rank))" +} + +func spendDashboardRefreshFailureText(_ count: Int) -> String { + "\(L("Refresh failures")): \(codexBarLocalizedInteger(count))" +} + +func spendDashboardCoverageText(covered: Int, requested: Int) -> String { + "\(L("Coverage")): \(codexBarLocalizedInteger(covered)) / \(codexBarLocalizedInteger(requested))" +} + +@MainActor +struct SpendDashboardPane: View { + @Bindable var settings: SettingsStore + @Bindable var store: UsageStore + @State private var controller: SpendDashboardController + + init(settings: SettingsStore, store: UsageStore) { + self.settings = settings + self.store = store + self._controller = State(initialValue: SpendDashboardController(requestBuilder: { mode in + await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) + })) + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + self.header + self.content + self.provenance + } + .padding(24) + } + .background(FocusResigningBackground()) + .onAppear { + self.controller.refreshDateWindow() + self.controller.update(configuration: self.configuration) + } + .onChange(of: self.configuration) { _, configuration in + self.controller.update(configuration: configuration) + } + .onDisappear { + self.controller.stop() + } + .onReceive(NotificationCenter.default.publisher(for: .NSCalendarDayChanged)) { _ in + self.controller.refreshDateWindow() + } + .onReceive(NotificationCenter.default.publisher(for: .NSSystemTimeZoneDidChange)) { _ in + self.controller.refreshDateWindow() + } + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in + self.controller.refreshDateWindow() + } + } + + private var configuration: SpendDashboardConfiguration { + SpendDashboardSource.configuration(settings: self.settings, store: self.store) + } + + private var header: some View { + HStack(alignment: .top, spacing: 16) { + VStack(alignment: .leading, spacing: 4) { + Text(L("Usage & Spend")) + .font(.title2.weight(.semibold)) + Text(L("Local estimated cost history across supported providers.")) + .font(.subheadline) + .foregroundStyle(.secondary) + } + Spacer() + Picker(L("Time range"), selection: self.daysBinding) { + Text(spendDashboardDayRangeText(7)).tag(7) + Text(spendDashboardDayRangeText(30)).tag(30) + } + .labelsHidden() + .pickerStyle(.segmented) + .frame(width: 116) + + Button { + self.controller.refresh() + } label: { + if self.controller.isRefreshing { + ProgressView().controlSize(.small) + } else { + Label(L("Refresh"), systemImage: "arrow.clockwise") + } + } + .disabled(self.controller.isRefreshing || !self.settings.costUsageEnabled) + } + } + + @ViewBuilder + private var content: some View { + if !self.settings.costUsageEnabled { + SpendDashboardPanel { + ContentUnavailableView { + Label(L("Cost tracking is off"), systemImage: "chart.bar.xaxis") + } description: { + Text(L("Turn on Track costs to build local estimates.")) + } + .frame(maxWidth: .infinity, minHeight: 220) + } + } else if self.controller.model.groups.isEmpty { + SpendDashboardPanel { + ContentUnavailableView { + Label(L("No local cost history yet"), systemImage: "chart.bar.xaxis") + } description: { + Text(L("Turn on cost tracking or refresh after using a supported provider.")) + } + .frame(maxWidth: .infinity, minHeight: 220) + } + } else { + ForEach(self.controller.model.groups) { group in + SpendCurrencySection(group: group, requestedDays: self.controller.model.requestedDays) + } + } + + if self.controller.failedSourceCount > 0 { + Label( + spendDashboardRefreshFailureText(self.controller.failedSourceCount), + systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + private var provenance: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "lock.shield.fill") + .foregroundStyle(.secondary) + Text(L("Native currencies stay separate; Codex account rows exclude Pi session history.")) + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Toggle(L("Track costs"), isOn: self.$settings.costUsageEnabled) + .toggleStyle(.switch) + .controlSize(.small) + } + } + + private var daysBinding: Binding { + Binding( + get: { self.controller.selectedDays }, + set: { self.controller.selectDays($0) }) + } +} + +private struct SpendCurrencySection: View { + let group: SpendDashboardModel.CurrencyGroup + let requestedDays: Int + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .firstTextBaseline) { + Text(self.group.currencyCode) + .font(.headline) + Spacer() + Text(self.group.totalCost.map { + UsageFormatter.currencyString($0, currencyCode: self.group.currencyCode) + } ?? L("Spend unavailable")) + .font(.title3.weight(.semibold)) + .monospacedDigit() + } + + Text( + "\(L("Local estimated history")) · " + + spendDashboardCoverageText( + covered: self.group.coveredDayCount, + requested: self.requestedDays)) + .font(.caption) + .foregroundStyle(.secondary) + + SpendDashboardPanel { + HStack(spacing: 24) { + SpendSummaryValue( + title: L("Estimated spend"), + value: self.group.totalCost.map { + UsageFormatter.currencyString($0, currencyCode: self.group.currencyCode) + } ?? "—") + SpendSummaryValue( + title: L("Tracked tokens"), + value: self.group.totalTokens.map(UsageFormatter.tokenCountString) ?? "—") + SpendSummaryValue( + title: L("Subscriptions"), + value: codexBarLocalizedInteger(self.group.providers.count)) + Spacer() + } + } + + SpendProviderPanel(group: self.group) + SpendModelPanel(group: self.group) + SpendDailyChart(group: self.group) + } + } +} + +private struct SpendSummaryValue: View { + let title: String + let value: String + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + Text(self.title) + .font(.caption) + .foregroundStyle(.secondary) + Text(self.value) + .font(.system(.title2, design: .rounded, weight: .semibold)) + .monospacedDigit() + } + } +} + +private struct SpendProviderPanel: View { + let group: SpendDashboardModel.CurrencyGroup + + var body: some View { + SpendDashboardPanel { + VStack(alignment: .leading, spacing: 0) { + Text(L("By subscription")).font(.headline).padding(.bottom, 8) + ForEach(self.group.providers) { row in + if row.rank > 1 { + Divider() + } + HStack(spacing: 10) { + Text(spendDashboardRankText(row.rank)) + .font(.caption.monospacedDigit()) + .foregroundStyle(.tertiary) + .frame(width: 26, alignment: .leading) + SpendProviderIcon(provider: row.provider) + Text(row.displayName).lineLimit(1) + Spacer() + Text(row.totalCost.map { + UsageFormatter.currencyString($0, currencyCode: self.group.currencyCode) + } ?? L("Spend unavailable")) + .foregroundStyle(row.totalCost == nil ? .secondary : .primary) + .monospacedDigit() + } + .padding(.vertical, 9) + } + } + } + } +} + +private struct SpendModelPanel: View { + let group: SpendDashboardModel.CurrencyGroup + + var body: some View { + SpendDashboardPanel { + VStack(alignment: .leading, spacing: 0) { + Text(L("Models")).font(.headline).padding(.bottom, 8) + if self.group.modelHistoryCompleteness == .incomplete { + Text(L("Model breakdown unavailable")) + .foregroundStyle(.secondary) + .padding(.vertical, 10) + } else if self.group.models.isEmpty { + Text(L("No model-level history")) + .foregroundStyle(.secondary) + .padding(.vertical, 10) + } else { + ForEach(self.group.models.prefix(8)) { row in + if row.rank > 1 { + Divider() + } + HStack(spacing: 10) { + Text(spendDashboardRankText(row.rank)) + .font(.caption.monospacedDigit()) + .foregroundStyle(.tertiary) + .frame(width: 26, alignment: .leading) + SpendProviderIcon(provider: row.provider) + VStack(alignment: .leading, spacing: 2) { + Text(row.modelName).lineLimit(1) + Text(row.providerName).font(.caption).foregroundStyle(.secondary) + } + Spacer() + Text(row.totalCost.map { + UsageFormatter.currencyString($0, currencyCode: self.group.currencyCode) + } ?? "—") + .monospacedDigit() + } + .padding(.vertical, 9) + } + } + } + } + } +} + +struct SpendDailyChartPresentation: Equatable { + enum Content: Equatable { + case chart + case unavailable + } + + struct Series: Equatable { + let name: String + let provider: UsageProvider + } + + let content: Content + let series: [Series] + let dayCount: Int + + init(dailyPoints: [SpendDashboardModel.DailyPoint], aggregateTotal: Double?) { + self.content = dailyPoints.isEmpty && aggregateTotal == nil ? .unavailable : .chart + self.dayCount = Set(dailyPoints.map(\.day)).count + + var seenNames: Set = [] + self.series = dailyPoints.compactMap { point in + guard seenNames.insert(point.providerName).inserted else { return nil } + return Series(name: point.providerName, provider: point.provider) + } + } + + var accessibilityValue: String { + L("%d days of usage data across %d services", self.dayCount, self.series.count) + } +} + +private struct SpendDailyChart: View { + let group: SpendDashboardModel.CurrencyGroup + + var body: some View { + let presentation = SpendDailyChartPresentation( + dailyPoints: self.group.dailyPoints, + aggregateTotal: self.group.totalCost) + SpendDashboardPanel { + VStack(alignment: .leading, spacing: 12) { + Text(L("Daily estimated spend")).font(.headline) + if presentation.content == .unavailable { + ContentUnavailableView(L("Spend unavailable"), systemImage: "chart.bar.xaxis") + .frame(maxWidth: .infinity, minHeight: 170) + } else { + Chart(self.group.dailyPoints) { point in + BarMark( + x: .value(L("Day"), point.day, unit: .day), + yStart: .value(L("Estimated spend"), point.stackStart), + yEnd: .value(L("Estimated spend"), point.stackEnd), + width: .ratio(0.72)) + .foregroundStyle(by: .value(L("Provider"), point.providerName)) + .accessibilityLabel(Text(self.pointAccessibilityLabel(point))) + .accessibilityValue(Text(UsageFormatter.currencyString( + point.cost, + currencyCode: self.group.currencyCode))) + } + .chartXScale(domain: self.group.chartDomain) + .chartForegroundStyleScale( + domain: presentation.series.map(\.name), + range: presentation.series.map { self.providerColor($0.provider) }) + .chartLegend(position: .bottom, alignment: .leading, spacing: 8) + .chartYAxis { + AxisMarks(position: .leading) { value in + AxisGridLine() + AxisValueLabel { + if let amount = value.as(Double.self) { + Text(UsageFormatter.compactCurrencyString( + amount, + currencyCode: self.group.currencyCode)) + } + } + } + } + .frame(height: 170) + .accessibilityLabel(L("Daily estimated spend")) + .accessibilityValue(presentation.accessibilityValue) + } + } + } + } + + private func pointAccessibilityLabel(_ point: SpendDashboardModel.DailyPoint) -> String { + let day = point.day.formatted( + .dateTime.month(.abbreviated).day().locale(codexBarLocalizedLocale())) + return "\(point.providerName), \(day)" + } + + private func providerColor(_ provider: UsageProvider) -> Color { + let color = ProviderDescriptorRegistry.descriptor(for: provider).branding.color + return Color(red: color.red, green: color.green, blue: color.blue) + } +} + +private struct SpendProviderIcon: View { + let provider: UsageProvider + + var body: some View { + Group { + if let icon = ProviderBrandIcon.image(for: self.provider) { + Image(nsImage: icon).resizable().scaledToFit() + } else { + Image(systemName: "circle.dotted") + } + } + .frame(width: 20, height: 20) + .accessibilityHidden(true) + } +} + +private struct SpendDashboardPanel: View { + @ViewBuilder let content: Content + + var body: some View { + self.content + .padding(16) + .background(.quaternary.opacity(0.55), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder(Color(nsColor: .separatorColor).opacity(0.35)) + } + } +} diff --git a/Sources/CodexBar/PreferencesView.swift b/Sources/CodexBar/PreferencesView.swift index 1cac33ab4a..6b13601525 100644 --- a/Sources/CodexBar/PreferencesView.swift +++ b/Sources/CodexBar/PreferencesView.swift @@ -5,6 +5,7 @@ import SwiftUI /// Sidebar destinations of the settings window: fixed app panes plus one entry per provider. enum SettingsPane: Hashable { case general + case usageSpend case notifications case menuBar case menu @@ -23,6 +24,7 @@ enum SettingsPane: Hashable { var title: String { switch self { case .general: L("tab_general") + case .usageSpend: L("tab_usage_spend") case .notifications: L("tab_notifications") case .menuBar: L("tab_menu_bar") case .menu: L("tab_menu") @@ -115,6 +117,8 @@ struct PreferencesView: View { switch self.selection.pane { case .general: GeneralPane(settings: self.settings) + case .usageSpend: + SpendDashboardPane(settings: self.settings, store: self.store) case .notifications: NotificationsPane(settings: self.settings) case .menuBar: diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index 90f0e6cfdf..ce64c36740 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -7,6 +7,7 @@ " providers" = " providers"; "(System)" = "(النظام)"; "30d" = "30 يومًا"; +"7d" = "7 أيام"; "A managed Codex login is already running. Wait for it to finish before adding " = "تسجيل دخول Codex المدار يعمل بالفعل. انتظر حتى ينتهي قبل إضافة "; "API key" = "مفتاح API"; "API region" = "منطقة API"; @@ -1210,3 +1211,26 @@ "Finish switching to a different Cursor account in your browser, then try again." = "أكمل التبديل إلى حساب Cursor مختلف في متصفحك، ثم حاول مرة أخرى."; "Timed out waiting for Cursor account switch. %@" = "انتهت مهلة انتظار تبديل حساب Cursor. %@"; "Timed out waiting for Cursor account switch. %@ Last error: %@" = "انتهت مهلة انتظار تبديل حساب Cursor. %@ آخر خطأ: %@"; + +/* Spend dashboard */ +"tab_usage_spend" = "الاستخدام والإنفاق"; +"Usage & Spend" = "الاستخدام والإنفاق"; +"Local estimated cost history across supported providers." = "سجل التكاليف التقديري المحلي عبر المزوّدين المدعومين."; +"Time range" = "النطاق الزمني"; +"Track costs" = "تتبّع التكاليف"; +"Cost tracking is off" = "تتبّع التكاليف متوقف"; +"Turn on Track costs to build local estimates." = "فعّل «تتبّع التكاليف» لإنشاء تقديرات محلية."; +"No local cost history yet" = "لا يوجد سجل تكاليف محلي بعد"; +"Turn on cost tracking or refresh after using a supported provider." = "فعّل تتبّع التكاليف أو حدّث بعد استخدام مزوّد مدعوم."; +"Refresh failures" = "حالات فشل التحديث"; +"Native currencies stay separate; Codex account rows exclude Pi session history." = "تبقى العملات الأصلية منفصلة؛ تستبعد صفوف حساب Codex سجل جلسات Pi."; +"Spend unavailable" = "الإنفاق غير متاح"; +"Model breakdown unavailable" = "تفصيل الإنفاق حسب النموذج غير متاح"; +"Local estimated history" = "السجل التقديري المحلي"; +"Coverage" = "التغطية"; +"Estimated spend" = "الإنفاق التقديري"; +"Tracked tokens" = "الرموز المتتبعة"; +"Subscriptions" = "الاشتراكات"; +"By subscription" = "حسب الاشتراك"; +"No model-level history" = "لا يوجد سجل على مستوى النموذج"; +"Daily estimated spend" = "الإنفاق اليومي التقديري"; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index 9f867c4bf1..66a0d5bbad 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -7,6 +7,7 @@ " providers" = " proveïdors"; "(System)" = "(Sistema)"; "30d" = "30 d"; +"7d" = "7 d"; "A managed Codex login is already running. Wait for it to finish before adding " = "Ja hi ha un inici de sessió gestionat de Codex en curs. Espereu que acabi abans d'afegir "; "API key" = "Clau d'API"; "API region" = "Regió de l'API"; @@ -1209,3 +1210,26 @@ "Finish switching to a different Cursor account in your browser, then try again." = "Acabeu de canviar a un compte de Cursor diferent al navegador i torneu-ho a provar."; "Timed out waiting for Cursor account switch. %@" = "S'ha esgotat el temps d'espera del canvi de compte de Cursor. %@"; "Timed out waiting for Cursor account switch. %@ Last error: %@" = "S'ha esgotat el temps d'espera del canvi de compte de Cursor. %@ Últim error: %@"; + +/* Spend dashboard */ +"tab_usage_spend" = "Ús i despesa"; +"Usage & Spend" = "Ús i despesa"; +"Local estimated cost history across supported providers." = "Historial local de costos estimats dels proveïdors compatibles."; +"Time range" = "Interval de temps"; +"Track costs" = "Fes seguiment dels costos"; +"Cost tracking is off" = "El seguiment de costos està desactivat"; +"Turn on Track costs to build local estimates." = "Activa «Fes seguiment dels costos» per crear estimacions locals."; +"No local cost history yet" = "Encara no hi ha historial local de costos"; +"Turn on cost tracking or refresh after using a supported provider." = "Activa el seguiment de costos o actualitza després d’utilitzar un proveïdor compatible."; +"Refresh failures" = "Actualitzacions fallides"; +"Native currencies stay separate; Codex account rows exclude Pi session history." = "Les monedes originals es mantenen separades; les files de comptes de Codex exclouen l’historial de sessions de Pi."; +"Spend unavailable" = "Despesa no disponible"; +"Model breakdown unavailable" = "Desglossament per model no disponible"; +"Local estimated history" = "Historial local estimat"; +"Coverage" = "Cobertura"; +"Estimated spend" = "Despesa estimada"; +"Tracked tokens" = "Tokens registrats"; +"Subscriptions" = "Subscripcions"; +"By subscription" = "Per subscripció"; +"No model-level history" = "Sense historial per model"; +"Daily estimated spend" = "Despesa diària estimada"; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index a4337647c8..d24bdf7ae7 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -7,6 +7,7 @@ " providers" = "Anbieter"; "(System)" = "(System)"; "30d" = "30d"; +"7d" = "7d"; "A managed Codex login is already running. Wait for it to finish before adding " = "Eine verwaltete Codex-Anmeldung läuft bereits. Warten Sie, bis der Vorgang abgeschlossen ist, bevor Sie ihn hinzufügen"; "API key" = "API-Schlüssel"; "API region" = "API-Region"; @@ -1210,3 +1211,25 @@ "Timed out waiting for Cursor account switch. %@ Last error: %@" = "Zeitüberschreitung beim Warten auf den Wechsel des Cursor-Kontos. %@ Letzter Fehler: %@"; "Use Account" = "Konto verwenden"; "Workspace" = "Arbeitsbereich"; +/* Spend dashboard */ +"tab_usage_spend" = "Nutzung & Ausgaben"; +"Usage & Spend" = "Nutzung & Ausgaben"; +"Local estimated cost history across supported providers." = "Lokaler Verlauf der geschätzten Kosten bei unterstützten Anbietern."; +"Time range" = "Zeitraum"; +"Track costs" = "Kosten verfolgen"; +"Cost tracking is off" = "Kostenverfolgung ist deaktiviert"; +"Turn on Track costs to build local estimates." = "Aktivieren Sie „Kosten verfolgen“, um lokale Schätzungen zu erstellen."; +"No local cost history yet" = "Noch kein lokaler Kostenverlauf"; +"Turn on cost tracking or refresh after using a supported provider." = "Aktivieren Sie die Kostenverfolgung oder aktualisieren Sie nach der Nutzung eines unterstützten Anbieters."; +"Refresh failures" = "Fehlgeschlagene Aktualisierungen"; +"Native currencies stay separate; Codex account rows exclude Pi session history." = "Originalwährungen bleiben getrennt; Codex-Kontozeilen schließen den Pi-Sitzungsverlauf aus."; +"Spend unavailable" = "Ausgaben nicht verfügbar"; +"Model breakdown unavailable" = "Modellaufschlüsselung nicht verfügbar"; +"Local estimated history" = "Lokaler Schätzverlauf"; +"Coverage" = "Abdeckung"; +"Estimated spend" = "Geschätzte Ausgaben"; +"Tracked tokens" = "Erfasste Token"; +"Subscriptions" = "Abonnements"; +"By subscription" = "Nach Abonnement"; +"No model-level history" = "Kein Verlauf auf Modellebene"; +"Daily estimated spend" = "Geschätzte tägliche Ausgaben"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 364584655d..33aef71116 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -7,6 +7,7 @@ " providers" = " providers"; "(System)" = "(System)"; "30d" = "30d"; +"7d" = "7d"; "A managed Codex login is already running. Wait for it to finish before adding " = "A managed Codex login is already running. Wait for it to finish before adding "; "API key" = "API key"; "API region" = "API region"; @@ -1210,3 +1211,26 @@ "Finish switching to a different Cursor account in your browser, then try again." = "Finish switching to a different Cursor account in your browser, then try again."; "Timed out waiting for Cursor account switch. %@" = "Timed out waiting for Cursor account switch. %@"; "Timed out waiting for Cursor account switch. %@ Last error: %@" = "Timed out waiting for Cursor account switch. %@ Last error: %@"; + +/* Spend dashboard */ +"tab_usage_spend" = "Usage & Spend"; +"Usage & Spend" = "Usage & Spend"; +"Local estimated cost history across supported providers." = "Local estimated cost history across supported providers."; +"Time range" = "Time range"; +"Track costs" = "Track costs"; +"Cost tracking is off" = "Cost tracking is off"; +"Turn on Track costs to build local estimates." = "Turn on Track costs to build local estimates."; +"No local cost history yet" = "No local cost history yet"; +"Turn on cost tracking or refresh after using a supported provider." = "Turn on cost tracking or refresh after using a supported provider."; +"Refresh failures" = "Refresh failures"; +"Native currencies stay separate; Codex account rows exclude Pi session history." = "Native currencies stay separate; Codex account rows exclude Pi session history."; +"Spend unavailable" = "Spend unavailable"; +"Model breakdown unavailable" = "Model breakdown unavailable"; +"Local estimated history" = "Local estimated history"; +"Coverage" = "Coverage"; +"Estimated spend" = "Estimated spend"; +"Tracked tokens" = "Tracked tokens"; +"Subscriptions" = "Subscriptions"; +"By subscription" = "By subscription"; +"No model-level history" = "No model-level history"; +"Daily estimated spend" = "Daily estimated spend"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index d1e4e662ea..2612223880 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -7,6 +7,7 @@ " providers" = " proveedores"; "(System)" = "(Sistema)"; "30d" = "30 d"; +"7d" = "7 d"; "A managed Codex login is already running. Wait for it to finish before adding " = "Ya hay un inicio de sesión gestionado de Codex en curso. Espera a que termine antes de añadir "; "API key" = "Clave de API"; "API region" = "Región de API"; @@ -1208,3 +1209,25 @@ "Use Account" = "Usar cuenta"; "Weekly" = "Semanal"; "z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." = "No se encontró el token de API de z.ai. Define apiKey en ~/.codexbar/config.json o Z_AI_API_KEY."; +/* Spend dashboard */ +"tab_usage_spend" = "Uso y gasto"; +"Usage & Spend" = "Uso y gasto"; +"Local estimated cost history across supported providers." = "Historial local de costes estimados de proveedores compatibles."; +"Time range" = "Intervalo de tiempo"; +"Track costs" = "Registrar costes"; +"Cost tracking is off" = "El seguimiento de costes está desactivado"; +"Turn on Track costs to build local estimates." = "Activa «Registrar costes» para crear estimaciones locales."; +"No local cost history yet" = "Aún no hay historial local de costes"; +"Turn on cost tracking or refresh after using a supported provider." = "Activa el seguimiento local de costes o actualiza después de usar un proveedor compatible."; +"Refresh failures" = "Actualizaciones fallidas"; +"Native currencies stay separate; Codex account rows exclude Pi session history." = "Las divisas originales se mantienen separadas; las filas de cuentas de Codex excluyen el historial de sesiones de Pi."; +"Spend unavailable" = "Gasto no disponible"; +"Model breakdown unavailable" = "Desglose por modelo no disponible"; +"Local estimated history" = "Historial local estimado"; +"Coverage" = "Cobertura"; +"Estimated spend" = "Gasto estimado"; +"Tracked tokens" = "Tokens registrados"; +"Subscriptions" = "Suscripciones"; +"By subscription" = "Por suscripción"; +"No model-level history" = "No hay historial por modelo"; +"Daily estimated spend" = "Gasto diario estimado"; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index 6e03bd43b8..3b37d1ffea 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -7,6 +7,7 @@ " providers" = " providers"; "(System)" = "(سیستم)"; "30d" = "30 روز"; +"7d" = "7 روز"; "A managed Codex login is already running. Wait for it to finish before adding " = "یک ورود Codex مدیریت شده هم اکنون در حال اجرا است. صبر کنید تا تمام شود و بعد را اضافه کنید"; "API key" = "کلید API"; "API region" = "منطقه API"; @@ -1210,3 +1211,26 @@ "Finish switching to a different Cursor account in your browser, then try again." = "تغییر به یک حساب Cursor دیگر را در مرورگر کامل کنید، سپس دوباره تلاش کنید."; "Timed out waiting for Cursor account switch. %@" = "مهلت انتظار برای تغییر حساب Cursor به پایان رسید. %@"; "Timed out waiting for Cursor account switch. %@ Last error: %@" = "مهلت انتظار برای تغییر حساب Cursor به پایان رسید. %@ آخرین خطا: %@"; + +/* Spend dashboard */ +"tab_usage_spend" = "مصرف و هزینه"; +"Usage & Spend" = "مصرف و هزینه"; +"Local estimated cost history across supported providers." = "تاریخچه برآورد هزینه محلی در ارائه‌دهندگان پشتیبانی‌شده."; +"Time range" = "بازه زمانی"; +"Track costs" = "پیگیری هزینه‌ها"; +"Cost tracking is off" = "پیگیری هزینه خاموش است"; +"Turn on Track costs to build local estimates." = "برای ایجاد برآوردهای محلی، «پیگیری هزینه‌ها» را روشن کنید."; +"No local cost history yet" = "هنوز تاریخچه هزینه محلی وجود ندارد"; +"Turn on cost tracking or refresh after using a supported provider." = "پیگیری هزینه را روشن کنید یا پس از استفاده از یک ارائه‌دهنده پشتیبانی‌شده تازه‌سازی کنید."; +"Refresh failures" = "خطاهای تازه‌سازی"; +"Native currencies stay separate; Codex account rows exclude Pi session history." = "ارزهای اصلی جدا نگه داشته می‌شوند؛ ردیف‌های حساب Codex تاریخچه نشست‌های Pi را دربر نمی‌گیرند."; +"Spend unavailable" = "هزینه در دسترس نیست"; +"Model breakdown unavailable" = "تفکیک مدل در دسترس نیست"; +"Local estimated history" = "تاریخچه برآورد محلی"; +"Coverage" = "پوشش"; +"Estimated spend" = "برآورد هزینه"; +"Tracked tokens" = "توکن‌های پیگیری‌شده"; +"Subscriptions" = "اشتراک‌ها"; +"By subscription" = "بر اساس اشتراک"; +"No model-level history" = "تاریخچه‌ای در سطح مدل وجود ندارد"; +"Daily estimated spend" = "برآورد هزینه روزانه"; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index db853cfde1..27918f36a6 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -7,6 +7,7 @@ " providers" = " fournisseurs"; "(System)" = "(System)"; "30d" = "30d"; +"7d" = "7d"; "A managed Codex login is already running. Wait for it to finish before adding " = "Une connexion Codex gérée est déjà en cours d'exécution. Attendez qu'il soit terminé avant d'ajouter"; "API key" = "Clé API"; "API region" = "Région API"; @@ -1209,3 +1210,25 @@ "Timed out waiting for Cursor account switch. %@" = "Le délai d'attente pour le changement de compte Cursor a expiré. %@"; "Timed out waiting for Cursor account switch. %@ Last error: %@" = "Le délai d'attente pour le changement de compte Cursor a expiré. %@ Dernière erreur : %@"; "Use Account" = "Utiliser le compte"; +/* Spend dashboard */ +"tab_usage_spend" = "Utilisation et dépenses"; +"Usage & Spend" = "Utilisation et dépenses"; +"Local estimated cost history across supported providers." = "Historique local des coûts estimés pour les fournisseurs pris en charge."; +"Time range" = "Période"; +"Track costs" = "Suivre les coûts"; +"Cost tracking is off" = "Le suivi des coûts est désactivé"; +"Turn on Track costs to build local estimates." = "Activez « Suivre les coûts » pour créer des estimations locales."; +"No local cost history yet" = "Aucun historique local des coûts pour l’instant"; +"Turn on cost tracking or refresh after using a supported provider." = "Activez le suivi local des coûts ou actualisez après avoir utilisé un fournisseur pris en charge."; +"Refresh failures" = "Échecs d’actualisation"; +"Native currencies stay separate; Codex account rows exclude Pi session history." = "Les devises d’origine restent séparées ; les lignes de compte Codex excluent l’historique des sessions Pi."; +"Spend unavailable" = "Dépenses indisponibles"; +"Model breakdown unavailable" = "Répartition par modèle indisponible"; +"Local estimated history" = "Historique local estimé"; +"Coverage" = "Couverture"; +"Estimated spend" = "Dépenses estimées"; +"Tracked tokens" = "Jetons suivis"; +"Subscriptions" = "Abonnements"; +"By subscription" = "Par abonnement"; +"No model-level history" = "Aucun historique au niveau des modèles"; +"Daily estimated spend" = "Dépenses quotidiennes estimées"; diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings index 4b12a97aa5..bfb4ab0c4e 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -7,6 +7,7 @@ " providers" = " provedores"; "(System)" = "(Sistema)"; "30d" = "30d"; +"7d" = "7d"; "A managed Codex login is already running. Wait for it to finish before adding " = "Xa hai un inicio de sesión xestionado de Codex en curso. Agarda a que remate antes de engadir "; "API key" = "Chave de API"; "API region" = "Rexión da API"; @@ -1205,3 +1206,26 @@ "Finish switching to a different Cursor account in your browser, then try again." = "Completa o cambio a outra conta de Cursor no navegador e téntao de novo."; "Timed out waiting for Cursor account switch. %@" = "Esgotouse o tempo de espera para cambiar de conta de Cursor. %@"; "Timed out waiting for Cursor account switch. %@ Last error: %@" = "Esgotouse o tempo de espera para cambiar de conta de Cursor. %@ Último erro: %@"; + +/* Spend dashboard */ +"tab_usage_spend" = "Uso e gasto"; +"Usage & Spend" = "Uso e gasto"; +"Local estimated cost history across supported providers." = "Historial local de custos estimados dos provedores compatibles."; +"Time range" = "Intervalo de tempo"; +"Track costs" = "Rastrexar custos"; +"Cost tracking is off" = "O seguimento de custos está desactivado"; +"Turn on Track costs to build local estimates." = "Activa «Rastrexar custos» para crear estimacións locais."; +"No local cost history yet" = "Aínda non hai historial local de custos"; +"Turn on cost tracking or refresh after using a supported provider." = "Activa o seguimento de custos ou actualiza despois de usar un provedor compatible."; +"Refresh failures" = "Actualizacións falladas"; +"Native currencies stay separate; Codex account rows exclude Pi session history." = "As moedas orixinais mantéñense separadas; as filas das contas de Codex exclúen o historial de sesións de Pi."; +"Spend unavailable" = "Gasto non dispoñible"; +"Model breakdown unavailable" = "Desglose por modelo non dispoñible"; +"Local estimated history" = "Historial local estimado"; +"Coverage" = "Cobertura"; +"Estimated spend" = "Gasto estimado"; +"Tracked tokens" = "Tokens rexistrados"; +"Subscriptions" = "Subscricións"; +"By subscription" = "Por subscrición"; +"No model-level history" = "Sen historial por modelo"; +"Daily estimated spend" = "Gasto diario estimado"; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index f60406c42c..da75c28464 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -7,6 +7,7 @@ " providers" = " penyedia"; "(System)" = "(Sistem)"; "30d" = "30 hari"; +"7d" = "7 hari"; "A managed Codex login is already running. Wait for it to finish before adding " = "Login Codex terkelola sudah berjalan. Tunggu hingga selesai sebelum menambahkan "; "API key" = "Kunci API"; "API region" = "Wilayah API"; @@ -1209,3 +1210,26 @@ "Finish switching to a different Cursor account in your browser, then try again." = "Selesaikan peralihan ke akun Cursor lain di browser Anda, lalu coba lagi."; "Timed out waiting for Cursor account switch. %@" = "Waktu tunggu untuk beralih akun Cursor habis. %@"; "Timed out waiting for Cursor account switch. %@ Last error: %@" = "Waktu tunggu untuk beralih akun Cursor habis. %@ Kesalahan terakhir: %@"; + +/* Spend dashboard */ +"tab_usage_spend" = "Penggunaan & Pengeluaran"; +"Usage & Spend" = "Penggunaan & Pengeluaran"; +"Local estimated cost history across supported providers." = "Riwayat perkiraan biaya lokal di seluruh penyedia yang didukung."; +"Time range" = "Rentang waktu"; +"Track costs" = "Lacak biaya"; +"Cost tracking is off" = "Pelacakan biaya dinonaktifkan"; +"Turn on Track costs to build local estimates." = "Aktifkan “Lacak biaya” untuk membuat perkiraan lokal."; +"No local cost history yet" = "Belum ada riwayat biaya lokal"; +"Turn on cost tracking or refresh after using a supported provider." = "Aktifkan pelacakan biaya atau segarkan setelah menggunakan penyedia yang didukung."; +"Refresh failures" = "Kegagalan penyegaran"; +"Native currencies stay separate; Codex account rows exclude Pi session history." = "Mata uang asli tetap dipisahkan; baris akun Codex tidak menyertakan riwayat sesi Pi."; +"Spend unavailable" = "Data pengeluaran tidak tersedia"; +"Model breakdown unavailable" = "Rincian per model tidak tersedia"; +"Local estimated history" = "Riwayat perkiraan lokal"; +"Coverage" = "Cakupan"; +"Estimated spend" = "Perkiraan pengeluaran"; +"Tracked tokens" = "Token yang dilacak"; +"Subscriptions" = "Langganan"; +"By subscription" = "Berdasarkan langganan"; +"No model-level history" = "Tidak ada riwayat tingkat model"; +"Daily estimated spend" = "Perkiraan pengeluaran harian"; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index 257493a9b6..81a1b74b14 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -7,6 +7,7 @@ " providers" = " provider"; "(System)" = "(Sistema)"; "30d" = "30 g"; +"7d" = "7 g"; "A managed Codex login is already running. Wait for it to finish before adding " = "È già in corso un accesso gestito a Codex. Attendi che termini prima di aggiungere "; "API key" = "Chiave API"; "API region" = "Regione API"; @@ -1209,3 +1210,26 @@ "Finish switching to a different Cursor account in your browser, then try again." = "Completa il passaggio a un altro account Cursor nel browser, quindi riprova."; "Timed out waiting for Cursor account switch. %@" = "Tempo scaduto in attesa del cambio di account Cursor. %@"; "Timed out waiting for Cursor account switch. %@ Last error: %@" = "Tempo scaduto in attesa del cambio di account Cursor. %@ Ultimo errore: %@"; + +/* Spend dashboard */ +"tab_usage_spend" = "Utilizzo e spesa"; +"Usage & Spend" = "Utilizzo e spesa"; +"Local estimated cost history across supported providers." = "Cronologia locale dei costi stimati per i provider supportati."; +"Time range" = "Intervallo di tempo"; +"Track costs" = "Tieni traccia dei costi"; +"Cost tracking is off" = "Il monitoraggio dei costi è disattivato"; +"Turn on Track costs to build local estimates." = "Attiva «Tieni traccia dei costi» per creare stime locali."; +"No local cost history yet" = "Ancora nessuna cronologia locale dei costi"; +"Turn on cost tracking or refresh after using a supported provider." = "Attiva il monitoraggio dei costi o aggiorna dopo aver usato un provider supportato."; +"Refresh failures" = "Aggiornamenti non riusciti"; +"Native currencies stay separate; Codex account rows exclude Pi session history." = "Le valute originali rimangono separate; le righe degli account Codex escludono la cronologia delle sessioni Pi."; +"Spend unavailable" = "Spesa non disponibile"; +"Model breakdown unavailable" = "Ripartizione per modello non disponibile"; +"Local estimated history" = "Cronologia locale stimata"; +"Coverage" = "Copertura"; +"Estimated spend" = "Spesa stimata"; +"Tracked tokens" = "Token tracciati"; +"Subscriptions" = "Abbonamenti"; +"By subscription" = "Per abbonamento"; +"No model-level history" = "Nessuna cronologia a livello di modello"; +"Daily estimated spend" = "Spesa giornaliera stimata"; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index f8663a3705..667c150160 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -7,6 +7,7 @@ " providers" = " 件のプロバイダ"; "(System)" = "(システム)"; "30d" = "30日"; +"7d" = "7日"; "A managed Codex login is already running. Wait for it to finish before adding " = "管理対象の Codex ログインがすでに実行中です。完了を待ってから追加してください "; "API key" = "API キー"; "API region" = "API リージョン"; @@ -1210,3 +1211,25 @@ "Timed out waiting for Cursor account switch. %@" = "Cursor アカウントの切り替え待機中にタイムアウトしました。%@"; "Timed out waiting for Cursor account switch. %@ Last error: %@" = "Cursor アカウントの切り替え待機中にタイムアウトしました。%@ 最後のエラー: %@"; "Use Account" = "アカウントを使用する"; +/* Spend dashboard */ +"tab_usage_spend" = "使用量と支出"; +"Usage & Spend" = "使用量と支出"; +"Local estimated cost history across supported providers." = "対応プロバイダ全体のローカル推定コスト履歴。"; +"Time range" = "期間"; +"Track costs" = "コストを追跡"; +"Cost tracking is off" = "コスト追跡はオフです"; +"Turn on Track costs to build local estimates." = "「コストを追跡」をオンにしてローカル推定を作成してください。"; +"No local cost history yet" = "ローカルのコスト履歴はまだありません"; +"Turn on cost tracking or refresh after using a supported provider." = "コスト追跡をオンにするか、対応プロバイダの使用後に更新してください。"; +"Refresh failures" = "更新失敗"; +"Native currencies stay separate; Codex account rows exclude Pi session history." = "各通貨は別々に扱われ、Codex アカウント行には Pi セッション履歴を含めません。"; +"Spend unavailable" = "支出を取得できません"; +"Model breakdown unavailable" = "モデル別の内訳を取得できません"; +"Local estimated history" = "ローカル推定履歴"; +"Coverage" = "対象範囲"; +"Estimated spend" = "推定支出"; +"Tracked tokens" = "追跡対象トークン"; +"Subscriptions" = "サブスクリプション"; +"By subscription" = "サブスクリプション別"; +"No model-level history" = "モデル別の履歴はありません"; +"Daily estimated spend" = "日別推定支出"; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index 9b7c1f921b..f3a1c8b73d 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -7,6 +7,7 @@ " providers" = " 공급자"; "(System)" = "(시스템)"; "30d" = "30일"; +"7d" = "7일"; "A managed Codex login is already running. Wait for it to finish before adding " = "관리되는 Codex 로그인이 이미 실행 중입니다. 추가하기 전에 완료될 때까지 기다리세요. "; "API key" = "API 키"; "API region" = "API 지역"; @@ -1177,3 +1178,25 @@ "Timed out waiting for Cursor account switch. %@" = "Cursor 계정 전환을 기다리는 동안 시간이 초과되었습니다. %@"; "Timed out waiting for Cursor account switch. %@ Last error: %@" = "Cursor 계정 전환을 기다리는 동안 시간이 초과되었습니다. %@ 마지막 오류: %@"; "Use Account" = "계정 사용"; +/* Spend dashboard */ +"tab_usage_spend" = "사용량 및 지출"; +"Usage & Spend" = "사용량 및 지출"; +"Local estimated cost history across supported providers." = "지원되는 공급자의 로컬 예상 비용 내역입니다."; +"Time range" = "기간"; +"Track costs" = "비용 추적"; +"Cost tracking is off" = "비용 추적이 꺼져 있습니다"; +"Turn on Track costs to build local estimates." = "로컬 예상치를 만들려면 ‘비용 추적’을 켜세요."; +"No local cost history yet" = "아직 로컬 비용 내역이 없습니다"; +"Turn on cost tracking or refresh after using a supported provider." = "비용 추적을 켜거나 지원되는 공급자를 사용한 후 새로 고치세요."; +"Refresh failures" = "새로 고침 실패"; +"Native currencies stay separate; Codex account rows exclude Pi session history." = "각 통화는 별도로 유지되며 Codex 계정 행에서는 Pi 세션 기록이 제외됩니다."; +"Spend unavailable" = "지출 정보 없음"; +"Model breakdown unavailable" = "모델별 내역을 사용할 수 없습니다"; +"Local estimated history" = "로컬 예상 내역"; +"Coverage" = "포함 범위"; +"Estimated spend" = "예상 지출"; +"Tracked tokens" = "추적된 토큰"; +"Subscriptions" = "구독"; +"By subscription" = "구독별"; +"No model-level history" = "모델별 내역이 없습니다"; +"Daily estimated spend" = "일별 예상 지출"; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index 75c0fc20ca..e731ba96d2 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -7,6 +7,7 @@ " providers" = " providers"; "(System)" = "(Systeem)"; "30d" = "30d"; +"7d" = "7d"; "A managed Codex login is already running. Wait for it to finish before adding " = "Er is al een beheerde Codex-aanmelding actief. Wacht tot het klaar is voordat je het toevoegt"; "API key" = "API-sleutel"; "API region" = "API-regio"; @@ -1209,3 +1210,25 @@ "Timed out waiting for Cursor account switch. %@" = "Er is een time-out opgetreden tijdens het wachten op het wisselen van Cursor-account. %@"; "Timed out waiting for Cursor account switch. %@ Last error: %@" = "Er is een time-out opgetreden tijdens het wachten op het wisselen van Cursor-account. %@ Laatste fout: %@"; "Use Account" = "Gebruik account"; +/* Spend dashboard */ +"tab_usage_spend" = "Gebruik en uitgaven"; +"Usage & Spend" = "Gebruik en uitgaven"; +"Local estimated cost history across supported providers." = "Lokale geschiedenis met geschatte kosten voor ondersteunde aanbieders."; +"Time range" = "Tijdsbereik"; +"Track costs" = "Kosten bijhouden"; +"Cost tracking is off" = "Kostenregistratie is uitgeschakeld"; +"Turn on Track costs to build local estimates." = "Schakel ‘Kosten bijhouden’ in om lokale schattingen op te bouwen."; +"No local cost history yet" = "Nog geen lokale kostengeschiedenis"; +"Turn on cost tracking or refresh after using a supported provider." = "Schakel kostenregistratie in of vernieuw nadat je een ondersteunde aanbieder hebt gebruikt."; +"Refresh failures" = "Mislukte vernieuwingen"; +"Native currencies stay separate; Codex account rows exclude Pi session history." = "Oorspronkelijke valuta’s blijven gescheiden; rijen met Codex-accounts sluiten Pi-sessiegeschiedenis uit."; +"Spend unavailable" = "Uitgaven niet beschikbaar"; +"Model breakdown unavailable" = "Uitsplitsing per model niet beschikbaar"; +"Local estimated history" = "Lokaal geschatte geschiedenis"; +"Coverage" = "Dekking"; +"Estimated spend" = "Geschatte uitgaven"; +"Tracked tokens" = "Bijgehouden tokens"; +"Subscriptions" = "Abonnementen"; +"By subscription" = "Per abonnement"; +"No model-level history" = "Geen geschiedenis op modelniveau"; +"Daily estimated spend" = "Geschatte dagelijkse uitgaven"; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index d59d72e744..21052e88c3 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -7,6 +7,7 @@ " providers" = " providers"; "(System)" = "(System)"; "30d" = "30d"; +"7d" = "7d"; "A managed Codex login is already running. Wait for it to finish before adding " = "Trwa już zarządzane logowanie Codex. Poczekaj na jego zakończenie, zanim dodasz "; "API key" = "Klucz API"; "API region" = "Region API"; @@ -1209,3 +1210,26 @@ "Finish switching to a different Cursor account in your browser, then try again." = "Dokończ przełączanie na inne konto Cursor w przeglądarce, a następnie spróbuj ponownie."; "Timed out waiting for Cursor account switch. %@" = "Upłynął limit czasu oczekiwania na przełączenie konta Cursor. %@"; "Timed out waiting for Cursor account switch. %@ Last error: %@" = "Upłynął limit czasu oczekiwania na przełączenie konta Cursor. %@ Ostatni błąd: %@"; + +/* Spend dashboard */ +"tab_usage_spend" = "Użycie i wydatki"; +"Usage & Spend" = "Użycie i wydatki"; +"Local estimated cost history across supported providers." = "Lokalna historia szacowanych kosztów u obsługiwanych dostawców."; +"Time range" = "Zakres czasu"; +"Track costs" = "Śledź koszty"; +"Cost tracking is off" = "Śledzenie kosztów jest wyłączone"; +"Turn on Track costs to build local estimates." = "Włącz opcję „Śledź koszty”, aby tworzyć lokalne szacunki."; +"No local cost history yet" = "Brak lokalnej historii kosztów"; +"Turn on cost tracking or refresh after using a supported provider." = "Włącz śledzenie kosztów lub odśwież po użyciu obsługiwanego dostawcy."; +"Refresh failures" = "Błędy odświeżania"; +"Native currencies stay separate; Codex account rows exclude Pi session history." = "Waluty źródłowe pozostają rozdzielone; wiersze kont Codex nie obejmują historii sesji Pi."; +"Spend unavailable" = "Wydatki niedostępne"; +"Model breakdown unavailable" = "Podział według modeli jest niedostępny"; +"Local estimated history" = "Lokalna historia szacunkowa"; +"Coverage" = "Pokrycie"; +"Estimated spend" = "Szacowane wydatki"; +"Tracked tokens" = "Śledzone tokeny"; +"Subscriptions" = "Subskrypcje"; +"By subscription" = "Według subskrypcji"; +"No model-level history" = "Brak historii na poziomie modeli"; +"Daily estimated spend" = "Szacowane dzienne wydatki"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index a75edeec58..c9c8ffc63f 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -7,6 +7,7 @@ " providers" = " provedores"; "(System)" = "(Sistema)"; "30d" = "30d"; +"7d" = "7d"; "A managed Codex login is already running. Wait for it to finish before adding " = "Um login gerenciado do Codex já está em andamento. Aguarde terminar antes de adicionar "; "API key" = "Chave de API"; "API region" = "Região da API"; @@ -1210,3 +1211,25 @@ "Timed out waiting for Cursor account switch. %@" = "Tempo limite esgotado aguardando a troca de conta do Cursor. %@"; "Timed out waiting for Cursor account switch. %@ Last error: %@" = "Tempo limite esgotado aguardando a troca de conta do Cursor. %@ Último erro: %@"; "Use Account" = "Usar conta"; +/* Spend dashboard */ +"tab_usage_spend" = "Uso e gastos"; +"Usage & Spend" = "Uso e gastos"; +"Local estimated cost history across supported providers." = "Histórico local de custos estimados nos provedores compatíveis."; +"Time range" = "Intervalo de tempo"; +"Track costs" = "Acompanhar custos"; +"Cost tracking is off" = "O acompanhamento de custos está desativado"; +"Turn on Track costs to build local estimates." = "Ative “Acompanhar custos” para criar estimativas locais."; +"No local cost history yet" = "Ainda não há histórico local de custos"; +"Turn on cost tracking or refresh after using a supported provider." = "Ative o acompanhamento de custos ou atualize após usar um provedor compatível."; +"Refresh failures" = "Falhas de atualização"; +"Native currencies stay separate; Codex account rows exclude Pi session history." = "As moedas originais permanecem separadas; as linhas de contas do Codex excluem o histórico de sessões do Pi."; +"Spend unavailable" = "Gastos indisponíveis"; +"Model breakdown unavailable" = "Detalhamento por modelo indisponível"; +"Local estimated history" = "Histórico local estimado"; +"Coverage" = "Cobertura"; +"Estimated spend" = "Gastos estimados"; +"Tracked tokens" = "Tokens acompanhados"; +"Subscriptions" = "Assinaturas"; +"By subscription" = "Por assinatura"; +"No model-level history" = "Sem histórico por modelo"; +"Daily estimated spend" = "Gasto diário estimado"; diff --git a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings index b5829eddc5..f9a8449124 100644 --- a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings @@ -7,6 +7,7 @@ " providers" = " провайдеров"; "(System)" = "(Система)"; "30d" = "30 дн."; +"7d" = "7 дн."; "A managed Codex login is already running. Wait for it to finish before adding " = "Управляемый вход Codex уже активен. Подождите, пока он завершится, прежде чем добавлять "; "API key" = "API-ключ"; "API region" = "Регион API"; @@ -1211,3 +1212,25 @@ "Timed out waiting for Cursor account switch. %@" = "Истекло время ожидания переключения учетной записи Cursor. %@"; "Timed out waiting for Cursor account switch. %@ Last error: %@" = "Истекло время ожидания переключения учетной записи Cursor. %@ Последняя ошибка: %@"; "Use Account" = "Использовать учетную запись"; +/* Spend dashboard */ +"tab_usage_spend" = "Использование и расходы"; +"Usage & Spend" = "Использование и расходы"; +"Local estimated cost history across supported providers." = "Локальная история предполагаемых расходов у поддерживаемых провайдеров."; +"Time range" = "Период"; +"Track costs" = "Отслеживать расходы"; +"Cost tracking is off" = "Отслеживание расходов выключено"; +"Turn on Track costs to build local estimates." = "Включите «Отслеживать расходы», чтобы создавать локальные оценки."; +"No local cost history yet" = "Локальной истории расходов пока нет"; +"Turn on cost tracking or refresh after using a supported provider." = "Включите отслеживание расходов или обновите данные после использования поддерживаемого провайдера."; +"Refresh failures" = "Ошибки обновления"; +"Native currencies stay separate; Codex account rows exclude Pi session history." = "Исходные валюты остаются раздельными; строки учётных записей Codex не включают историю сеансов Pi."; +"Spend unavailable" = "Расходы недоступны"; +"Model breakdown unavailable" = "Разбивка по моделям недоступна"; +"Local estimated history" = "Локальная история оценок"; +"Coverage" = "Охват"; +"Estimated spend" = "Предполагаемые расходы"; +"Tracked tokens" = "Отслеживаемые токены"; +"Subscriptions" = "Подписки"; +"By subscription" = "По подпискам"; +"No model-level history" = "Нет истории по моделям"; +"Daily estimated spend" = "Предполагаемые ежедневные расходы"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index 72cf284d3d..cb9a28af2c 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -7,6 +7,7 @@ " providers" = " leverantörer"; "(System)" = "(System)"; "30d" = "30 d"; +"7d" = "7 d"; "A managed Codex login is already running. Wait for it to finish before adding " = "En hanterad Codex-inloggning körs redan. Vänta tills den är klar innan du lägger till "; "API key" = "API-nyckel"; "API region" = "API-region"; @@ -1208,3 +1209,25 @@ "Timed out waiting for Cursor account switch. %@" = "Tidsgränsen överskreds i väntan på byte av Cursor-konto. %@"; "Timed out waiting for Cursor account switch. %@ Last error: %@" = "Tidsgränsen överskreds i väntan på byte av Cursor-konto. %@ Senaste fel: %@"; "Use Account" = "Använd konto"; +/* Spend dashboard */ +"tab_usage_spend" = "Användning och utgifter"; +"Usage & Spend" = "Användning och utgifter"; +"Local estimated cost history across supported providers." = "Lokal historik över uppskattade kostnader från leverantörer som stöds."; +"Time range" = "Tidsintervall"; +"Track costs" = "Spåra kostnader"; +"Cost tracking is off" = "Kostnadsspårning är avstängd"; +"Turn on Track costs to build local estimates." = "Aktivera ”Spåra kostnader” för att skapa lokala uppskattningar."; +"No local cost history yet" = "Ingen lokal kostnadshistorik än"; +"Turn on cost tracking or refresh after using a supported provider." = "Aktivera kostnadsspårning eller uppdatera efter att ha använt en leverantör som stöds."; +"Refresh failures" = "Misslyckade uppdateringar"; +"Native currencies stay separate; Codex account rows exclude Pi session history." = "Ursprungliga valutor hålls åtskilda; rader för Codex-konton utesluter Pi-sessionshistorik."; +"Spend unavailable" = "Utgifter ej tillgängliga"; +"Model breakdown unavailable" = "Modellfördelning ej tillgänglig"; +"Local estimated history" = "Lokal uppskattad historik"; +"Coverage" = "Täckning"; +"Estimated spend" = "Uppskattade utgifter"; +"Tracked tokens" = "Spårade token"; +"Subscriptions" = "Abonnemang"; +"By subscription" = "Per abonnemang"; +"No model-level history" = "Ingen historik på modellnivå"; +"Daily estimated spend" = "Uppskattade dagliga utgifter"; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index 9bdec2c2ac..96d68e79bf 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -7,6 +7,7 @@ " providers" = "ผู้ให้บริการ "; "(System)" = "(ระบบ)"; "30d" = "30 วัน"; +"7d" = "7 วัน"; "A managed Codex login is already running. Wait for it to finish before adding " = "การเข้าสู่ระบบ Codex ที่มีการจัดการกําลังทํางานอยู่แล้ว รอให้เสร็จก่อนที่จะเพิ่ม "; "API key" = "ปุ่ม API"; "API region" = "ภูมิภาค API"; @@ -1210,3 +1211,26 @@ "Finish switching to a different Cursor account in your browser, then try again." = "สลับไปยังบัญชี Cursor อื่นในเบราว์เซอร์ให้เสร็จ แล้วลองอีกครั้ง"; "Timed out waiting for Cursor account switch. %@" = "หมดเวลารอการสลับบัญชี Cursor %@"; "Timed out waiting for Cursor account switch. %@ Last error: %@" = "หมดเวลารอการสลับบัญชี Cursor %@ ข้อผิดพลาดล่าสุด: %@"; + +/* Spend dashboard */ +"tab_usage_spend" = "การใช้งานและค่าใช้จ่าย"; +"Usage & Spend" = "การใช้งานและค่าใช้จ่าย"; +"Local estimated cost history across supported providers." = "ประวัติค่าใช้จ่ายโดยประมาณในเครื่องจากผู้ให้บริการที่รองรับ"; +"Time range" = "ช่วงเวลา"; +"Track costs" = "ติดตามค่าใช้จ่าย"; +"Cost tracking is off" = "ปิดการติดตามค่าใช้จ่ายอยู่"; +"Turn on Track costs to build local estimates." = "เปิด “ติดตามค่าใช้จ่าย” เพื่อสร้างการประมาณการในเครื่อง"; +"No local cost history yet" = "ยังไม่มีประวัติค่าใช้จ่ายในเครื่อง"; +"Turn on cost tracking or refresh after using a supported provider." = "เปิดการติดตามค่าใช้จ่ายหรือรีเฟรชหลังจากใช้ผู้ให้บริการที่รองรับ"; +"Refresh failures" = "การรีเฟรชที่ล้มเหลว"; +"Native currencies stay separate; Codex account rows exclude Pi session history." = "สกุลเงินต้นทางแยกจากกัน แถวบัญชี Codex ไม่รวมประวัติเซสชัน Pi"; +"Spend unavailable" = "ไม่มีข้อมูลค่าใช้จ่าย"; +"Model breakdown unavailable" = "ไม่มีรายละเอียดแยกตามโมเดล"; +"Local estimated history" = "ประวัติโดยประมาณในเครื่อง"; +"Coverage" = "ความครอบคลุม"; +"Estimated spend" = "ค่าใช้จ่ายโดยประมาณ"; +"Tracked tokens" = "โทเค็นที่ติดตาม"; +"Subscriptions" = "การสมัครสมาชิก"; +"By subscription" = "แยกตามการสมัครสมาชิก"; +"No model-level history" = "ไม่มีประวัติระดับโมเดล"; +"Daily estimated spend" = "ค่าใช้จ่ายรายวันโดยประมาณ"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index 5ca10bf770..34294eed78 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -7,6 +7,7 @@ " providers" = " sağlayıcı"; "(System)" = "(Sistem)"; "30d" = "30 gün"; +"7d" = "7 gün"; "A managed Codex login is already running. Wait for it to finish before adding " = "Yönetilen bir Codex girişi zaten çalışıyor. Eklemeden önce bitmesini bekleyin "; "API key" = "API anahtarı"; "API region" = "API bölgesi"; @@ -1207,3 +1208,26 @@ "Finish switching to a different Cursor account in your browser, then try again." = "Tarayıcınızda farklı bir Cursor hesabına geçişi tamamlayın, ardından yeniden deneyin."; "Timed out waiting for Cursor account switch. %@" = "Cursor hesap değişikliği beklenirken zaman aşımına uğradı. %@"; "Timed out waiting for Cursor account switch. %@ Last error: %@" = "Cursor hesap değişikliği beklenirken zaman aşımına uğradı. %@ Son hata: %@"; + +/* Spend dashboard */ +"tab_usage_spend" = "Kullanım ve Harcama"; +"Usage & Spend" = "Kullanım ve Harcama"; +"Local estimated cost history across supported providers." = "Desteklenen sağlayıcılardaki yerel tahmini maliyet geçmişi."; +"Time range" = "Zaman aralığı"; +"Track costs" = "Maliyetleri izle"; +"Cost tracking is off" = "Maliyet takibi kapalı"; +"Turn on Track costs to build local estimates." = "Yerel tahminler oluşturmak için “Maliyetleri izle” seçeneğini açın."; +"No local cost history yet" = "Henüz yerel maliyet geçmişi yok"; +"Turn on cost tracking or refresh after using a supported provider." = "Maliyet takibini açın veya desteklenen bir sağlayıcıyı kullandıktan sonra yenileyin."; +"Refresh failures" = "Yenileme hataları"; +"Native currencies stay separate; Codex account rows exclude Pi session history." = "Kaynak para birimleri ayrı tutulur; Codex hesap satırlarına Pi oturum geçmişi dahil edilmez."; +"Spend unavailable" = "Harcama verisi kullanılamıyor"; +"Model breakdown unavailable" = "Model dökümü kullanılamıyor"; +"Local estimated history" = "Yerel tahmini geçmiş"; +"Coverage" = "Kapsam"; +"Estimated spend" = "Tahmini harcama"; +"Tracked tokens" = "İzlenen tokenlar"; +"Subscriptions" = "Abonelikler"; +"By subscription" = "Aboneliğe göre"; +"No model-level history" = "Model düzeyinde geçmiş yok"; +"Daily estimated spend" = "Günlük tahmini harcama"; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index d2a802d73d..644ddb3b45 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -7,6 +7,7 @@ " providers" = "провайдерів"; "(System)" = "(Система)"; "30d" = "30д"; +"7d" = "7д"; "A managed Codex login is already running. Wait for it to finish before adding " = "Керований вхід до Codex вже запущено. Перш ніж додавати, зачекайте, поки він закінчиться"; "API key" = "Ключ API"; "API region" = "регіон API"; @@ -1209,3 +1210,25 @@ "Timed out waiting for Cursor account switch. %@" = "Минув час очікування зміни облікового запису Cursor. %@"; "Timed out waiting for Cursor account switch. %@ Last error: %@" = "Минув час очікування зміни облікового запису Cursor. %@ Остання помилка: %@"; "Use Account" = "Використати обліковий запис"; +/* Spend dashboard */ +"tab_usage_spend" = "Використання й витрати"; +"Usage & Spend" = "Використання й витрати"; +"Local estimated cost history across supported providers." = "Локальна історія орієнтовних витрат у підтримуваних провайдерів."; +"Time range" = "Період"; +"Track costs" = "Відстежувати витрати"; +"Cost tracking is off" = "Відстеження витрат вимкнено"; +"Turn on Track costs to build local estimates." = "Увімкніть «Відстежувати витрати», щоб створювати локальні оцінки."; +"No local cost history yet" = "Локальної історії витрат ще немає"; +"Turn on cost tracking or refresh after using a supported provider." = "Увімкніть відстеження витрат або оновіть дані після використання підтримуваного провайдера."; +"Refresh failures" = "Помилки оновлення"; +"Native currencies stay separate; Codex account rows exclude Pi session history." = "Вихідні валюти залишаються розділеними; рядки облікових записів Codex не включають історію сеансів Pi."; +"Spend unavailable" = "Витрати недоступні"; +"Model breakdown unavailable" = "Розподіл за моделями недоступний"; +"Local estimated history" = "Локальна історія оцінок"; +"Coverage" = "Охоплення"; +"Estimated spend" = "Орієнтовні витрати"; +"Tracked tokens" = "Відстежувані токени"; +"Subscriptions" = "Підписки"; +"By subscription" = "За підписками"; +"No model-level history" = "Немає історії за моделями"; +"Daily estimated spend" = "Орієнтовні щоденні витрати"; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index 70bb23931c..d430188dd6 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -7,6 +7,7 @@ " providers" = "nhà cung cấp"; "(System)" = "(Hệ thống)"; "30d" = "30d"; +"7d" = "7d"; "A managed Codex login is already running. Wait for it to finish before adding " = "Đăng nhập Codex được quản lý đã chạy. Đợi quá trình này hoàn tất trước khi thêm"; "API key" = "API khóa"; "API region" = "API khu vực"; @@ -1210,3 +1211,25 @@ "Timed out waiting for Cursor account switch. %@" = "Đã hết thời gian chờ chuyển đổi tài khoản Cursor. %@"; "Timed out waiting for Cursor account switch. %@ Last error: %@" = "Đã hết thời gian chờ chuyển đổi tài khoản Cursor. %@ Lỗi gần nhất: %@"; "Use Account" = "Sử dụng tài khoản"; +/* Spend dashboard */ +"tab_usage_spend" = "Sử dụng & Chi tiêu"; +"Usage & Spend" = "Sử dụng & Chi tiêu"; +"Local estimated cost history across supported providers." = "Lịch sử chi phí ước tính cục bộ trên các nhà cung cấp được hỗ trợ."; +"Time range" = "Khoảng thời gian"; +"Track costs" = "Theo dõi chi phí"; +"Cost tracking is off" = "Đang tắt tính năng theo dõi chi phí"; +"Turn on Track costs to build local estimates." = "Bật “Theo dõi chi phí” để tạo ước tính cục bộ."; +"No local cost history yet" = "Chưa có lịch sử chi phí cục bộ"; +"Turn on cost tracking or refresh after using a supported provider." = "Bật tính năng theo dõi chi phí hoặc làm mới sau khi sử dụng nhà cung cấp được hỗ trợ."; +"Refresh failures" = "Lần làm mới thất bại"; +"Native currencies stay separate; Codex account rows exclude Pi session history." = "Đơn vị tiền tệ gốc được giữ riêng biệt; các hàng tài khoản Codex không bao gồm lịch sử phiên Pi."; +"Spend unavailable" = "Không có dữ liệu chi tiêu"; +"Model breakdown unavailable" = "Phân tích theo mô hình không khả dụng"; +"Local estimated history" = "Lịch sử ước tính cục bộ"; +"Coverage" = "Phạm vi"; +"Estimated spend" = "Chi tiêu ước tính"; +"Tracked tokens" = "Token được theo dõi"; +"Subscriptions" = "Gói đăng ký"; +"By subscription" = "Theo gói đăng ký"; +"No model-level history" = "Không có lịch sử theo mô hình"; +"Daily estimated spend" = "Chi tiêu ước tính hằng ngày"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index d19be1f7a4..3a1c5c50a3 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -7,6 +7,7 @@ " providers" = " 提供商"; "(System)" = "(System)"; "30d" = "30 天"; +"7d" = "7 天"; "A managed Codex login is already running. Wait for it to finish before adding " = "托管 Codex 登录已在运行。请等待其完成后再添加 "; "API key" = "API 密钥"; "API key limit" = "API 密钥限制"; @@ -756,7 +757,7 @@ "This week" = "本周"; "Week" = "本周"; "Month" = "本月"; -"Models" = "模型数"; +"Models" = "模型"; "24h tokens" = "24 小时 token 用量"; "Latest hour" = "最近 1 小时"; "Peak hour" = "峰值小时"; @@ -1185,3 +1186,25 @@ "Timed out waiting for Cursor account switch. %@" = "等待切换 Cursor 账户超时。%@"; "Timed out waiting for Cursor account switch. %@ Last error: %@" = "等待切换 Cursor 账户超时。%@ 最近错误:%@"; "Use Account" = "使用此账户"; +/* Spend dashboard */ +"tab_usage_spend" = "用量与支出"; +"Usage & Spend" = "用量与支出"; +"Local estimated cost history across supported providers." = "所有受支持提供商的本地估算费用历史。"; +"Time range" = "时间范围"; +"Track costs" = "跟踪费用"; +"Cost tracking is off" = "费用跟踪已关闭"; +"Turn on Track costs to build local estimates." = "启用“跟踪费用”以生成本地估算。"; +"No local cost history yet" = "暂无本地费用历史"; +"Turn on cost tracking or refresh after using a supported provider." = "启用费用跟踪,或在使用受支持的提供商后刷新。"; +"Refresh failures" = "刷新失败"; +"Native currencies stay separate; Codex account rows exclude Pi session history." = "各原始币种保持分开;Codex 帐户行不包含 Pi 会话历史。"; +"Spend unavailable" = "支出数据不可用"; +"Model breakdown unavailable" = "模型明细不可用"; +"Local estimated history" = "本地估算历史"; +"Coverage" = "覆盖范围"; +"Estimated spend" = "估算支出"; +"Tracked tokens" = "已跟踪 token"; +"Subscriptions" = "订阅"; +"By subscription" = "按订阅"; +"No model-level history" = "暂无模型级历史"; +"Daily estimated spend" = "每日估算支出"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index 9e51c904e4..5ce2b25813 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -7,6 +7,7 @@ " providers" = " 提供者"; "(System)" = "(系統)"; "30d" = "30 天"; +"7d" = "7 天"; "A managed Codex login is already running. Wait for it to finish before adding " = "託管 Codex 登入已在執行。請等待其完成後再新增 "; "API key" = "API 金鑰"; "API key limit" = "API 金鑰限制"; @@ -806,7 +807,7 @@ "This week" = "本週"; "Week" = "週"; "Month" = "月"; -"Models" = "模型數"; +"Models" = "模型"; "24h tokens" = "24 小時 token"; "Latest hour" = "最新小時"; "Peak hour" = "尖峰小時"; @@ -1240,3 +1241,25 @@ "Timed out waiting for Cursor account switch. %@" = "等待切換 Cursor 帳號逾時。%@"; "Timed out waiting for Cursor account switch. %@ Last error: %@" = "等待切換 Cursor 帳號逾時。%@ 最近錯誤:%@"; "Use Account" = "使用此帳號"; +/* Spend dashboard */ +"tab_usage_spend" = "使用量與支出"; +"Usage & Spend" = "使用量與支出"; +"Local estimated cost history across supported providers." = "所有支援提供者的本機預估費用歷史。"; +"Time range" = "時間範圍"; +"Track costs" = "追蹤費用"; +"Cost tracking is off" = "費用追蹤已關閉"; +"Turn on Track costs to build local estimates." = "開啟「追蹤費用」以建立本機預估。"; +"No local cost history yet" = "尚無本機費用歷史"; +"Turn on cost tracking or refresh after using a supported provider." = "開啟費用追蹤,或在使用支援的提供者後重新整理。"; +"Refresh failures" = "重新整理失敗"; +"Native currencies stay separate; Codex account rows exclude Pi session history." = "各原始幣別保持分開;Codex 帳號列不包含 Pi 工作階段歷史。"; +"Spend unavailable" = "無法取得支出資料"; +"Model breakdown unavailable" = "無法取得模型明細"; +"Local estimated history" = "本機預估歷史"; +"Coverage" = "涵蓋範圍"; +"Estimated spend" = "預估支出"; +"Tracked tokens" = "已追蹤 token"; +"Subscriptions" = "訂閱"; +"By subscription" = "依訂閱"; +"No model-level history" = "尚無模型層級歷史"; +"Daily estimated spend" = "每日預估支出"; diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index ba408160d6..5985a08abe 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -374,8 +374,12 @@ extension SettingsStore { var costUsageEnabled: Bool { get { self.defaultsState.costUsageEnabled } set { + let changed = self.defaultsState.costUsageEnabled != newValue self.defaultsState.costUsageEnabled = newValue self.userDefaults.set(newValue, forKey: "tokenCostUsageEnabled") + if changed { + self.costUsageSettingsRevision &+= 1 + } self.noteBackgroundWorkSettingsChanged() } } @@ -384,8 +388,12 @@ extension SettingsStore { get { self.defaultsState.costUsageHistoryDays } set { let clamped = max(1, min(365, newValue)) + let changed = self.defaultsState.costUsageHistoryDays != clamped self.defaultsState.costUsageHistoryDays = clamped self.userDefaults.set(clamped, forKey: "tokenCostUsageHistoryDays") + if changed { + self.costUsageSettingsRevision &+= 1 + } self.noteBackgroundWorkSettingsChanged() } } @@ -853,7 +861,9 @@ extension SettingsStore { for provider in providers where !seen.contains(provider) { seen.insert(provider) normalized.append(provider) - if let maxCount, normalized.count >= maxCount { break } + if let maxCount, normalized.count >= maxCount { + break + } } return normalized } diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 0b7b1873df..bcd1b07050 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -217,6 +217,7 @@ final class SettingsStore { var configRevision: Int = 0 var providerDetailSettingsRevision: Int = 0 var backgroundWorkSettingsRevision: Int = 0 + var costUsageSettingsRevision: UInt64 = 0 var providerOrder: [UsageProvider] = [] var providerEnablement: [UsageProvider: Bool] = [:] @ObservationIgnored var providerEnablementRevisions: [UsageProvider: UInt64] = [:] diff --git a/Sources/CodexBar/SpendDashboardController.swift b/Sources/CodexBar/SpendDashboardController.swift new file mode 100644 index 0000000000..6f9ec8c361 --- /dev/null +++ b/Sources/CodexBar/SpendDashboardController.swift @@ -0,0 +1,1049 @@ +import CodexBarCore +import CryptoKit +import Foundation +import Observation + +struct SpendDashboardConfiguration: Equatable, Sendable { + let costUsageEnabled: Bool + let providerIDs: [String] + let codexAccountIdentities: [String] + let codexAccountDisplayNames: [String: String] + let sourceOwnershipFingerprints: [String] + let sourceRevisions: [String] + + init( + costUsageEnabled: Bool, + providerIDs: [String], + codexAccountIdentities: [String], + codexAccountDisplayNames: [String: String] = [:], + sourceOwnershipFingerprints: [String] = [], + sourceRevisions: [String] = []) + { + self.costUsageEnabled = costUsageEnabled + self.providerIDs = providerIDs + self.codexAccountIdentities = codexAccountIdentities + self.codexAccountDisplayNames = codexAccountDisplayNames + self.sourceOwnershipFingerprints = sourceOwnershipFingerprints + self.sourceRevisions = sourceRevisions + } +} + +struct CodexSpendScanRequest: Equatable, Sendable { + let id: String + let displayName: String + let source: CodexActiveSource + let homePath: String + let authFingerprint: String? + let authFileWasReadable: Bool + let cacheIdentity: String +} + +enum SpendDashboardRequestBuildMode: Equatable, Sendable { + case refreshMissing + case forceRefresh + case captureOnly + + var forcesLoader: Bool { + self == .forceRefresh + } + + func shouldRefresh(hasPublication: Bool) -> Bool { + switch self { + case .refreshMissing: !hasPublication + case .forceRefresh: true + case .captureOnly: false + } + } +} + +struct SpendDashboardLoadRequest: Sendable { + let configuration: SpendDashboardConfiguration + let capturedInputs: [SpendDashboardModel.ProviderInput] + let unavailableSourceIDs: Set + let confirmedEmptySourceIDs: Set + let codexRequests: [CodexSpendScanRequest] + let now: Date + let force: Bool + + init( + configuration: SpendDashboardConfiguration, + capturedInputs: [SpendDashboardModel.ProviderInput], + unavailableSourceIDs: Set, + confirmedEmptySourceIDs: Set = [], + codexRequests: [CodexSpendScanRequest], + now: Date, + force: Bool) + { + self.configuration = configuration + self.capturedInputs = capturedInputs + self.unavailableSourceIDs = unavailableSourceIDs + self.confirmedEmptySourceIDs = confirmedEmptySourceIDs + self.codexRequests = codexRequests + self.now = now + self.force = force + } +} + +struct SpendDashboardLoadResult: Sendable { + let inputs: [SpendDashboardModel.ProviderInput] + let failedSourceIDs: Set + let invalidatedSourceIDs: Set + + init( + inputs: [SpendDashboardModel.ProviderInput], + failedSourceIDs: Set, + invalidatedSourceIDs: Set = []) + { + self.inputs = inputs + self.failedSourceIDs = failedSourceIDs + self.invalidatedSourceIDs = invalidatedSourceIDs + } + + var failedSourceCount: Int { + self.failedSourceIDs.count + } +} + +struct CodexSpendSnapshotLoadContext: Sendable { + let account: CodexSpendScanRequest + let cacheRoot: URL + let now: Date + let force: Bool + let historyDays: Int + let refreshPricingInBackground: Bool + let includePiSessions: Bool +} + +enum SpendDashboardSource { + typealias CodexSnapshotLoader = @Sendable (CodexSpendSnapshotLoadContext) async throws + -> CostUsageTokenSnapshot + + static let scanDays = 30 + + @MainActor + static func configuration(settings: SettingsStore, store: UsageStore) -> SpendDashboardConfiguration { + let providers = self.costCapableProviders(store: store) + let codexRequests = providers.contains(.codex) + ? self.codexRequests(settings: settings, store: store) + : [] + return self.configuration( + settings: settings, + store: store, + providers: providers, + codexRequests: codexRequests) + } + + @MainActor + private static func configuration( + settings: SettingsStore, + store: UsageStore, + providers: [UsageProvider], + codexRequests: [CodexSpendScanRequest]) -> SpendDashboardConfiguration + { + SpendDashboardConfiguration( + costUsageEnabled: settings.costUsageEnabled, + providerIDs: providers.map(\.rawValue), + codexAccountIdentities: codexRequests.map { "\($0.id)|\($0.cacheIdentity)" }, + codexAccountDisplayNames: self.codexDisplayNamesByID(codexRequests), + sourceOwnershipFingerprints: self.sourceOwnershipFingerprints( + providers: providers, + settings: settings, + store: store), + sourceRevisions: self.sourceRevisions(providers: providers, settings: settings, store: store)) + } + + @MainActor + static func makeRequest( + settings: SettingsStore, + store: UsageStore, + mode: SpendDashboardRequestBuildMode, + now: Date? = nil, + nowProvider: @escaping @Sendable () -> Date = { Date() }) async -> SpendDashboardLoadRequest + { + guard settings.costUsageEnabled else { + return SpendDashboardLoadRequest( + configuration: self.configuration(settings: settings, store: store), + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [], + now: now ?? nowProvider(), + force: mode.forcesLoader) + } + + let initialProviders = self.costCapableProviders(store: store) + let providerBaselines = initialProviders.filter { $0 != .codex }.map { provider in + ( + provider: provider, + publication: store.tokenSnapshotPublicationForCurrentProviderConfig(for: provider), + publicationRevision: store.tokenSnapshotPublicationRevision(for: provider)) + } + for baseline in providerBaselines where mode.shouldRefresh(hasPublication: baseline.publication != nil) { + if UsageStore.tokenCostRequiresProviderSnapshot(baseline.provider) { + await store.refreshProvider(baseline.provider) + } else { + await store.refreshTokenUsageNow(for: baseline.provider, force: true) + } + } + + // A later provider refresh can suspend while an earlier provider publishes again. + // Capture every provider only after all refresh work finishes so the request owns the + // newest same-scope publication available at this boundary. + let captureNow = now ?? nowProvider() + let providers = self.costCapableProviders(store: store) + let codexRequests = providers.contains(.codex) + ? self.codexRequests(settings: settings, store: store) + : [] + let configuration = self.configuration( + settings: settings, + store: store, + providers: providers, + codexRequests: codexRequests) + guard configuration.costUsageEnabled else { + return SpendDashboardLoadRequest( + configuration: configuration, + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [], + now: captureNow, + force: mode.forcesLoader) + } + + var inputs: [SpendDashboardModel.ProviderInput] = [] + var unavailableSourceIDs: Set = [] + var confirmedEmptySourceIDs: Set = [] + for provider in providers where provider != .codex { + guard let baseline = providerBaselines.first(where: { $0.provider == provider }) else { + unavailableSourceIDs.insert(provider.rawValue) + continue + } + let shouldRefresh = mode.shouldRefresh(hasPublication: baseline.publication != nil) + let current = store.tokenSnapshotPublicationForCurrentProviderConfig(for: provider) + guard let current else { + unavailableSourceIDs.insert(provider.rawValue) + continue + } + if shouldRefresh, baseline.publicationRevision == current.publicationRevision { + unavailableSourceIDs.insert(provider.rawValue) + continue + } + guard let snapshot = current.snapshot else { + confirmedEmptySourceIDs.insert(provider.rawValue) + continue + } + inputs.append(SpendDashboardModel.ProviderInput( + provider: provider, + displayName: store.metadata(for: provider).displayName, + snapshot: snapshot)) + } + return SpendDashboardLoadRequest( + configuration: configuration, + capturedInputs: inputs, + unavailableSourceIDs: unavailableSourceIDs, + confirmedEmptySourceIDs: confirmedEmptySourceIDs, + codexRequests: codexRequests, + now: captureNow, + force: mode.forcesLoader) + } + + static func load(_ request: SpendDashboardLoadRequest) async -> SpendDashboardLoadResult { + await self.load(request, codexSnapshotLoader: { context in + try await self.loadCodexSnapshot(context) + }) + } + + static func load( + _ request: SpendDashboardLoadRequest, + codexSnapshotLoader: CodexSnapshotLoader) async -> SpendDashboardLoadResult + { + var inputs = request.capturedInputs + var failedSourceIDs = request.unavailableSourceIDs + var invalidatedSourceIDs: Set = [] + for account in request.codexRequests { + let sourceID = "codex:\(account.id)" + do { + guard self.currentAuthFingerprint(for: account) == account.authFingerprint else { + failedSourceIDs.insert(sourceID) + invalidatedSourceIDs.insert(sourceID) + continue + } + let cacheRoot = UsageStore.costUsageCacheDirectory() + .appendingPathComponent("accounts", isDirectory: true) + .appendingPathComponent(account.cacheIdentity, isDirectory: true) + let snapshot = try await codexSnapshotLoader(CodexSpendSnapshotLoadContext( + account: account, + cacheRoot: cacheRoot, + now: request.now, + force: request.force, + historyDays: Self.scanDays, + refreshPricingInBackground: false, + includePiSessions: false)) + try Task.checkCancellation() + guard self.currentAuthFingerprint(for: account) == account.authFingerprint else { + failedSourceIDs.insert(sourceID) + invalidatedSourceIDs.insert(sourceID) + continue + } + inputs.append(SpendDashboardModel.ProviderInput( + id: sourceID, + provider: .codex, + displayName: account.displayName, + modelProviderName: ProviderDescriptorRegistry.descriptor(for: .codex).metadata.displayName, + snapshot: snapshot)) + } catch is CancellationError { + failedSourceIDs.formUnion(request.codexRequests.map { "codex:\($0.id)" }) + return SpendDashboardLoadResult( + inputs: [], + failedSourceIDs: failedSourceIDs, + invalidatedSourceIDs: invalidatedSourceIDs) + } catch { + failedSourceIDs.insert(sourceID) + } + } + let lateInvalidatedSourceIDs = Set(request.codexRequests.compactMap { account in + self.currentAuthFingerprint(for: account) == account.authFingerprint + ? nil + : "codex:\(account.id)" + }) + failedSourceIDs.formUnion(lateInvalidatedSourceIDs) + invalidatedSourceIDs.formUnion(lateInvalidatedSourceIDs) + inputs.removeAll { lateInvalidatedSourceIDs.contains($0.id) } + return SpendDashboardLoadResult( + inputs: inputs, + failedSourceIDs: failedSourceIDs, + invalidatedSourceIDs: invalidatedSourceIDs) + } + + private static func loadCodexSnapshot( + _ context: CodexSpendSnapshotLoadContext) async throws -> CostUsageTokenSnapshot + { + try await CostUsageFetcher(cacheRoot: context.cacheRoot).loadTokenSnapshot( + provider: .codex, + environment: CodexHomeScope.scopedEnvironment(base: [:], codexHome: context.account.homePath), + now: context.now, + forceRefresh: context.force, + codexHomePath: context.account.homePath, + historyDays: context.historyDays, + refreshPricingInBackground: context.refreshPricingInBackground, + includePiSessions: context.includePiSessions) + } + + @MainActor + static func costCapableProviders(store: UsageStore) -> [UsageProvider] { + store.enabledProvidersForDisplay().filter { + ProviderDescriptorRegistry.descriptor(for: $0).tokenCost.supportsTokenCost + } + } + + @MainActor + static func codexRequests(settings: SettingsStore, store: UsageStore) -> [CodexSpendScanRequest] { + let accounts = settings.codexVisibleAccountProjection.visibleAccounts + let providerName = store.metadata(for: .codex).displayName + return accounts.enumerated().compactMap { index, account in + let homePath: String? = switch account.selectionSource { + case .liveSystem: + settings.liveSystemCodexHomePath(forActiveSource: .liveSystem) + case let .managedAccount(id): + settings.managedCodexRemoteHomePath(forActiveSource: .managedAccount(id: id)) + case let .profileHome(path): + settings.profileCodexHomePath(forActiveSource: .profileHome(path: path)) + } + return self.codexRequest( + account: account, + homePath: homePath, + providerName: providerName, + index: index, + count: accounts.count) + } + } + + @MainActor + private static func sourceRevisions( + providers: [UsageProvider], + settings: SettingsStore, + store: UsageStore) -> [String] + { + ["settings:\(settings.configRevision)"] + providers.compactMap { provider in + guard provider != .codex else { return nil } + let current = store.tokenSnapshotPublicationForCurrentProviderConfig(for: provider) + guard let current else { return "\(provider.rawValue):unavailable" } + guard let snapshot = current.snapshot else { + return "\(provider.rawValue):empty:\(current.publicationRevision)" + } + return "\(provider.rawValue):snapshot:\(current.publicationRevision):\(self.snapshotRevision(snapshot))" + } + } + + private static func snapshotRevision(_ snapshot: CostUsageTokenSnapshot) -> String { + var encoder = SpendDashboardSnapshotRevisionEncoder() + encoder.append(snapshot.currencyCode) + encoder.append(snapshot.historyDays) + encoder.append(snapshot.historyCoverageIsEstablished) + encoder.append(snapshot.updatedAt.timeIntervalSinceReferenceDate) + encoder.append(snapshot.last30DaysTokens) + encoder.append(snapshot.last30DaysCostUSD) + encoder.append(snapshot.daily.count) + for entry in snapshot.daily { + encoder.append(entry.date) + encoder.append(entry.inputTokens) + encoder.append(entry.cacheReadTokens) + encoder.append(entry.cacheCreationTokens) + encoder.append(entry.outputTokens) + encoder.append(entry.totalTokens) + encoder.append(entry.requestCount) + encoder.append(entry.costUSD) + encoder.append(entry.modelBreakdowns?.count) + for breakdown in entry.modelBreakdowns ?? [] { + encoder.append(breakdown.modelName) + encoder.append(breakdown.totalTokens) + encoder.append(breakdown.requestCount) + encoder.append(breakdown.costUSD) + encoder.append(breakdown.standardCostUSD) + encoder.append(breakdown.priorityCostUSD) + encoder.append(breakdown.standardTokens) + encoder.append(breakdown.priorityTokens) + } + } + return encoder.finalize() + } + + @MainActor + private static func sourceOwnershipFingerprints( + providers: [UsageProvider], + settings: SettingsStore, + store: UsageStore) -> [String] + { + providers.compactMap { provider in + guard provider != .codex else { return nil } + var config = settings.providerConfig(for: provider) ?? ProviderConfig(id: provider) + config.enabled = nil + config.quotaWarnings = nil + // The dashboard follows the effective account, not the whole saved-account collection. + // Inactive-account edits must not invalidate visible spend for the selected account. + config.tokenAccounts = nil + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let encoded = (try? encoder.encode(config)) ?? Data() + let scope = store.tokenSnapshotScopeSignature(for: provider) + let accountOwnership = settings.effectiveSelectedTokenAccount(for: provider) + .map { store.tokenAccountSnapshotCacheKey(provider: provider, account: $0) } + ?? "ambient" + return "\(provider.rawValue):\(self.sha256(encoded)):\(self.sha256(scope)):" + + self.sha256(accountOwnership) + } + } + + static func codexRequest( + account: CodexVisibleAccount, + homePath: String?, + providerName: String, + index: Int, + count: Int) -> CodexSpendScanRequest? + { + guard let homePath = CodexHomeScope.normalizedHomePath(homePath) else { return nil } + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: homePath, isDirectory: &isDirectory), + isDirectory.boolValue, + FileManager.default.isReadableFile(atPath: homePath) + else { return nil } + let sourceToken = self.sourceToken(account.selectionSource) + let liveAuthFingerprint = CodexAuthFingerprint.fingerprint(homePath: homePath) + let authFingerprint = liveAuthFingerprint + ?? CodexAuthFingerprint.normalize(account.authFingerprint) + let cacheIdentity = self.sha256([ + account.id, + sourceToken, + homePath, + authFingerprint ?? "missing-auth", + ].joined(separator: "\u{0}")) + let displayName = count == 1 + ? providerName + : "\(providerName) · #\(codexBarLocalizedInteger(index + 1))" + return CodexSpendScanRequest( + id: account.id, + displayName: displayName, + source: account.selectionSource, + homePath: homePath, + authFingerprint: authFingerprint, + authFileWasReadable: liveAuthFingerprint != nil, + cacheIdentity: cacheIdentity) + } + + private static func codexDisplayNamesByID(_ requests: [CodexSpendScanRequest]) -> [String: String] { + requests.reduce(into: [:]) { result, request in + result["codex:\(request.id)"] = request.displayName + } + } + + private static func sourceToken(_ source: CodexActiveSource) -> String { + switch source { + case .liveSystem: "live" + case let .managedAccount(id): "managed:\(id.uuidString.lowercased())" + case let .profileHome(path): "profile:\(path)" + } + } + + private static func sha256(_ value: String) -> String { + self.sha256(Data(value.utf8)) + } + + private static func sha256(_ value: Data) -> String { + SHA256.hash(data: value).map { String(format: "%02x", $0) }.joined() + } + + private static func currentAuthFingerprint(for request: CodexSpendScanRequest) -> String? { + let current = CodexAuthFingerprint.fingerprint(homePath: request.homePath) + return request.authFileWasReadable ? current : current ?? request.authFingerprint + } +} + +private struct SpendDashboardSnapshotRevisionEncoder { + private var hasher = SHA256() + + mutating func append(_ value: String) { + let data = Data(value.utf8) + self.append(UInt64(data.count)) + self.hasher.update(data: data) + } + + mutating func append(_ value: Int) { + self.append(UInt64(bitPattern: Int64(value))) + } + + mutating func append(_ value: Int?) { + guard let value else { + self.appendPresence(false) + return + } + self.appendPresence(true) + self.append(value) + } + + mutating func append(_ value: Bool) { + self.appendPresence(value) + } + + mutating func append(_ value: Double) { + self.append(value.bitPattern) + } + + mutating func append(_ value: Double?) { + guard let value else { + self.appendPresence(false) + return + } + self.appendPresence(true) + self.append(value) + } + + mutating func finalize() -> String { + self.hasher.finalize().map { String(format: "%02x", $0) }.joined() + } + + private mutating func appendPresence(_ isPresent: Bool) { + var byte: UInt8 = isPresent ? 1 : 0 + withUnsafeBytes(of: &byte) { bytes in + self.hasher.update(data: Data(bytes)) + } + } + + private mutating func append(_ value: UInt64) { + var value = value.bigEndian + withUnsafeBytes(of: &value) { bytes in + self.hasher.update(data: Data(bytes)) + } + } +} + +@MainActor +@Observable +final class SpendDashboardController { + typealias RequestBuilder = @MainActor @Sendable (SpendDashboardRequestBuildMode) async + -> SpendDashboardLoadRequest + typealias Loader = @Sendable (SpendDashboardLoadRequest) async -> SpendDashboardLoadResult + + private enum ReconciliationObservation: Sendable { + case confirmedEmpty + case confirmedNonempty(SpendDashboardModel.ProviderInput) + } + + private struct ForcedOutcome: Sendable { + let request: SpendDashboardLoadRequest + let result: SpendDashboardLoadResult + let invalidatedSourceIDs: Set + let observations: [String: ReconciliationObservation] + + func incorporating(capture: SpendDashboardLoadRequest) -> Self { + var observations = self.observations + for input in capture.capturedInputs { + let forcedRevision = Self.sourceRevision(for: input.id, in: self.request.configuration) + let captureRevision = Self.sourceRevision(for: input.id, in: capture.configuration) + let hasNewerSourceRevision = forcedRevision != nil + && captureRevision != nil + && forcedRevision != captureRevision + if self.result.failedSourceIDs.contains(input.id), + observations[input.id] == nil, + !hasNewerSourceRevision + { + continue + } + observations[input.id] = .confirmedNonempty(input) + } + for sourceID in capture.confirmedEmptySourceIDs { + observations[sourceID] = .confirmedEmpty + } + return Self( + request: self.request, + result: self.result, + invalidatedSourceIDs: self.invalidatedSourceIDs, + observations: observations) + } + + private static func sourceRevision( + for sourceID: String, + in configuration: SpendDashboardConfiguration) -> String? + { + let prefix = "\(sourceID):" + return configuration.sourceRevisions.first { $0.hasPrefix(prefix) } + } + + var confirmedEmptySourceIDs: Set { + Set(self.observations.compactMap { sourceID, observation in + guard case .confirmedEmpty = observation else { return nil } + return sourceID + }) + } + + var confirmedNonemptyInputs: [SpendDashboardModel.ProviderInput] { + self.observations.sorted { $0.key < $1.key }.compactMap { _, observation in + guard case let .confirmedNonempty(input) = observation else { return nil } + return input + } + } + } + + private struct ReconciledOutcome: Sendable { + let result: SpendDashboardLoadResult + let confirmedEmptySourceIDs: Set + } + + private enum LoadPhase: Sendable { + case ordinary + case forcing + case reconciling(ForcedOutcome) + + var buildMode: SpendDashboardRequestBuildMode { + switch self { + case .ordinary: .refreshMissing + case .forcing: .forceRefresh + case .reconciling: .captureOnly + } + } + + var manualRefreshOutstanding: Bool { + switch self { + case .ordinary: false + case .forcing, .reconciling: true + } + } + } + + private(set) var model = SpendDashboardModel(requestedDays: 30, groups: []) + private(set) var isRefreshing = false + private(set) var failedSourceCount = 0 + private(set) var generation: UInt64 = 0 + private(set) var configuration: SpendDashboardConfiguration? + private(set) var selectedDays: Int + + private static let daysDefaultsKey = "settingsSpendDashboardDays" + private let userDefaults: UserDefaults + private let requestBuilder: RequestBuilder + private let loader: Loader + private let nowProvider: @Sendable () -> Date + private var loadTask: Task? + private var loadedInputs: [SpendDashboardModel.ProviderInput] = [] + private var loadedAt = Date() + private var lastSuccessfulConfiguration: SpendDashboardConfiguration? + private var phase = LoadPhase.ordinary + + init( + userDefaults: UserDefaults = .standard, + requestBuilder: @escaping RequestBuilder, + loader: @escaping Loader = SpendDashboardSource.load, + nowProvider: @escaping @Sendable () -> Date = { Date() }) + { + self.userDefaults = userDefaults + self.requestBuilder = requestBuilder + self.loader = loader + self.nowProvider = nowProvider + self.selectedDays = Self.normalizedDays(userDefaults.integer(forKey: Self.daysDefaultsKey)) + } + + func update(configuration: SpendDashboardConfiguration, force: Bool = false) { + self.refreshRetainedCodexDisplayNames(configuration.codexAccountDisplayNames) + if force { + self.configuration = configuration + self.startLoad(configuration: configuration, phase: .forcing) + return + } + guard configuration != self.configuration else { return } + let previousConfiguration = self.configuration + self.configuration = configuration + if self.phase.manualRefreshOutstanding, + let previousConfiguration, + Self.sameSourceOwnership(previousConfiguration, configuration) + { + return + } + let nextPhase: LoadPhase = self.phase.manualRefreshOutstanding ? .forcing : .ordinary + self.startLoad(configuration: configuration, phase: nextPhase) + } + + private func startLoad( + configuration: SpendDashboardConfiguration, + phase: LoadPhase) + { + self.generation &+= 1 + let generation = self.generation + self.loadTask?.cancel() + let invalidatedSourceIDs = switch phase { + case let .reconciling(outcome): outcome.invalidatedSourceIDs + case .ordinary, .forcing: + Self.invalidatedSourceIDs( + previous: self.lastSuccessfulConfiguration, + current: configuration) + } + self.phase = phase + + if !invalidatedSourceIDs.isEmpty { + self.loadedInputs.removeAll { invalidatedSourceIDs.contains($0.id) } + self.failedSourceCount = 0 + self.rebuildModel() + } + + guard configuration.costUsageEnabled, !configuration.providerIDs.isEmpty else { + self.loadedInputs = [] + self.failedSourceCount = 0 + self.isRefreshing = false + self.lastSuccessfulConfiguration = configuration + self.phase = .ordinary + self.loadTask = nil + self.rebuildModel() + return + } + + self.isRefreshing = true + self.loadTask = Task { [weak self] in + guard let self else { return } + let request = await self.requestBuilder(phase.buildMode) + guard !Task.isCancelled, + generation == self.generation + else { return } + await self.handleBuiltRequest( + request, + startedWith: configuration, + phase: phase, + generation: generation, + invalidatedSourceIDs: invalidatedSourceIDs) + } + } + + private func handleBuiltRequest( + _ request: SpendDashboardLoadRequest, + startedWith startConfiguration: SpendDashboardConfiguration, + phase: LoadPhase, + generation: UInt64, + invalidatedSourceIDs: Set) async + { + guard let targetConfiguration = self.configuration else { return } + if case let .reconciling(outcome) = phase, + !Self.sameSourceOwnership(outcome.request.configuration, targetConfiguration) + { + self.startLoad(configuration: targetConfiguration, phase: .forcing) + return + } + guard Self.sameSourceOwnership(startConfiguration, targetConfiguration) else { + self.restartAfterBuildMismatch(targetConfiguration, phase: phase) + return + } + + let phase: LoadPhase = if case let .reconciling(outcome) = phase, + Self.sameSourceOwnership(request.configuration, targetConfiguration) + { + .reconciling(outcome.incorporating(capture: request)) + } else { + phase + } + + if request.configuration != targetConfiguration { + if case .forcing = phase, + Self.sameSourceOwnership(request.configuration, targetConfiguration) + { + // Same-owner revision churn does not justify another provider force. The forced + // loader executes once; its mandatory capture barrier reconciles the latest token. + if targetConfiguration == startConfiguration { + self.configuration = request.configuration + } + } else if targetConfiguration == startConfiguration, + Self.sameSourceOwnership(targetConfiguration, request.configuration) + { + // The request owns an atomic newer same-owner capture. Adopt it even when the + // external observation callback has not delivered that revision yet. + self.configuration = request.configuration + } else { + let nextConfiguration = targetConfiguration == startConfiguration + ? request.configuration + : targetConfiguration + self.restartAfterBuildMismatch(nextConfiguration, phase: phase) + return + } + } + + switch phase { + case .ordinary: + let result = await self.loader(request) + guard !Task.isCancelled, + generation == self.generation, + let latestConfiguration = self.configuration + else { return } + guard request.configuration == latestConfiguration else { + self.startLoad(configuration: latestConfiguration, phase: .ordinary) + return + } + self.apply( + request: request, + result: result, + invalidatedSourceIDs: invalidatedSourceIDs, + confirmedEmptySourceIDs: request.confirmedEmptySourceIDs) + + case .forcing: + let result = await self.loader(request) + guard !Task.isCancelled, + generation == self.generation, + let latestConfiguration = self.configuration + else { return } + guard Self.sameSourceOwnership(request.configuration, latestConfiguration) else { + self.startLoad(configuration: latestConfiguration, phase: .forcing) + return + } + let outcome = ForcedOutcome( + request: request, + result: result, + invalidatedSourceIDs: invalidatedSourceIDs, + observations: Dictionary(uniqueKeysWithValues: request.confirmedEmptySourceIDs.map { + ($0, ReconciliationObservation.confirmedEmpty) + })) + self.startLoad(configuration: latestConfiguration, phase: .reconciling(outcome)) + + case let .reconciling(outcome): + let reconciled = Self.merge(outcome: outcome, capture: request) + self.apply( + request: request, + result: reconciled.result, + invalidatedSourceIDs: outcome.invalidatedSourceIDs, + confirmedEmptySourceIDs: reconciled.confirmedEmptySourceIDs) + } + } + + private func restartAfterBuildMismatch( + _ configuration: SpendDashboardConfiguration, + phase: LoadPhase) + { + self.configuration = configuration + let nextPhase: LoadPhase = switch phase { + case .ordinary: .ordinary + case .forcing: .forcing + case let .reconciling(outcome): + Self.sameSourceOwnership(outcome.request.configuration, configuration) + ? .reconciling(outcome) + : .forcing + } + self.startLoad(configuration: configuration, phase: nextPhase) + } + + private func apply( + request: SpendDashboardLoadRequest, + result: SpendDashboardLoadResult, + invalidatedSourceIDs: Set, + confirmedEmptySourceIDs: Set) + { + let codexDisplayNames = request.configuration.codexAccountDisplayNames + self.refreshRetainedCodexDisplayNames(codexDisplayNames) + var nextInputs = result.inputs + if !result.failedSourceIDs.isEmpty { + let freshIDs = Set(nextInputs.map(\.id)) + let unsafeSourceIDs = invalidatedSourceIDs + .union(result.invalidatedSourceIDs) + .union(confirmedEmptySourceIDs) + nextInputs.append(contentsOf: self.loadedInputs.filter { + result.failedSourceIDs.contains($0.id) && + !unsafeSourceIDs.contains($0.id) && + !freshIDs.contains($0.id) + }.map { Self.relabelCodexInput($0, displayNamesByID: codexDisplayNames) }) + } + self.configuration = request.configuration + self.loadedInputs = nextInputs + self.loadedAt = request.now + self.lastSuccessfulConfiguration = request.configuration + self.failedSourceCount = result.failedSourceCount + self.isRefreshing = false + self.phase = .ordinary + self.loadTask = nil + self.rebuildModel() + } + + private static func merge( + outcome: ForcedOutcome, + capture: SpendDashboardLoadRequest) -> ReconciledOutcome + { + let forceFailed = outcome.result.failedSourceIDs + let invalidated = outcome.result.invalidatedSourceIDs + let barrierFailed = capture.unavailableSourceIDs + let forcedCodexIDs = Set(outcome.request.codexRequests.map { "codex:\($0.id)" }) + let confirmedNonemptyInputs = outcome.confirmedNonemptyInputs + let confirmedNonemptyIDs = Set(confirmedNonemptyInputs.map(\.id)) + var inputs = capture.capturedInputs.filter { + (!forceFailed.contains($0.id) || confirmedNonemptyIDs.contains($0.id)) && + !invalidated.contains($0.id) && + !outcome.confirmedEmptySourceIDs.contains($0.id) + } + var capturedIDs = Set(inputs.map(\.id)) + for input in confirmedNonemptyInputs + where !capturedIDs.contains(input.id) && !invalidated.contains(input.id) + { + inputs.append(input) + capturedIDs.insert(input.id) + } + for input in outcome.result.inputs + where !capturedIDs.contains(input.id) && + !forceFailed.contains(input.id) && + !invalidated.contains(input.id) && + !outcome.confirmedEmptySourceIDs.contains(input.id) && + (forcedCodexIDs.contains(input.id) || barrierFailed.contains(input.id)) + { + inputs.append(input) + capturedIDs.insert(input.id) + } + return ReconciledOutcome( + result: SpendDashboardLoadResult( + inputs: inputs, + failedSourceIDs: forceFailed.union(barrierFailed), + invalidatedSourceIDs: invalidated), + confirmedEmptySourceIDs: outcome.confirmedEmptySourceIDs) + } + + func refresh() { + guard let configuration else { return } + self.update(configuration: configuration, force: true) + } + + func selectDays(_ days: Int) { + let days = Self.normalizedDays(days) + guard days != self.selectedDays else { return } + self.selectedDays = days + self.userDefaults.set(days, forKey: Self.daysDefaultsKey) + self.rebuildModel() + } + + func refreshDateWindow(now: Date? = nil) { + self.loadedAt = now ?? self.nowProvider() + self.rebuildModel() + guard let configuration else { return } + let nextPhase: LoadPhase = self.phase.manualRefreshOutstanding ? .forcing : .ordinary + self.startLoad(configuration: configuration, phase: nextPhase) + } + + func stop() { + self.loadTask?.cancel() + self.loadTask = nil + self.configuration = nil + self.isRefreshing = false + self.phase = .ordinary + } + + private func rebuildModel() { + self.model = SpendDashboardModel.build( + inputs: self.loadedInputs, + requestedDays: self.selectedDays, + now: self.loadedAt) + } + + private func refreshRetainedCodexDisplayNames(_ displayNamesByID: [String: String]) { + guard !displayNamesByID.isEmpty else { return } + var didChange = false + let relabeled = self.loadedInputs.map { input in + let updated = Self.relabelCodexInput(input, displayNamesByID: displayNamesByID) + didChange = didChange || updated.displayName != input.displayName + return updated + } + guard didChange else { return } + self.loadedInputs = relabeled + self.rebuildModel() + } + + private static func relabelCodexInput( + _ input: SpendDashboardModel.ProviderInput, + displayNamesByID: [String: String]) -> SpendDashboardModel.ProviderInput + { + guard input.provider == .codex, + let displayName = displayNamesByID[input.id], + displayName != input.displayName + else { return input } + return SpendDashboardModel.ProviderInput( + id: input.id, + provider: input.provider, + displayName: displayName, + modelProviderName: input.modelProviderName, + snapshot: input.snapshot) + } + + private static func sameSourceOwnership( + _ lhs: SpendDashboardConfiguration, + _ rhs: SpendDashboardConfiguration) -> Bool + { + lhs.costUsageEnabled == rhs.costUsageEnabled && + lhs.providerIDs == rhs.providerIDs && + lhs.codexAccountIdentities == rhs.codexAccountIdentities && + lhs.sourceOwnershipFingerprints == rhs.sourceOwnershipFingerprints + } + + private static func invalidatedSourceIDs( + previous: SpendDashboardConfiguration?, + current: SpendDashboardConfiguration) -> Set + { + guard let previous else { return [] } + let previousOwnership = self.sourceOwnershipByID(previous.sourceOwnershipFingerprints) + let currentOwnership = self.sourceOwnershipByID(current.sourceOwnershipFingerprints) + let providerIDs = Set(previousOwnership.keys).union(currentOwnership.keys) + let changedProviderIDs = providerIDs.filter { previousOwnership[$0] != currentOwnership[$0] } + + let previousCodexOwnership = self.codexOwnershipByID(previous.codexAccountIdentities) + let currentCodexOwnership = self.codexOwnershipByID(current.codexAccountIdentities) + let codexIDs = Set(previousCodexOwnership.keys).union(currentCodexOwnership.keys) + let changedCodexIDs = codexIDs.filter { + previousCodexOwnership[$0] != currentCodexOwnership[$0] + } + return Set(changedProviderIDs).union(changedCodexIDs) + } + + private static func sourceOwnershipByID(_ fingerprints: [String]) -> [String: String] { + Dictionary(uniqueKeysWithValues: fingerprints.compactMap { fingerprint in + guard let separator = fingerprint.firstIndex(of: ":") else { return nil } + let sourceID = String(fingerprint[.. [String: String] { + Dictionary(uniqueKeysWithValues: identities.compactMap { identity in + guard let separator = identity.lastIndex(of: "|") else { return nil } + let accountID = String(identity[.. Int { + value == 7 ? 7 : 30 + } +} diff --git a/Sources/CodexBar/SpendDashboardModel.swift b/Sources/CodexBar/SpendDashboardModel.swift new file mode 100644 index 0000000000..849a49b3f9 --- /dev/null +++ b/Sources/CodexBar/SpendDashboardModel.swift @@ -0,0 +1,676 @@ +import CodexBarCore +import Foundation + +struct SpendDashboardModel: Equatable, Sendable { + struct ProviderInput: Sendable { + let id: String + let provider: UsageProvider + let displayName: String + let modelProviderName: String + let snapshot: CostUsageTokenSnapshot + + init( + id: String? = nil, + provider: UsageProvider, + displayName: String, + modelProviderName: String? = nil, + snapshot: CostUsageTokenSnapshot) + { + self.id = id ?? provider.rawValue + self.provider = provider + self.displayName = displayName + self.modelProviderName = modelProviderName ?? displayName + self.snapshot = snapshot + } + } + + struct ProviderRow: Identifiable, Equatable, Sendable { + let id: String + let rank: Int + let provider: UsageProvider + let displayName: String + let totalTokens: Int? + let totalCost: Double? + let coveredDayCount: Int + } + + struct ModelRow: Identifiable, Equatable, Sendable { + let rank: Int + let provider: UsageProvider + let providerName: String + let modelName: String + let totalTokens: Int? + let totalCost: Double? + + var id: String { + "\(self.provider.rawValue):\(self.modelName)" + } + } + + struct DailyPoint: Identifiable, Equatable, Sendable { + let sourceID: String + let provider: UsageProvider + let providerName: String + let day: Date + let cost: Double + let stackStart: Double + let stackEnd: Double + + var id: String { + "\(self.sourceID):\(Int(self.day.timeIntervalSince1970))" + } + } + + enum ModelHistoryCompleteness: Equatable, Sendable { + case complete + case incomplete + } + + struct CurrencyGroup: Identifiable, Equatable, Sendable { + let currencyCode: String + let providers: [ProviderRow] + let models: [ModelRow] + let dailyPoints: [DailyPoint] + let totalTokens: Int? + let totalCost: Double? + let coveredDayCount: Int + let chartDomain: ClosedRange + let modelHistoryCompleteness: ModelHistoryCompleteness + + var id: String { + self.currencyCode + } + } + + let requestedDays: Int + let groups: [CurrencyGroup] + + static func build( + inputs: [ProviderInput], + requestedDays: Int, + now: Date, + calendar: Calendar = .current) -> Self + { + let days = max(1, min(30, requestedDays)) + let calculationCalendar = Self.gregorianCalendar(timeZone: calendar.timeZone) + let classifiedInputs = inputs.compactMap { input -> (currencyCode: String, input: ProviderInput)? in + guard let currencyCode = Self.currencyCode(input.snapshot.currencyCode) else { return nil } + return (currencyCode, input) + } + let groups = Dictionary(grouping: classifiedInputs, by: { $0.currencyCode }) + .map { currencyCode, inputs in + Self.buildCurrencyGroup( + currencyCode: currencyCode, + inputs: inputs.map(\.input), + days: days, + now: now, + calendar: calculationCalendar) + } + .sorted { $0.currencyCode < $1.currencyCode } + return Self(requestedDays: days, groups: groups) + } + + private struct InputSummary { + let input: ProviderInput + let entries: [WindowEntry] + let totalTokens: Int? + let totalCost: Double? + let coveredInterval: ClosedRange? + let coveredDayCount: Int + let hasInvalidCostHistory: Bool + } + + private struct WindowEntry { + let day: Date + let entry: CostUsageDailyReport.Entry + } + + private struct ModelKey: Hashable { + let provider: UsageProvider + let modelName: String + } + + private struct ModelAccumulator { + let providerName: String + var tokens: Int? + var cost: Double? + var sawTokens = false + var sawCost = false + var invalidTokens = false + var invalidCost = false + var overflowedTokens = false + var overflowedCost = false + } + + private struct ModelSummary { + let rows: [ModelRow] + let completeness: ModelHistoryCompleteness + } + + private struct DailyKey: Hashable { + let day: Date + let sourceID: String + } + + private struct DailyAccumulator { + let provider: UsageProvider + let providerName: String + var cost: Double? + var invalid = false + var overflowed = false + } + + private static func buildCurrencyGroup( + currencyCode: String, + inputs: [ProviderInput], + days: Int, + now: Date, + calendar: Calendar) -> CurrencyGroup + { + let bounds = Self.bounds(days: days, now: now, calendar: calendar) + let summaries = inputs.map { input in + Self.inputSummary(input: input, bounds: bounds, calendar: calendar) + } + let providers = Self.providerRows(summaries) + let modelSummary = Self.modelSummary(summaries: summaries) + let modelHistoryCompleteness = summaries.contains(where: { $0.totalCost == nil }) + ? ModelHistoryCompleteness.incomplete + : modelSummary.completeness + let dailyPoints = Self.dailyPoints(summaries: summaries) + return CurrencyGroup( + currencyCode: currencyCode, + providers: providers, + models: modelHistoryCompleteness == .complete ? modelSummary.rows : [], + dailyPoints: dailyPoints, + totalTokens: Self.completeIntSum(providers.map(\.totalTokens)), + totalCost: Self.completeCostSum(providers.map(\.totalCost)), + coveredDayCount: Self.commonCoverageDayCount(summaries: summaries, calendar: calendar), + chartDomain: Self.chartDomain(bounds: bounds, calendar: calendar), + modelHistoryCompleteness: modelHistoryCompleteness) + } + + private static func inputSummary( + input: ProviderInput, + bounds: ClosedRange, + calendar: Calendar) -> InputSummary + { + let coveredInterval = Self.coverageInterval( + input: input, + bounds: bounds, + displayCalendar: calendar) + var entries: [WindowEntry] = [] + var hasInvalidCostHistory = false + var hasInvalidTokenHistory = false + for entry in input.snapshot.daily { + guard let day = Self.day(entry.date, provider: input.provider, displayCalendar: calendar) else { + hasInvalidCostHistory = hasInvalidCostHistory || !Self.hasProvenZeroCost(entry) + hasInvalidTokenHistory = hasInvalidTokenHistory || !Self.hasProvenZeroTokens(entry) + continue + } + guard bounds.contains(day) else { continue } + guard coveredInterval?.contains(day) == true else { + hasInvalidCostHistory = hasInvalidCostHistory || !Self.hasProvenZeroCost(entry) + hasInvalidTokenHistory = hasInvalidTokenHistory || !Self.hasProvenZeroTokens(entry) + continue + } + entries.append(WindowEntry(day: day, entry: entry)) + } + let coveredDayCount = Self.dayCount(in: coveredInterval, calendar: calendar) + let hasCompleteTokenHistory = Self.hasCompleteTokenHistory(input, displayCalendar: calendar) + let tokenAggregateIsConsistent = input.snapshot.last30DaysTokens == nil || hasCompleteTokenHistory + let totalTokens = hasInvalidTokenHistory || !tokenAggregateIsConsistent + ? nil + : entries.isEmpty + ? (coveredDayCount > 0 && hasCompleteTokenHistory ? 0 : nil) + : Self.completeIntSum(entries.map { Self.nonnegative($0.entry.totalTokens) }) + let hasCompleteCostHistory = Self.hasCompleteCostHistory(input, displayCalendar: calendar) + let costAggregateIsConsistent = input.snapshot.last30DaysCostUSD == nil || hasCompleteCostHistory + let invalidCostHistory = hasInvalidCostHistory || !costAggregateIsConsistent + let totalCost = invalidCostHistory + ? nil + : entries.isEmpty + ? (coveredDayCount > 0 && hasCompleteCostHistory ? 0 : nil) + : Self.completeCostSum(entries.map { Self.validCost($0.entry.costUSD) }) + return InputSummary( + input: input, + entries: entries, + totalTokens: totalTokens, + totalCost: totalCost, + coveredInterval: coveredInterval, + coveredDayCount: coveredDayCount, + hasInvalidCostHistory: invalidCostHistory) + } + + private static func providerRows(_ summaries: [InputSummary]) -> [ProviderRow] { + summaries.enumerated() + .sorted { lhs, rhs in + switch (lhs.element.totalCost, rhs.element.totalCost) { + case let (left?, right?) where left != right: left > right + case (_?, nil): true + case (nil, _?): false + default: lhs.offset < rhs.offset + } + } + .enumerated() + .map { rank, entry in + ProviderRow( + id: entry.element.input.id, + rank: rank + 1, + provider: entry.element.input.provider, + displayName: entry.element.input.displayName, + totalTokens: entry.element.totalTokens, + totalCost: entry.element.totalCost, + coveredDayCount: entry.element.coveredDayCount) + } + } + + private static func modelSummary(summaries: [InputSummary]) -> ModelSummary { + var aggregates: [ModelKey: ModelAccumulator] = [:] + var completeness = ModelHistoryCompleteness.complete + for summary in summaries { + let input = summary.input + let hasCompleteTokenHistory = summary.totalTokens != nil && summary.entries.allSatisfy { + Self.hasCompleteModelTokenCoverage($0.entry) + } + for windowEntry in summary.entries { + let entry = windowEntry.entry + let breakdowns = entry.modelBreakdowns ?? [] + if !Self.hasCompleteModelCostCoverage(entry) { + completeness = .incomplete + } + for breakdown in breakdowns { + let name = breakdown.modelName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { continue } + let key = ModelKey(provider: input.provider, modelName: name) + var aggregate = aggregates[key] ?? ModelAccumulator( + providerName: input.modelProviderName, + tokens: 0, + cost: 0) + if hasCompleteTokenHistory, + let tokens = Self.nonnegative(breakdown.totalTokens) + { + aggregate.sawTokens = true + aggregate.tokens = Self.add( + tokens, + to: aggregate.tokens, + overflowed: &aggregate.overflowedTokens) + } else { + aggregate.invalidTokens = true + } + if let cost = Self.validCost(breakdown.costUSD) { + aggregate.sawCost = true + aggregate.cost = Self.add(cost, to: aggregate.cost, overflowed: &aggregate.overflowedCost) + } else { + aggregate.invalidCost = true + } + aggregates[key] = aggregate + } + } + } + if aggregates.values.contains(where: { + !$0.sawCost || $0.invalidCost || $0.overflowedCost || $0.cost == nil + }) { + completeness = .incomplete + } + + let rows = aggregates.map { key, value in + ModelRow( + rank: 0, + provider: key.provider, + providerName: value.providerName, + modelName: key.modelName, + totalTokens: value.sawTokens && !value.invalidTokens && !value.overflowedTokens ? value.tokens : nil, + totalCost: value.sawCost && !value.invalidCost && !value.overflowedCost ? value.cost : nil) + } + .sorted { lhs, rhs in + switch (lhs.totalCost, rhs.totalCost) { + case let (left?, right?) where left != right: return left > right + case (_?, nil): return true + case (nil, _?): return false + default: + if lhs.providerName != rhs.providerName { + return lhs.providerName < rhs.providerName + } + return lhs.modelName < rhs.modelName + } + } + .enumerated() + .map { rank, row in + ModelRow( + rank: rank + 1, + provider: row.provider, + providerName: row.providerName, + modelName: row.modelName, + totalTokens: row.totalTokens, + totalCost: row.totalCost) + } + return ModelSummary(rows: rows, completeness: completeness) + } + + private static func hasProvenZeroCost(_ entry: CostUsageDailyReport.Entry) -> Bool { + self.validCost(entry.costUSD) == 0 + && (entry.modelBreakdowns?.allSatisfy(self.hasProvenZeroCost) ?? true) + } + + private static func hasProvenZeroCost(_ breakdown: CostUsageDailyReport.ModelBreakdown) -> Bool { + let optionalCosts = [breakdown.standardCostUSD, breakdown.priorityCostUSD] + return Self.validCost(breakdown.costUSD) == 0 + && optionalCosts.allSatisfy { value in + value == nil || Self.validCost(value) == 0 + } + } + + private static func hasProvenZeroTokens(_ entry: CostUsageDailyReport.Entry) -> Bool { + let optionalTokens = [ + entry.inputTokens, + entry.cacheReadTokens, + entry.cacheCreationTokens, + entry.outputTokens, + ] + return Self.nonnegative(entry.totalTokens) == 0 + && optionalTokens.allSatisfy { $0 == nil || Self.nonnegative($0) == 0 } + && (entry.modelBreakdowns?.allSatisfy(Self.hasProvenZeroTokens) ?? true) + } + + private static func hasProvenZeroTokens(_ breakdown: CostUsageDailyReport.ModelBreakdown) -> Bool { + let optionalTokens = [breakdown.standardTokens, breakdown.priorityTokens] + return Self.nonnegative(breakdown.totalTokens) == 0 + && optionalTokens.allSatisfy { $0 == nil || Self.nonnegative($0) == 0 } + } + + private static func hasCompleteModelCostCoverage(_ entry: CostUsageDailyReport.Entry) -> Bool { + var totalCost = 0.0 + var sawNamedBreakdown = false + for breakdown in entry.modelBreakdowns ?? [] { + let name = breakdown.modelName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { + guard Self.hasProvenZeroCost(breakdown) else { return false } + continue + } + sawNamedBreakdown = true + guard let cost = Self.validCost(breakdown.costUSD) else { return false } + totalCost += cost + guard totalCost.isFinite else { return false } + } + + guard sawNamedBreakdown else { return Self.hasProvenZeroCost(entry) } + guard let entryCost = Self.validCost(entry.costUSD) else { return false } + return Self.costsMatch(entryCost, totalCost) + } + + private static func hasCompleteModelTokenCoverage(_ entry: CostUsageDailyReport.Entry) -> Bool { + var totalTokens = 0 + var sawNamedBreakdown = false + for breakdown in entry.modelBreakdowns ?? [] { + let name = breakdown.modelName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { + guard Self.hasProvenZeroTokens(breakdown) else { return false } + continue + } + sawNamedBreakdown = true + guard let tokens = Self.nonnegative(breakdown.totalTokens) else { return false } + let addition = totalTokens.addingReportingOverflow(tokens) + guard !addition.overflow else { return false } + totalTokens = addition.partialValue + } + + guard sawNamedBreakdown else { return Self.hasProvenZeroTokens(entry) } + guard let entryTokens = Self.nonnegative(entry.totalTokens) else { return false } + return entryTokens == totalTokens + } + + private static func costsMatch(_ lhs: Double, _ rhs: Double) -> Bool { + let scaledTolerance = max(abs(lhs), abs(rhs)) * 1e-12 + let tolerance = min(1e-6, max(1e-9, scaledTolerance)) + return abs(lhs - rhs) <= tolerance + } + + private static func hasCompleteCostHistory( + _ input: ProviderInput, + displayCalendar: Calendar) -> Bool + { + guard let aggregate = validCost(input.snapshot.last30DaysCostUSD) else { return false } + let coverage = Self.sourceCoverageInterval(input: input, displayCalendar: displayCalendar) + var dailyTotal = 0.0 + for entry in input.snapshot.daily { + guard let day = Self.day(entry.date, provider: input.provider, displayCalendar: displayCalendar) else { + guard Self.hasProvenZeroCost(entry) else { return false } + continue + } + guard coverage.contains(day) else { continue } + guard let cost = validCost(entry.costUSD) else { return false } + dailyTotal += cost + guard dailyTotal.isFinite else { return false } + } + return self.costsMatch(aggregate, dailyTotal) + } + + private static func hasCompleteTokenHistory( + _ input: ProviderInput, + displayCalendar: Calendar) -> Bool + { + guard let aggregate = nonnegative(input.snapshot.last30DaysTokens) else { return false } + let coverage = Self.sourceCoverageInterval(input: input, displayCalendar: displayCalendar) + var dailyTotal = 0 + for entry in input.snapshot.daily { + guard let day = Self.day(entry.date, provider: input.provider, displayCalendar: displayCalendar) else { + guard Self.hasProvenZeroTokens(entry) else { return false } + continue + } + guard coverage.contains(day) else { continue } + guard let tokens = nonnegative(entry.totalTokens) else { return false } + let addition = dailyTotal.addingReportingOverflow(tokens) + guard !addition.overflow else { return false } + dailyTotal = addition.partialValue + } + return aggregate == dailyTotal + } + + private static func dailyPoints(summaries: [InputSummary]) -> [DailyPoint] { + var aggregates: [DailyKey: DailyAccumulator] = [:] + for summary in summaries where !summary.hasInvalidCostHistory { + let input = summary.input + for windowEntry in summary.entries { + let day = windowEntry.day + let entry = windowEntry.entry + let key = DailyKey(day: day, sourceID: input.id) + var aggregate = aggregates[key] ?? DailyAccumulator( + provider: input.provider, + providerName: input.displayName, + cost: 0) + if let cost = Self.validCost(entry.costUSD) { + aggregate.cost = Self.add(cost, to: aggregate.cost, overflowed: &aggregate.overflowed) + } else { + aggregate.invalid = true + } + aggregates[key] = aggregate + } + } + + let byDay = Dictionary(grouping: aggregates, by: { $0.key.day }) + return byDay.keys.sorted().flatMap { day -> [DailyPoint] in + let rows = (byDay[day] ?? []) + .filter { !$0.value.invalid && !$0.value.overflowed && $0.value.cost != nil } + .sorted { $0.key.sourceID < $1.key.sourceID } + guard let total = Self.completeCostSum(rows.map(\.value.cost)), total.isFinite else { return [] } + var cursor = 0.0 + var points: [DailyPoint] = [] + for (key, value) in rows { + guard let cost = value.cost else { return [] } + let start = cursor + cursor += cost + points.append(DailyPoint( + sourceID: key.sourceID, + provider: value.provider, + providerName: value.providerName, + day: day, + cost: cost, + stackStart: start, + stackEnd: cursor)) + } + return points + } + } + + private static func bounds(days: Int, now: Date, calendar: Calendar) -> ClosedRange { + let end = calendar.startOfDay(for: now) + let start = calendar.date(byAdding: .day, value: -(days - 1), to: end) ?? end + return start...end + } + + private static func gregorianCalendar(timeZone: TimeZone) -> Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = timeZone + return calendar + } + + private static func chartDomain(bounds: ClosedRange, calendar: Calendar) -> ClosedRange { + let end = calendar.date(byAdding: .day, value: 1, to: bounds.upperBound) ?? bounds.upperBound + return bounds.lowerBound...end + } + + private static func coverageInterval( + input: ProviderInput, + bounds: ClosedRange, + displayCalendar: Calendar) -> ClosedRange? + { + guard input.snapshot.historyCoverageIsEstablished else { return nil } + let sourceCoverage = Self.sourceCoverageInterval(input: input, displayCalendar: displayCalendar) + let overlapStart = max(bounds.lowerBound, sourceCoverage.lowerBound) + let overlapEnd = min(bounds.upperBound, sourceCoverage.upperBound) + guard overlapStart <= overlapEnd else { return nil } + return overlapStart...overlapEnd + } + + private static func sourceCoverageInterval( + input: ProviderInput, + displayCalendar: Calendar) -> ClosedRange + { + let bucketCalendar = Self.bucketCalendar(for: input.provider, displayCalendar: displayCalendar) + let bucketEnd = bucketCalendar.startOfDay(for: input.snapshot.updatedAt) + let scanEnd = displayCalendar.startOfDay(for: bucketEnd) + let scanDays = max(1, input.snapshot.historyDays) + let bucketStart = bucketCalendar.date(byAdding: .day, value: -(scanDays - 1), to: bucketEnd) ?? bucketEnd + let scanStart = displayCalendar.startOfDay(for: bucketStart) + return scanStart...scanEnd + } + + private static func commonCoverageDayCount(summaries: [InputSummary], calendar: Calendar) -> Int { + guard let first = summaries.first?.coveredInterval else { return 0 } + var intersection = first + for summary in summaries.dropFirst() { + guard let interval = summary.coveredInterval else { return 0 } + let start = max(intersection.lowerBound, interval.lowerBound) + let end = min(intersection.upperBound, interval.upperBound) + guard start <= end else { return 0 } + intersection = start...end + } + return Self.dayCount(in: intersection, calendar: calendar) + } + + private static func dayCount(in interval: ClosedRange?, calendar: Calendar) -> Int { + guard let interval else { return 0 } + return (calendar.dateComponents([.day], from: interval.lowerBound, to: interval.upperBound).day ?? 0) + 1 + } + + private static func day( + _ rawValue: String, + provider: UsageProvider, + displayCalendar: Calendar) -> Date? + { + let bytes = Array(rawValue.utf8) + let digitIndices = [0, 1, 2, 3, 5, 6, 8, 9] + guard bytes.count == 10, + bytes[4] == 45, + bytes[7] == 45, + digitIndices.allSatisfy({ (48...57).contains(bytes[$0]) }) + else { return nil } + let parts = rawValue.split(separator: "-") + let bucketCalendar = Self.bucketCalendar(for: provider, displayCalendar: displayCalendar) + guard parts.count == 3, + let year = Int(parts[0]), + let month = Int(parts[1]), + let day = Int(parts[2]), + let date = bucketCalendar.date(from: DateComponents(year: year, month: month, day: day)) + else { return nil } + guard bucketCalendar.dateComponents([.year, .month, .day], from: date) == DateComponents( + year: year, + month: month, + day: day) + else { return nil } + return displayCalendar.startOfDay(for: date) + } + + private static func bucketCalendar(for provider: UsageProvider, displayCalendar: Calendar) -> Calendar { + guard provider == .mistral else { return displayCalendar } + // Mistral labels both daily buckets and snapshot coverage by UTC day. Map each UTC boundary into the + // containing local dashboard day instead of reinterpreting the label as a local date. + return self.gregorianCalendar(timeZone: TimeZone(secondsFromGMT: 0) ?? .gmt) + } + + private static func currencyCode(_ rawValue: String) -> String? { + let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + return value.isEmpty || value == "XXX" ? nil : value + } + + private static func validCost(_ value: Double?) -> Double? { + guard let value, value.isFinite, value >= 0 else { return nil } + return value + } + + private static func nonnegative(_ value: Int?) -> Int? { + guard let value, value >= 0 else { return nil } + return value + } + + private static func safeCostSum(_ values: [Double]) -> Double? { + guard !values.isEmpty else { return nil } + var result = 0.0 + for value in values { + result += value + guard result.isFinite else { return nil } + } + return result + } + + private static func completeCostSum(_ values: [Double?]) -> Double? { + guard values.allSatisfy({ $0 != nil }) else { return nil } + return self.safeCostSum(values.compactMap(\.self)) + } + + private static func safeIntSum(_ values: [Int]) -> Int? { + guard !values.isEmpty else { return nil } + var result = 0 + for value in values { + let addition = result.addingReportingOverflow(value) + guard !addition.overflow else { return nil } + result = addition.partialValue + } + return result + } + + private static func completeIntSum(_ values: [Int?]) -> Int? { + guard values.allSatisfy({ $0 != nil }) else { return nil } + return self.safeIntSum(values.compactMap(\.self)) + } + + private static func add(_ value: Int, to current: Int?, overflowed: inout Bool) -> Int? { + guard !overflowed, let current else { return nil } + let addition = current.addingReportingOverflow(value) + if addition.overflow { + overflowed = true + return nil + } + return addition.partialValue + } + + private static func add(_ value: Double, to current: Double?, overflowed: inout Bool) -> Double? { + guard !overflowed, let current else { return nil } + let result = current + value + guard result.isFinite else { + overflowed = true + return nil + } + return result + } +} diff --git a/Sources/CodexBar/UsageStore+BackgroundRefresh.swift b/Sources/CodexBar/UsageStore+BackgroundRefresh.swift index 354234d3ac..2a87229bdd 100644 --- a/Sources/CodexBar/UsageStore+BackgroundRefresh.swift +++ b/Sources/CodexBar/UsageStore+BackgroundRefresh.swift @@ -52,7 +52,7 @@ extension UsageStore { if provider == .claude { self.clearClaudeSwapAccountState() } - self.tokenSnapshots.removeValue(forKey: provider) + self.clearTokenSnapshot(for: provider) self.tokenErrors[provider] = nil self.providerStorageFootprints.removeValue(forKey: provider) self.failureGates[provider]?.reset() diff --git a/Sources/CodexBar/UsageStore+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index 097bc92325..44a70bfb74 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -132,10 +132,13 @@ extension UsageStore { } let request = self.providerRefreshCoordinator.beginReplacingRequest(for: provider) - self.providerRefreshPublicationContexts[provider] = ( + self.providerRefreshPublicationContexts[provider] = ProviderRefreshPublicationContext( generation: request.generation, enablementRevision: self.settings.providerEnablementRevision(for: provider), configRevision: self.settings.providerConfigRevision(for: provider), + tokenCostScopeSignature: Self.tokenCostRequiresProviderSnapshot(provider) + ? self.tokenSnapshotScopeSignature(for: provider) + : nil, allowDisabled: allowDisabled) let task = Task { @MainActor [weak self] in guard let self else { return } @@ -150,10 +153,13 @@ extension UsageStore { // A replacement can wait behind a predecessor while Settings changes. Capture // the publication inputs at actual fetch start so that queued work uses the new // configuration, while later changes still reject its suspended result. - self.providerRefreshPublicationContexts[provider] = ( + self.providerRefreshPublicationContexts[provider] = ProviderRefreshPublicationContext( generation: request.generation, enablementRevision: self.settings.providerEnablementRevision(for: provider), configRevision: self.settings.providerConfigRevision(for: provider), + tokenCostScopeSignature: Self.tokenCostRequiresProviderSnapshot(provider) + ? self.tokenSnapshotScopeSignature(for: provider) + : nil, allowDisabled: allowDisabled) snapshotUpdatedAtBeforeRefresh = self.snapshot(for: provider)?.updatedAt didStartRefresh = true @@ -188,7 +194,9 @@ extension UsageStore { return false } return context.enablementRevision == self.settings.providerEnablementRevision(for: provider) && - context.configRevision == self.settings.providerConfigRevision(for: provider) + context.configRevision == self.settings.providerConfigRevision(for: provider) && + (context.tokenCostScopeSignature == nil || + context.tokenCostScopeSignature == self.tokenSnapshotScopeSignature(for: provider)) } func currentProviderRefreshAllowsDisabledPublication(_ provider: UsageProvider) -> Bool { @@ -552,11 +560,11 @@ extension UsageStore { self.clearDeepSeekProfileTransition() } if let tokenSnapshot = self.tokenSnapshot(fromProviderSnapshot: backfilled, provider: provider) { - self.tokenSnapshots[provider] = tokenSnapshot + self.publishTokenSnapshot(tokenSnapshot, for: provider) self.tokenErrors[provider] = nil self.tokenFailureGates[provider]?.recordSuccess() } else if Self.tokenCostRequiresProviderSnapshot(provider) { - self.tokenSnapshots.removeValue(forKey: provider) + self.publishConfirmedEmptyTokenSnapshot(for: provider) self.tokenErrors[provider] = nil } self.lastSourceLabels[provider] = result.sourceLabel @@ -966,7 +974,7 @@ extension UsageStore { self.knownLimitsAvailabilityByProvider.removeValue(forKey: .claude) self.lastSourceLabels.removeValue(forKey: .claude) self.accountSnapshots.removeValue(forKey: .claude) - self.tokenSnapshots.removeValue(forKey: .claude) + self.clearTokenSnapshot(for: .claude) self.tokenErrors[.claude] = nil self.failureGates[.claude]?.reset() self.tokenFailureGates[.claude]?.reset() @@ -1086,6 +1094,9 @@ extension UsageStore { self.errors[provider] = error.localizedDescription if !preservesPriorData, !preservesClaudeWebSessionFailure { self.snapshots.removeValue(forKey: provider) + if Self.tokenCostRequiresProviderSnapshot(provider) { + self.clearTokenSnapshot(for: provider) + } } } else { self.errors[provider] = nil diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index 40b9e50511..c87f1b81d8 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -69,9 +69,11 @@ extension UsageStore { if let snapshot = cached.snapshot { self.snapshots[provider] = snapshot self.lastKnownResetSnapshots[provider] = snapshot + self.installProviderDerivedTokenSnapshot(from: snapshot, for: provider) } else { self.snapshots.removeValue(forKey: provider) self.lastKnownResetSnapshots.removeValue(forKey: provider) + self.resetProviderDerivedTokenSnapshot(for: provider) } self.errors[provider] = cached.error if let sourceLabel = cached.sourceLabel { @@ -131,6 +133,7 @@ extension UsageStore { private func clearTokenAccountLiveSnapshot(provider: UsageProvider) { self.snapshots.removeValue(forKey: provider) + self.resetProviderDerivedTokenSnapshot(for: provider) self.errors.removeValue(forKey: provider) self.lastSourceLabels.removeValue(forKey: provider) self.lastKnownResetSnapshots.removeValue(forKey: provider) @@ -158,6 +161,9 @@ extension UsageStore { var material = Data(provider.rawValue.utf8) material.append((try? encoder.encode(config)) ?? Data()) material.append((try? encoder.encode(account)) ?? Data()) + if Self.tokenCostRequiresProviderSnapshot(provider) { + material.append(Data(self.tokenSnapshotScopeSignature(for: provider).utf8)) + } return SHA256.hash(data: material).map { String(format: "%02x", $0) }.joined() } @@ -896,7 +902,6 @@ extension UsageStore { tokenOverride: override, codexActiveSourceOverride: codexActiveSourceOverride) let fetcher = ProviderRegistry.makeFetcher(base: self.codexFetcher, provider: provider, env: env) - let verbose = self.settings.isVerboseLoggingEnabled let contextProvider = provider let publicationGeneration = self.providerRefreshPublicationContexts[provider]?.generation let contextConfigRevision = self.settings.providerConfigRevision(for: provider) @@ -912,7 +917,7 @@ extension UsageStore { override: override), webTimeout: 60, webDebugDumpHTML: false, - verbose: verbose, + verbose: self.settings.isVerboseLoggingEnabled, env: env, settings: snapshot, fetcher: fetcher, @@ -1524,6 +1529,7 @@ extension UsageStore { if provider == .deepseek { self.clearDeepSeekProfileTransition() } + self.publishProviderDerivedTokenSnapshot(from: backfilled, for: provider) self.lastSourceLabels[provider] = result.sourceLabel self.errors[provider] = nil self.knownLimitsAvailabilityByProvider.removeValue(forKey: provider) @@ -1575,6 +1581,7 @@ extension UsageStore { if shouldSurface { self.errors[provider] = message self.snapshots.removeValue(forKey: provider) + self.clearProviderDerivedTokenSnapshot(for: provider) } else { self.errors[provider] = nil } diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index 764682455f..91a3299827 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -1,6 +1,23 @@ import CodexBarCore import Foundation +struct CurrentProviderConfigTokenSnapshot: Sendable, Equatable { + let snapshot: CostUsageTokenSnapshot + let publicationRevision: UInt64 +} + +struct CurrentProviderConfigTokenPublication: Sendable, Equatable { + let snapshot: CostUsageTokenSnapshot? + let publicationRevision: UInt64 +} + +struct TokenSnapshotPublication: Sendable, Equatable { + let snapshot: CostUsageTokenSnapshot? + let publicationRevision: UInt64 + let providerConfigRevision: UInt64 + let scopeSignature: String +} + extension UsageStore { func loadTokenUsageSnapshot( provider: UsageProvider, @@ -48,6 +65,105 @@ extension UsageStore { self.tokenSnapshots[provider] } + func tokenSnapshotForCurrentProviderConfig( + for provider: UsageProvider) -> CurrentProviderConfigTokenSnapshot? + { + guard let publication = self.tokenSnapshotPublicationForCurrentProviderConfig(for: provider), + let snapshot = publication.snapshot + else { return nil } + return CurrentProviderConfigTokenSnapshot( + snapshot: snapshot, + publicationRevision: publication.publicationRevision) + } + + func tokenSnapshotPublicationForCurrentProviderConfig( + for provider: UsageProvider) -> CurrentProviderConfigTokenPublication? + { + guard let publication = self.tokenSnapshotPublications[provider], + publication.providerConfigRevision == self.settings.providerConfigRevision(for: provider), + publication.scopeSignature == self.tokenSnapshotScopeSignature(for: provider) + else { return nil } + return CurrentProviderConfigTokenPublication( + snapshot: publication.snapshot, + publicationRevision: publication.publicationRevision) + } + + func tokenSnapshotPublicationRevision(for provider: UsageProvider) -> UInt64 { + self.tokenSnapshotPublicationRevisions[provider] ?? 0 + } + + func publishTokenSnapshot(_ snapshot: CostUsageTokenSnapshot, for provider: UsageProvider) { + self.tokenSnapshots[provider] = snapshot + self.publishTokenSnapshotState(snapshot, for: provider) + } + + func publishConfirmedEmptyTokenSnapshot(for provider: UsageProvider) { + self.tokenSnapshots.removeValue(forKey: provider) + self.publishTokenSnapshotState(nil, for: provider) + } + + private func publishTokenSnapshotState(_ snapshot: CostUsageTokenSnapshot?, for provider: UsageProvider) { + self.tokenSnapshotPublicationRevisions[provider, default: 0] &+= 1 + self.tokenSnapshotPublications[provider] = TokenSnapshotPublication( + snapshot: snapshot, + publicationRevision: self.tokenSnapshotPublicationRevision(for: provider), + providerConfigRevision: self.settings.providerConfigRevision(for: provider), + scopeSignature: self.tokenSnapshotScopeSignature(for: provider)) + } + + func installCachedTokenSnapshot(_ snapshot: CostUsageTokenSnapshot, for provider: UsageProvider) { + self.tokenSnapshots[provider] = snapshot + self.tokenSnapshotPublications[provider] = TokenSnapshotPublication( + snapshot: snapshot, + publicationRevision: self.tokenSnapshotPublicationRevision(for: provider), + providerConfigRevision: self.settings.providerConfigRevision(for: provider), + scopeSignature: self.tokenSnapshotScopeSignature(for: provider)) + } + + func clearTokenSnapshot(for provider: UsageProvider) { + self.tokenSnapshots.removeValue(forKey: provider) + self.tokenSnapshotPublications.removeValue(forKey: provider) + } + + func clearTokenSnapshots() { + self.tokenSnapshots.removeAll() + self.tokenSnapshotPublications.removeAll() + } + + func installProviderDerivedTokenSnapshot(from snapshot: UsageSnapshot, for provider: UsageProvider) { + guard Self.tokenCostRequiresProviderSnapshot(provider) else { return } + if let tokenSnapshot = self.tokenSnapshot(fromProviderSnapshot: snapshot, provider: provider) { + self.installCachedTokenSnapshot(tokenSnapshot, for: provider) + } else { + self.clearTokenSnapshot(for: provider) + } + self.tokenErrors[provider] = nil + self.tokenFailureGates[provider]?.recordSuccess() + } + + func publishProviderDerivedTokenSnapshot(from snapshot: UsageSnapshot, for provider: UsageProvider) { + guard Self.tokenCostRequiresProviderSnapshot(provider) else { return } + if let tokenSnapshot = self.tokenSnapshot(fromProviderSnapshot: snapshot, provider: provider) { + self.publishTokenSnapshot(tokenSnapshot, for: provider) + } else { + self.publishConfirmedEmptyTokenSnapshot(for: provider) + } + self.tokenErrors[provider] = nil + self.tokenFailureGates[provider]?.recordSuccess() + } + + func resetProviderDerivedTokenSnapshot(for provider: UsageProvider) { + guard Self.tokenCostRequiresProviderSnapshot(provider) else { return } + self.clearTokenSnapshot(for: provider) + self.tokenErrors[provider] = nil + self.tokenFailureGates[provider]?.reset() + } + + func clearProviderDerivedTokenSnapshot(for provider: UsageProvider) { + guard Self.tokenCostRequiresProviderSnapshot(provider) else { return } + self.clearTokenSnapshot(for: provider) + } + func tokenError(for provider: UsageProvider) -> String? { self.tokenErrors[provider] } @@ -56,18 +172,23 @@ extension UsageStore { self.lastTokenFetchAt[provider] } - func hydrateCachedTokenSnapshots(now: Date = Date()) { - guard self.settings.costUsageEnabled else { return } + @discardableResult + func hydrateCachedTokenSnapshots(now: Date = Date()) -> Task? { + guard self.settings.costUsageEnabled else { return nil } guard self.settings.enabledProvidersOrdered(metadataByProvider: self.providerMetadata).contains(.codex) else { - return + return nil } let scope = self.tokenCostScope(for: .codex) let historyDays = self.settings.costUsageHistoryDays let publicationRevision = self.providerPublicationRevision(for: .codex) - Task { @MainActor [weak self] in + let providerConfigRevision = self.settings.providerConfigRevision(for: .codex) + let costUsageSettingsRevision = self.settings.costUsageSettingsRevision + let tokenSnapshotScopeSignature = self.tokenSnapshotScopeSignature(for: .codex) + let tokenSnapshotPublicationRevision = self.tokenSnapshotPublicationRevision(for: .codex) + return Task { @MainActor [weak self] in guard let self else { return } - guard self.tokenSnapshots[.codex] == nil else { return } + guard self.tokenSnapshotPublicationForCurrentProviderConfig(for: .codex) == nil else { return } let result: (snapshot: CostUsageTokenSnapshot, lastRefreshAt: Date?)? = if let override = self ._test_cachedCodexTokenSnapshotLoaderOverride { @@ -84,22 +205,26 @@ extension UsageStore { return } guard self.providerPublicationRevisionIsCurrent(publicationRevision, for: .codex), + self.settings.providerConfigRevision(for: .codex) == providerConfigRevision, + self.settings.costUsageSettingsRevision == costUsageSettingsRevision, self.settings.costUsageEnabled, self.isEnabled(.codex), self.tokenCostScope(for: .codex).signature == scope.signature, self.settings.costUsageHistoryDays == historyDays, - self.tokenSnapshots[.codex] == nil + self.tokenSnapshotScopeSignature(for: .codex) == tokenSnapshotScopeSignature, + self.tokenSnapshotPublicationRevision(for: .codex) == tokenSnapshotPublicationRevision, + self.tokenSnapshotPublicationForCurrentProviderConfig(for: .codex) == nil else { return } - self.tokenSnapshots[.codex] = result.snapshot + self.installCachedTokenSnapshot(result.snapshot, for: .codex) self.tokenErrors[.codex] = nil if let lastRefreshAt = result.lastRefreshAt, now.timeIntervalSince(lastRefreshAt) >= 0, now.timeIntervalSince(lastRefreshAt) < self.tokenFetchTTL { self.lastTokenFetchAt[.codex] = lastRefreshAt - self.lastTokenFetchScope[.codex] = "\(scope.signature)|historyDays=\(historyDays)" + self.lastTokenFetchScope[.codex] = tokenSnapshotScopeSignature } } } @@ -109,6 +234,9 @@ extension UsageStore { } func tokenCostScope(for provider: UsageProvider) -> (codexHomePath: String?, signature: String) { + if provider == .vertexai { + return (nil, "vertexai:allow-claude-fallback=\(!self.isEnabled(.claude))") + } guard provider == .codex else { return (nil, provider.rawValue) } @@ -120,6 +248,42 @@ extension UsageStore { return (homePath, "codex:managed:\(homePath)") } + func tokenSnapshotScopeSignature(for provider: UsageProvider) -> String { + let scope = self.tokenCostScope(for: provider) + return "\(scope.signature)|historyDays=\(self.settings.costUsageHistoryDays)" + + "|settingsRevision=\(self.settings.costUsageSettingsRevision)" + } + + func tokenRefreshCanReuseCurrentSnapshot( + provider: UsageProvider, + now: Date, + costScopeSignature: String) -> Bool + { + guard self.tokenSnapshotPublicationForCurrentProviderConfig(for: provider) != nil, + let last = self.lastTokenFetchAt[provider], + self.lastTokenFetchScope[provider] == costScopeSignature + else { + return false + } + return now.timeIntervalSince(last) < self.tokenFetchTTL + } + + func tokenRefreshPublicationIsCurrent( + provider: UsageProvider, + publicationRevision: ProviderPublicationRevision, + providerConfigRevision: UInt64, + costScopeSignature: String) -> Bool + { + guard self.providerPublicationRevisionIsCurrent(publicationRevision, for: provider), + self.settings.providerConfigRevision(for: provider) == providerConfigRevision, + self.settings.costUsageEnabled, + self.isEnabled(provider) + else { + return false + } + return self.tokenSnapshotScopeSignature(for: provider) == costScopeSignature + } + func tokenSnapshot( fromProviderSnapshot snapshot: UsageSnapshot?, provider: UsageProvider) @@ -175,7 +339,7 @@ extension UsageStore { guard errorMessage == nil else { return errorMessage } - self.tokenSnapshots.removeAll() + self.clearTokenSnapshots() self.tokenErrors.removeAll() self.lastTokenFetchAt.removeAll() self.lastTokenFetchScope.removeAll() diff --git a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift index f2a2ec2a31..0a8000d756 100644 --- a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift +++ b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift @@ -40,11 +40,10 @@ extension UsageStore { private func makeWidgetEntry(for provider: UsageProvider, now: Date) -> WidgetSnapshot.ProviderEntry? { let snapshot = self.snapshots[provider] - let storedTokenSnapshot = self.tokenSnapshots[provider] + let storedTokenSnapshot = self.tokenSnapshotForCurrentProviderConfig(for: provider)?.snapshot guard snapshot != nil || (provider == .claude && storedTokenSnapshot != nil) else { return nil } - let tokenSnapshot = self.tokenSnapshot(fromProviderSnapshot: snapshot, provider: provider) ?? self - .tokenSnapshots[provider] + let tokenSnapshot = storedTokenSnapshot let dailyUsage = tokenSnapshot?.daily.map { entry in WidgetSnapshot.DailyUsagePoint( dayKey: entry.date, diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index e7d15e2c65..12b3877c43 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -172,6 +172,8 @@ final class UsageStore { @ObservationIgnored var claudeSwapRefreshTask: Task? @ObservationIgnored var claudeSwapTransientState = ClaudeSwapTransientState() var tokenSnapshots: [UsageProvider: CostUsageTokenSnapshot] = [:] + var tokenSnapshotPublications: [UsageProvider: TokenSnapshotPublication] = [:] + var tokenSnapshotPublicationRevisions: [UsageProvider: UInt64] = [:] var tokenErrors: [UsageProvider: String] = [:] var tokenRefreshInFlight: Set = [] var credits: CreditsSnapshot? @@ -280,11 +282,7 @@ final class UsageStore { @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 providerRefreshPublicationContexts: [UsageProvider: ProviderRefreshPublicationContext] = [:] @ObservationIgnored var providerCleanupRevisions: [UsageProvider: UInt64] = [:] @ObservationIgnored private var providerAvailabilityCache: [UsageProvider: ProviderAvailabilityCacheEntry] = [:] @ObservationIgnored var accountInfoCache: [UsageProvider: AccountInfoCacheEntry] = [:] @@ -1425,7 +1423,7 @@ extension UsageStore { func refreshTokenUsage(_ provider: UsageProvider, force: Bool) async { guard ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.supportsTokenCost else { - self.tokenSnapshots.removeValue(forKey: provider) + self.clearTokenSnapshot(for: provider) self.tokenErrors[provider] = nil self.tokenFailureGates[provider]?.reset() self.lastTokenFetchAt.removeValue(forKey: provider) @@ -1434,13 +1432,12 @@ extension UsageStore { } if Self.tokenCostRequiresProviderSnapshot(provider) { - if let snapshot = self.tokenSnapshot(fromProviderSnapshot: self.snapshots[provider], provider: provider) { - self.tokenSnapshots[provider] = snapshot + if self.tokenSnapshotPublicationForCurrentProviderConfig(for: provider) != nil { self.tokenErrors[provider] = nil self.tokenFailureGates[provider]?.recordSuccess() self.persistWidgetSnapshot(reason: "token-usage") } else { - self.tokenSnapshots.removeValue(forKey: provider) + self.clearTokenSnapshot(for: provider) self.tokenErrors[provider] = nil self.tokenFailureGates[provider]?.reset() } @@ -1448,7 +1445,7 @@ extension UsageStore { } guard self.settings.costUsageEnabled else { - self.tokenSnapshots.removeValue(forKey: provider) + self.clearTokenSnapshot(for: provider) self.tokenErrors[provider] = nil self.tokenFailureGates[provider]?.reset() self.lastTokenFetchAt.removeValue(forKey: provider) @@ -1457,7 +1454,7 @@ extension UsageStore { } guard self.isEnabled(provider) else { - self.tokenSnapshots.removeValue(forKey: provider) + self.clearTokenSnapshot(for: provider) self.tokenErrors[provider] = nil self.tokenFailureGates[provider]?.reset() self.lastTokenFetchAt.removeValue(forKey: provider) @@ -1470,12 +1467,13 @@ extension UsageStore { let now = Date() let historyDays = self.settings.costUsageHistoryDays let costScope = self.tokenCostScope(for: provider) - let costScopeSignature = "\(costScope.signature)|historyDays=\(historyDays)" + let costScopeSignature = self.tokenSnapshotScopeSignature(for: provider) let publicationRevision = self.providerPublicationRevision(for: provider) - if !force, - let last = self.lastTokenFetchAt[provider], - self.lastTokenFetchScope[provider] == costScopeSignature, - now.timeIntervalSince(last) < self.tokenFetchTTL + let providerConfigRevision = self.settings.providerConfigRevision(for: provider) + if !force, self.tokenRefreshCanReuseCurrentSnapshot( + provider: provider, + now: now, + costScopeSignature: costScopeSignature) { return } @@ -1494,9 +1492,8 @@ extension UsageStore { } let startedAt = Date() - let providerText = provider.rawValue self.tokenCostLogger - .debug("cost usage start provider=\(providerText) force=\(force)") + .debug("cost usage start provider=\(provider.rawValue) force=\(force)") do { // Codex cost usage scans local session logs from this machine. That data is @@ -1514,6 +1511,7 @@ extension UsageStore { guard self.tokenRefreshPublicationIsCurrent( provider: provider, publicationRevision: publicationRevision, + providerConfigRevision: providerConfigRevision, costScopeSignature: costScopeSignature) else { self.clearTokenFetchMetadataIfMatching( @@ -1525,7 +1523,7 @@ extension UsageStore { } guard !snapshot.daily.isEmpty else { - self.tokenSnapshots.removeValue(forKey: provider) + self.publishConfirmedEmptyTokenSnapshot(for: provider) self.tokenErrors[provider] = Self.tokenCostNoDataMessage(for: provider) self.tokenFailureGates[provider]?.recordSuccess() return @@ -1537,12 +1535,12 @@ extension UsageStore { .map { UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) } ?? "—" let durationText = String(format: "%.2f", duration) let message = - "cost usage success provider=\(providerText) " + + "cost usage success provider=\(provider.rawValue) " + "duration=\(durationText)s " + "today=\(sessionCost) " + "historyDays=\(historyDays) windowCost=\(monthCost)" self.tokenCostLogger.info(message) - self.tokenSnapshots[provider] = snapshot + self.publishTokenSnapshot(snapshot, for: provider) self.tokenErrors[provider] = nil self.tokenFailureGates[provider]?.recordSuccess() self.persistWidgetSnapshot(reason: "token-usage") @@ -1550,6 +1548,7 @@ extension UsageStore { guard self.tokenRefreshPublicationIsCurrent( provider: provider, publicationRevision: publicationRevision, + providerConfigRevision: providerConfigRevision, costScopeSignature: costScopeSignature) else { self.clearTokenFetchMetadataIfMatching( @@ -1569,7 +1568,7 @@ extension UsageStore { 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)" + let message = "cost usage failed provider=\(provider.rawValue) duration=\(durationText)s error=\(msg)" self.tokenCostLogger.error(message) if Self.tokenFetchFailureAllowsEarlyRetry(error) { self.clearTokenFetchMetadataIfMatching( @@ -1582,29 +1581,13 @@ extension UsageStore { .shouldSurfaceError(onFailureWithPriorData: hadPriorData) ?? true if shouldSurface { self.tokenErrors[provider] = error.localizedDescription - self.tokenSnapshots.removeValue(forKey: provider) + self.clearTokenSnapshot(for: 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, diff --git a/Sources/CodexBar/UsageStoreSupport.swift b/Sources/CodexBar/UsageStoreSupport.swift index c0047d19b1..d553e56e1b 100644 --- a/Sources/CodexBar/UsageStoreSupport.swift +++ b/Sources/CodexBar/UsageStoreSupport.swift @@ -34,6 +34,14 @@ struct ProviderStatus { let updatedAt: Date? } +struct ProviderRefreshPublicationContext { + let generation: UInt64 + let enablementRevision: UInt64 + var configRevision: UInt64 + let tokenCostScopeSignature: String? + let allowDisabled: Bool +} + /// A single component/service row on a statuspage.io-style status page /// (e.g. "Codex API", "CLI", "FedRAMP") with its current state. A row with non-empty /// `children` is a component group and renders as an expandable dropdown. @@ -94,7 +102,9 @@ struct ConsecutiveFailureGate { /// Returns true when the caller should surface the error to the UI. mutating func shouldSurfaceError(onFailureWithPriorData hadPriorData: Bool) -> Bool { self.streak += 1 - if hadPriorData, self.streak == 1 { return false } + if hadPriorData, self.streak == 1 { + return false + } return true } } @@ -106,7 +116,11 @@ extension UsageStore { } func _setTokenSnapshotForTesting(_ snapshot: CostUsageTokenSnapshot?, provider: UsageProvider) { - self.tokenSnapshots[provider] = snapshot + if let snapshot { + self.publishTokenSnapshot(snapshot, for: provider) + } else { + self.clearTokenSnapshot(for: provider) + } } func _setTokenErrorForTesting(_ error: String?, provider: UsageProvider) { diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index c1f1cfb4cb..1967165c79 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -65,7 +65,8 @@ public struct CostUsageFetcher: Sendable { allowVertexClaudeFallback: Bool = false, codexHomePath: String? = nil, historyDays: Int = 30, - refreshPricingInBackground: Bool = true) async throws -> CostUsageTokenSnapshot + refreshPricingInBackground: Bool = true, + includePiSessions: Bool = true) async throws -> CostUsageTokenSnapshot { try await Self.loadTokenSnapshot( provider: provider, @@ -76,6 +77,7 @@ public struct CostUsageFetcher: Sendable { codexHomePath: codexHomePath, historyDays: historyDays, refreshPricingInBackground: refreshPricingInBackground, + includePiSessions: includePiSessions, bypassScannerDebounce: false, scannerOptions: self.scannerOptionsOverride()) } @@ -89,6 +91,7 @@ public struct CostUsageFetcher: Sendable { codexHomePath: String? = nil, historyDays: Int = 30, refreshPricingInBackground: Bool = true, + includePiSessions: Bool = true, bypassScannerDebounce: Bool) async throws -> CostUsageTokenSnapshot { try await Self.loadTokenSnapshot( @@ -100,6 +103,7 @@ public struct CostUsageFetcher: Sendable { codexHomePath: codexHomePath, historyDays: historyDays, refreshPricingInBackground: refreshPricingInBackground, + includePiSessions: includePiSessions, bypassScannerDebounce: bypassScannerDebounce, scannerOptions: self.scannerOptionsOverride()) } @@ -140,6 +144,7 @@ public struct CostUsageFetcher: Sendable { codexHomePath: String? = nil, historyDays: Int = 30, refreshPricingInBackground: Bool = true, + includePiSessions: Bool = true, bypassScannerDebounce: Bool = false, scannerOptions overrideScannerOptions: CostUsageScanner.Options? = nil, piScannerOptions overridePiScannerOptions: PiSessionCostScanner @@ -252,7 +257,7 @@ public struct CostUsageFetcher: Sendable { range: CostUsageScanner.CostUsageDayRange(since: since, until: until), modelsDevCacheRoot: scanOptions.cacheRoot) } - if provider == .codex || provider == .claude { + if includePiSessions, provider == .codex || provider == .claude { let piReport = try PiSessionCostScanner.loadDailyReportCancellable( provider: provider, since: since, @@ -291,6 +296,7 @@ public struct CostUsageFetcher: Sendable { codexHomePath: codexHomePath, historyDays: historyDays, refreshPricingInBackground: false, + includePiSessions: includePiSessions, scannerOptions: options, piScannerOptions: piOptions, modelsDevClient: modelsDevClient, diff --git a/Sources/CodexBarCore/CostUsageModels.swift b/Sources/CodexBarCore/CostUsageModels.swift index bee1022d83..61ba9c3333 100644 --- a/Sources/CodexBarCore/CostUsageModels.swift +++ b/Sources/CodexBarCore/CostUsageModels.swift @@ -31,6 +31,7 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { public let last30DaysRequests: Int? public let currencyCode: String public let historyDays: Int + public let historyCoverageIsEstablished: Bool public let historyLabel: String? public let daily: [CostUsageDailyReport.Entry] public let projects: [CostUsageProjectBreakdown] @@ -45,6 +46,7 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { last30DaysRequests: Int? = nil, currencyCode: String = "USD", historyDays: Int = 30, + historyCoverageIsEstablished: Bool = true, historyLabel: String? = nil, daily: [CostUsageDailyReport.Entry], projects: [CostUsageProjectBreakdown] = [], @@ -56,10 +58,10 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { self.last30DaysTokens = last30DaysTokens self.last30DaysCostUSD = last30DaysCostUSD self.last30DaysRequests = last30DaysRequests - self.currencyCode = currencyCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - ? "USD" - : currencyCode.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + let normalizedCurrencyCode = currencyCode.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + self.currencyCode = normalizedCurrencyCode.isEmpty ? "XXX" : normalizedCurrencyCode self.historyDays = historyDays + self.historyCoverageIsEstablished = historyCoverageIsEstablished self.historyLabel = historyLabel self.daily = daily self.projects = projects diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift b/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift index a337501e75..db653f1a84 100644 --- a/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift +++ b/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift @@ -228,43 +228,295 @@ public struct MistralUsageSnapshot: Codable, Sendable { } public func toCostUsageTokenSnapshot(historyDays: Int = 30) -> CostUsageTokenSnapshot { - let clampedHistoryDays = max(1, min(365, historyDays)) - let selected = self.daily - let entries = selected.map { bucket in - let modelBreakdowns = bucket.models.map { - CostUsageDailyReport.ModelBreakdown( - modelName: $0.name, - costUSD: max($0.cost, 0), - totalTokens: $0.totalTokens) - } + let window = self.dailyWindow(requestedHistoryDays: historyDays) + let buckets = window.rows.map(\.bucket) + let hasUnplacedCost = window.rows.contains { row in + row.date == nil && (row.bucket.cost != 0 || row.bucket.models.contains { $0.cost != 0 }) + } + let hasUnplacedTokens = window.rows.contains { row in + row.date == nil && ( + row.bucket.inputTokens != 0 + || row.bucket.cachedTokens != 0 + || row.bucket.outputTokens != 0 + || row.bucket.models.contains { + $0.inputTokens != 0 || $0.cachedTokens != 0 || $0.outputTokens != 0 + }) + } + let costDataIsComplete = window.coverageIsEstablished + && self.dailyCostMatchesSnapshot() + && !hasUnplacedCost + let tokenDataIsComplete = window.coverageIsEstablished + && self.dailyTokensMatchSnapshot() + && !hasUnplacedTokens + let windowCosts = costDataIsComplete ? Self.nonnegativeWindowCosts(buckets.map(\.cost)) : nil + let windowCostIsComplete = windowCosts != nil + let displayedCosts = windowCosts?.map(Optional.some) ?? Array(repeating: nil, count: buckets.count) + let rowTokens: [Int?] = buckets.map { bucket in + tokenDataIsComplete ? Self.tokenTotal(for: bucket) : nil + } + let windowTokensAreComplete = tokenDataIsComplete && rowTokens.allSatisfy { $0 != nil } + let entries = buckets.enumerated().map { index, bucket in + let modelBreakdowns = Self.modelBreakdowns( + for: bucket, + costsAreComplete: windowCostIsComplete, + tokensAreComplete: tokenDataIsComplete) let modelsUsed = bucket.models.map(\.name) return CostUsageDailyReport.Entry( date: bucket.day, - inputTokens: bucket.inputTokens, - outputTokens: bucket.outputTokens, - cacheReadTokens: bucket.cachedTokens, + inputTokens: tokenDataIsComplete ? bucket.inputTokens : nil, + outputTokens: tokenDataIsComplete ? bucket.outputTokens : nil, + cacheReadTokens: tokenDataIsComplete ? bucket.cachedTokens : nil, cacheCreationTokens: nil, - totalTokens: bucket.totalTokens, - costUSD: max(bucket.cost, 0), + totalTokens: rowTokens[index], + costUSD: displayedCosts[index], modelsUsed: modelsUsed.isEmpty ? nil : modelsUsed, modelBreakdowns: modelBreakdowns.isEmpty ? nil : modelBreakdowns) } - let latest = selected.last - let totalCost = max(self.totalCost, 0) - let totalTokens = selected.isEmpty - ? self.totalInputTokens + self.totalCachedTokens + self.totalOutputTokens - : selected.reduce(0) { $0 + $1.totalTokens } - let tokens = totalTokens > 0 ? totalTokens : nil + let latestIndex = window.rows.enumerated() + .compactMap { index, row in row.date.map { (index: index, date: $0) } } + .max { $0.date < $1.date }? + .index + let totalCost = windowCostIsComplete + ? Self.safeCostSum(displayedCosts.compactMap(\.self)) + : nil + let totalTokens = windowTokensAreComplete + ? Self.safeIntSum(rowTokens.compactMap(\.self)) + : nil return CostUsageTokenSnapshot( - sessionTokens: latest?.totalTokens, - sessionCostUSD: latest.map { max($0.cost, 0) }, - last30DaysTokens: tokens, + sessionTokens: latestIndex.flatMap { rowTokens[$0] }, + sessionCostUSD: latestIndex.flatMap { displayedCosts[$0] }, + last30DaysTokens: totalTokens, last30DaysCostUSD: totalCost, currencyCode: self.currency, - historyDays: selected.isEmpty ? clampedHistoryDays : max(1, min(365, selected.count)), - historyLabel: "This month", + historyDays: window.coveredDays, + historyCoverageIsEstablished: window.coverageIsEstablished, + historyLabel: window.isMonthToDate ? "This month" : nil, daily: entries, - updatedAt: self.updatedAt) + updatedAt: window.observationEnd) + } + + private struct WindowedBucket { + let bucket: MistralDailyUsageBucket + let date: Date? + } + + private struct DailyWindow { + let rows: [WindowedBucket] + let coveredDays: Int + let coverageIsEstablished: Bool + let isMonthToDate: Bool + let observationEnd: Date + } + + private struct DailyCoverage { + let days: Int + let end: Date + let isEstablished: Bool + } + + private func dailyWindow(requestedHistoryDays: Int) -> DailyWindow { + let requestedDays = max(1, min(365, requestedHistoryDays)) + let calendar = Self.apiCalendar + let selectionEnd = calendar.startOfDay(for: min(self.endDate ?? self.updatedAt, self.updatedAt)) + let windowStart = calendar.date(byAdding: .day, value: -(requestedDays - 1), to: selectionEnd) + ?? selectionEnd + var rows: [WindowedBucket] = [] + var selectedDates: [Date] = [] + for bucket in self.daily { + guard let date = Self.apiDay(from: bucket.day, calendar: calendar) else { + rows.append(WindowedBucket(bucket: bucket, date: nil)) + continue + } + guard date >= windowStart, date <= selectionEnd else { continue } + rows.append(WindowedBucket(bucket: bucket, date: date)) + selectedDates.append(date) + } + let coverage = self.dailyCoverage( + windowStart: windowStart, + windowEnd: selectionEnd, + selectedDates: selectedDates, + calendar: calendar) + + return DailyWindow( + rows: rows, + coveredDays: coverage.days, + coverageIsEstablished: coverage.isEstablished, + isMonthToDate: self.isMonthToDateWindow( + windowStart: windowStart, + windowEnd: selectionEnd, + calendar: calendar), + observationEnd: coverage.end) + } + + private func dailyCoverage( + windowStart: Date, + windowEnd: Date, + selectedDates: [Date], + calendar: Calendar) -> DailyCoverage + { + if let startDate = self.startDate, let endDate = self.endDate, + let coveredDays = Self.inclusiveDayCount( + from: max(calendar.startOfDay(for: startDate), windowStart), + through: min(calendar.startOfDay(for: min(endDate, self.updatedAt)), windowEnd), + calendar: calendar) + { + let coveredEnd = min(calendar.startOfDay(for: min(endDate, self.updatedAt)), windowEnd) + return DailyCoverage(days: coveredDays, end: coveredEnd, isEstablished: true) + } + + if let firstDay = selectedDates.min(), let lastDay = selectedDates.max(), + let coveredDays = Self.inclusiveDayCount(from: firstDay, through: lastDay, calendar: calendar) + { + return DailyCoverage(days: coveredDays, end: lastDay, isEstablished: true) + } + + return DailyCoverage(days: 1, end: windowEnd, isEstablished: false) + } + + private func isMonthToDateWindow(windowStart: Date, windowEnd: Date, calendar: Calendar) -> Bool { + guard let startDate = self.startDate, let endDate = self.endDate else { return false } + let monthStart = calendar.startOfDay(for: startDate) + let observationEnd = calendar.startOfDay(for: min(endDate, self.updatedAt)) + return calendar.component(.day, from: monthStart) == 1 + && calendar.isDate(monthStart, equalTo: self.updatedAt, toGranularity: .month) + && calendar.startOfDay(for: endDate) >= calendar.startOfDay(for: self.updatedAt) + && windowStart <= monthStart + && windowEnd >= observationEnd + } + + private func dailyCostMatchesSnapshot() -> Bool { + guard self.hasNonnegativeCosts(), + let dailyCost = Self.safeCostSum(self.daily.map(\.cost)) + else { return false } + return Self.costsMatch(self.totalCost, dailyCost) + } + + private func hasNonnegativeCosts() -> Bool { + guard self.totalCost.isFinite, self.totalCost >= 0 else { return false } + return self.daily.allSatisfy { bucket in + bucket.cost.isFinite + && bucket.cost >= 0 + && bucket.models.allSatisfy { $0.cost.isFinite && $0.cost >= 0 } + } + } + + private func dailyTokensMatchSnapshot() -> Bool { + guard self.hasNonnegativeTokenCounters(), + let snapshotTokens = Self.safeIntSum([ + self.totalInputTokens, + self.totalCachedTokens, + self.totalOutputTokens, + ]), + let dailyTokens = Self.safeIntSum(self.daily.flatMap { bucket in + [bucket.inputTokens, bucket.cachedTokens, bucket.outputTokens] + }) + else { return false } + return snapshotTokens == dailyTokens + } + + private func hasNonnegativeTokenCounters() -> Bool { + guard [self.totalInputTokens, self.totalCachedTokens, self.totalOutputTokens] + .allSatisfy({ $0 >= 0 }) + else { return false } + return self.daily.allSatisfy { bucket in + [bucket.inputTokens, bucket.cachedTokens, bucket.outputTokens].allSatisfy { $0 >= 0 } + && bucket.models.allSatisfy { model in + [model.inputTokens, model.cachedTokens, model.outputTokens].allSatisfy { $0 >= 0 } + } + } + } + + private static func nonnegativeWindowCosts(_ rawCosts: [Double]) -> [Double]? { + guard rawCosts.allSatisfy({ $0.isFinite && $0 >= 0 }) else { return nil } + return rawCosts + } + + private static func modelBreakdowns( + for bucket: MistralDailyUsageBucket, + costsAreComplete: Bool, + tokensAreComplete: Bool) -> [CostUsageDailyReport.ModelBreakdown] + { + bucket.models.map { model in + let modelCost = costsAreComplete && model.cost.isFinite && model.cost >= 0 ? model.cost : nil + let modelTokens = tokensAreComplete ? Self.safeIntSum([ + model.inputTokens, + model.cachedTokens, + model.outputTokens, + ]) : nil + return CostUsageDailyReport.ModelBreakdown( + modelName: model.name, + costUSD: modelCost, + totalTokens: modelTokens) + } + } + + private static func tokenTotal(for bucket: MistralDailyUsageBucket) -> Int? { + self.safeIntSum([bucket.inputTokens, bucket.cachedTokens, bucket.outputTokens]) + } + + private static func safeCostSum(_ values: [Double]) -> Double? { + var total = 0.0 + for value in values { + guard value.isFinite else { return nil } + total += value + guard total.isFinite else { return nil } + } + return total + } + + private static func safeIntSum(_ values: [Int]) -> Int? { + var total = 0 + for value in values { + let addition = total.addingReportingOverflow(value) + guard !addition.overflow else { return nil } + total = addition.partialValue + } + return total + } + + private static func costsMatch(_ lhs: Double, _ rhs: Double) -> Bool { + guard lhs.isFinite, rhs.isFinite else { return false } + let tolerance = min(1e-6, max(1e-9, max(abs(lhs), abs(rhs)) * 1e-12)) + return abs(lhs - rhs) <= tolerance + } + + private static var apiCalendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.locale = Locale(identifier: "en_US_POSIX") + calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .gmt + return calendar + } + + private static func inclusiveDayCount(from start: Date, through end: Date, calendar: Calendar) -> Int? { + let startDay = calendar.startOfDay(for: start) + let endDay = calendar.startOfDay(for: end) + guard startDay <= endDay, + let difference = calendar.dateComponents([.day], from: startDay, to: endDay).day, + difference >= 0 + else { return nil } + return min(365, difference + 1) + } + + private static func apiDay(from rawValue: String, calendar: Calendar) -> Date? { + let bytes = Array(rawValue.utf8) + let digitIndices = [0, 1, 2, 3, 5, 6, 8, 9] + guard bytes.count == 10, + bytes[4] == 45, + bytes[7] == 45, + digitIndices.allSatisfy({ (48...57).contains(bytes[$0]) }) + else { return nil } + let parts = rawValue.split(separator: "-") + guard parts.count == 3, + let year = Int(parts[0]), + let month = Int(parts[1]), + let day = Int(parts[2]), + let date = calendar.date(from: DateComponents(year: year, month: month, day: day)), + calendar.dateComponents([.year, .month, .day], from: date) == DateComponents( + year: year, + month: month, + day: day) + else { return nil } + return calendar.startOfDay(for: date) } } diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralUsageFetcher.swift b/Sources/CodexBarCore/Providers/Mistral/MistralUsageFetcher.swift index 520cc4b694..e0a9575712 100644 --- a/Sources/CodexBarCore/Providers/Mistral/MistralUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Mistral/MistralUsageFetcher.swift @@ -294,8 +294,15 @@ public enum MistralUsageFetcher { } } - let currency = billing.currency ?? "EUR" - let currencySymbol = billing.currencySymbol ?? "€" + let rawCurrency = billing.currency?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let currency = rawCurrency.isEmpty ? "XXX" : rawCurrency.uppercased() + let rawCurrencySymbol = billing.currencySymbol?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let defaultCurrencySymbol = switch currency { + case "EUR": "€" + case "XXX": "¤" + default: currency + } + let currencySymbol = rawCurrencySymbol.isEmpty ? defaultCurrencySymbol : rawCurrencySymbol let startDate = billing.startDate.flatMap { Self.parseDate($0) } let endDate = billing.endDate.flatMap { Self.parseDate($0) } diff --git a/Tests/CodexBarTests/CostUsageFetcherTests.swift b/Tests/CodexBarTests/CostUsageFetcherTests.swift index 19acef3f49..f357ec9e42 100644 --- a/Tests/CodexBarTests/CostUsageFetcherTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherTests.swift @@ -555,6 +555,12 @@ struct CostUsageFetcherTests { now: day, scannerOptions: nativeOptions, piScannerOptions: piOptions) + let withoutPi = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + includePiSessions: false, + scannerOptions: nativeOptions, + piScannerOptions: piOptions) let nativeCost = CostUsagePricing.codexCostUSD( model: "gpt-5.4", @@ -570,6 +576,7 @@ struct CostUsageFetcherTests { #expect(snapshot.daily.count == 1) #expect(snapshot.daily.first?.date == "2026-04-08") #expect(snapshot.daily.first?.totalTokens == 170) + #expect(withoutPi.daily.first?.totalTokens == 110) #expect(abs((snapshot.daily.first?.costUSD ?? 0) - (nativeCost + piCost)) < 0.000001) let breakdown = try #require(snapshot.daily.first?.modelBreakdowns?.first) #expect(breakdown.modelName == "gpt-5.4") diff --git a/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift b/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift index ddc79a0160..94bb97fbac 100644 --- a/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift @@ -24,6 +24,43 @@ struct CostUsageFetcherUnknownModelPricingTests { #expect(abs((breakdown.costUSD ?? 0) - 0.00028) < 0.0000001) } + @Test + func `pricing retry preserves disabled pi session merging`() async throws { + let fixture = try UnknownModelPricingFixture() + defer { fixture.environment.cleanup() } + let piAssistant: [String: Any] = [ + "type": "message", + "timestamp": fixture.environment.isoString(for: fixture.day), + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "openai/gpt-5.4", + "timestamp": Int(fixture.day.timeIntervalSince1970 * 1000), + "usage": ["input": 50, "output": 10, "totalTokens": 60], + ], + ] + _ = try fixture.environment.writePiSessionFile( + relativePath: "2026-04-12T12-00-00-000Z_retry.jsonl", + contents: fixture.environment.jsonl([piAssistant])) + let piOptions = PiSessionCostScanner.Options( + piSessionsRoot: fixture.environment.piSessionsRoot, + cacheRoot: fixture.environment.cacheRoot, + refreshMinIntervalSeconds: 0) + + let snapshot = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: fixture.day, + refreshPricingInBackground: false, + includePiSessions: false, + scannerOptions: fixture.options, + piScannerOptions: piOptions, + modelsDevClient: ModelsDevClient(transport: CostUsageFetcherModelsDevTransport( + data: fixture.refreshedCatalog))) + + #expect(snapshot.daily.first?.totalTokens == 110) + #expect(snapshot.daily.first?.modelBreakdowns?.map(\.modelName) == ["gpt-new"]) + } + @Test func `background pricing refresh returns unpriced usage before catalog download finishes`() async throws { let fixture = try UnknownModelPricingFixture() diff --git a/Tests/CodexBarTests/CostUsageTokenSnapshotDaySelectionTests.swift b/Tests/CodexBarTests/CostUsageTokenSnapshotDaySelectionTests.swift index d587151c1e..1e3c87b8e9 100644 --- a/Tests/CodexBarTests/CostUsageTokenSnapshotDaySelectionTests.swift +++ b/Tests/CodexBarTests/CostUsageTokenSnapshotDaySelectionTests.swift @@ -85,6 +85,37 @@ struct CostUsageTokenSnapshotDaySelectionTests { #expect(snapshot.sessionTokens == 300) } + @Test + func `token snapshot distinguishes omitted and explicitly unknown currency`() { + let omitted = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: nil, + last30DaysCostUSD: nil, + daily: [], + updatedAt: Date()) + let blank = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: nil, + last30DaysCostUSD: nil, + currencyCode: " ", + daily: [], + updatedAt: Date()) + let euro = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: nil, + last30DaysCostUSD: nil, + currencyCode: " eur ", + daily: [], + updatedAt: Date()) + + #expect(omitted.currencyCode == "USD") + #expect(blank.currencyCode == "XXX") + #expect(euro.currencyCode == "EUR") + } + @Test func `latest entry ignores invalid calendar dates`() { let latest = CostUsageTokenSnapshot.latestEntry(in: [ diff --git a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift index 601024631e..33cf266ff4 100644 --- a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift +++ b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift @@ -263,6 +263,31 @@ struct LocalizationLanguageCatalogTests { #expect(Set(galician.keys) == Set(english.keys)) } + @Test + func `model breakdown unavailable exists in every app catalog`() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let resourcesURL = root.appendingPathComponent("Sources/CodexBar/Resources") + let catalogs = try FileManager.default.contentsOfDirectory( + at: resourcesURL, + includingPropertiesForKeys: nil) + .filter { $0.pathExtension == "lproj" } + + #expect(catalogs.count == 23) + for catalogURL in catalogs { + let stringsURL = catalogURL.appendingPathComponent("Localizable.strings") + let catalog = try #require(NSDictionary(contentsOf: stringsURL) as? [String: String]) + let value = try #require(catalog["Model breakdown unavailable"]) + #expect(!value.isEmpty, "\(catalogURL.lastPathComponent)") + #expect(!value.contains("%"), "\(catalogURL.lastPathComponent)") + if catalogURL.lastPathComponent == "en.lproj" { + #expect(value == "Model breakdown unavailable") + } + } + } + @Test func `catalan localization matches the English catalog`() throws { let root = URL(fileURLWithPath: #filePath) diff --git a/Tests/CodexBarTests/MenuCardCostHintTests.swift b/Tests/CodexBarTests/MenuCardCostHintTests.swift index 086e9c6336..e275a68405 100644 --- a/Tests/CodexBarTests/MenuCardCostHintTests.swift +++ b/Tests/CodexBarTests/MenuCardCostHintTests.swift @@ -93,4 +93,68 @@ struct MenuCardCostHintTests { #expect(model.tokenUsage?.monthLine.hasPrefix("Today: ") == true) } + + @Test + func `metadata free Mistral day uses billing label only for a valid bucket`() throws { + let formatter = ISO8601DateFormatter() + let now = try #require(formatter.date(from: "2026-07-16T12:00:00Z")) + let billingDay = try #require(formatter.date(from: "2026-07-10T00:00:00Z")) + let metadata = try #require(ProviderDefaults.metadata[.mistral]) + let makeModel: (CostUsageTokenSnapshot) -> UsageMenuCardView.Model = { snapshot in + UsageMenuCardView.Model.make(.init( + provider: .mistral, + metadata: metadata, + snapshot: nil, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: snapshot, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + } + let valid = CostUsageTokenSnapshot( + sessionTokens: 10, + sessionCostUSD: 1, + last30DaysTokens: 10, + last30DaysCostUSD: 1, + currencyCode: "EUR", + historyDays: 1, + daily: [.init( + date: "2026-07-10", + inputTokens: 10, + outputTokens: 0, + totalTokens: 10, + costUSD: 1, + modelsUsed: nil, + modelBreakdowns: nil)], + updatedAt: billingDay) + let invalid = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: nil, + last30DaysCostUSD: nil, + currencyCode: "EUR", + historyDays: 1, + daily: [.init( + date: "not-a-day", + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + costUSD: nil, + modelsUsed: nil, + modelBreakdowns: nil)], + updatedAt: now) + + #expect(makeModel(valid).tokenUsage?.monthLine.hasPrefix("Latest billing day: ") == true) + #expect(makeModel(invalid).tokenUsage?.monthLine.hasPrefix("Today: ") == true) + } } diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift index ee844f0275..b613095126 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -315,6 +315,9 @@ struct ProviderInlineDashboardModelTests { @Test func `mistral billing usage can show cost card summary`() throws { + let formatter = ISO8601DateFormatter() + let monthStart = try #require(formatter.date(from: "2023-11-01T00:00:00Z")) + let monthEnd = try #require(formatter.date(from: "2023-11-30T23:59:59Z")) let now = Date(timeIntervalSince1970: 1_700_179_200) let metadata = try #require(ProviderDefaults.metadata[.mistral]) let snapshot = MistralUsageSnapshot( @@ -341,8 +344,8 @@ struct ProviderInlineDashboardModelTests { outputTokens: 50), ]), ], - startDate: nil, - endDate: nil, + startDate: monthStart, + endDate: monthEnd, updatedAt: now) let model = UsageMenuCardView.Model.make(.init( diff --git a/Tests/CodexBarTests/MistralUsageParserTests.swift b/Tests/CodexBarTests/MistralUsageParserTests.swift index 3858d56cde..1ea4478260 100644 --- a/Tests/CodexBarTests/MistralUsageParserTests.swift +++ b/Tests/CodexBarTests/MistralUsageParserTests.swift @@ -154,6 +154,15 @@ struct MistralUsageParserTests { #expect(snapshot.currency == "EUR") } + @Test(arguments: ["{}", #"{"currency":" ","currency_symbol":" "}"#]) + func `missing currency stays explicitly unknown`(json: String) throws { + let snapshot = try MistralUsageFetcher.parseResponse(data: Data(json.utf8), updatedAt: Date()) + + #expect(snapshot.currency == "XXX") + #expect(snapshot.currencySymbol == "¤") + #expect(snapshot.toCostUsageTokenSnapshot().currencyCode == "XXX") + } + @Test func `parses credits response`() throws { let json = """ @@ -383,8 +392,8 @@ struct MistralUsageSnapshotConversionTests { } @Test - func `converts billing usage into cost token snapshot`() { - let now = Date(timeIntervalSince1970: 1_700_179_200) + func `requested one day trims rows totals and latest session to observed UTC day`() throws { + let now = try #require(ISO8601DateFormatter().date(from: "2023-11-15T12:00:00Z")) let snapshot = MistralUsageSnapshot( totalCost: 1.75, currency: "eur", @@ -429,21 +438,303 @@ struct MistralUsageSnapshotConversionTests { let cost = snapshot.toCostUsageTokenSnapshot(historyDays: 1) #expect(cost.currencyCode == "EUR") - #expect(cost.historyLabel == "This month") - #expect(cost.historyDays == 2) + #expect(cost.historyLabel == nil) + #expect(cost.historyDays == 1) #expect(cost.sessionCostUSD == 0.25) #expect(cost.sessionTokens == 330) - #expect(cost.last30DaysCostUSD == 1.75) - #expect(cost.last30DaysTokens == 500) - #expect(cost.daily.count == 2) - #expect(cost.daily.last?.modelsUsed == ["mistral-small"]) + #expect(cost.last30DaysCostUSD == 0.25) + #expect(cost.last30DaysTokens == 330) + #expect(cost.daily.map(\.date) == ["2023-11-15"]) + #expect(cost.daily.first?.modelsUsed == ["mistral-small"]) + } + + @Test + func `sparse daily usage reports inclusive covered day span`() throws { + let updatedAt = try #require(ISO8601DateFormatter().date(from: "2026-07-16T12:00:00Z")) + let snapshot = Self.coverageSnapshot( + dailyDays: ["2026-07-01", "2026-07-16"], + updatedAt: updatedAt) + + #expect(snapshot.toCostUsageTokenSnapshot().historyDays == 16) + let sevenDays = snapshot.toCostUsageTokenSnapshot(historyDays: 7) + #expect(sevenDays.historyDays == 1) + #expect(sevenDays.daily.map(\.date) == ["2026-07-16"]) + #expect(sevenDays.last30DaysCostUSD == 1) + #expect(sevenDays.last30DaysTokens == 1) + } + + @Test + func `metadata free coverage ends on latest valid billing bucket`() throws { + let formatter = ISO8601DateFormatter() + let updatedAt = try #require(formatter.date(from: "2026-07-16T12:00:00Z")) + let fetchDay = try #require(formatter.date(from: "2026-07-16T00:00:00Z")) + let latestBucket = try #require(formatter.date(from: "2026-07-15T00:00:00Z")) + let snapshot = Self.coverageSnapshot( + dailyDays: ["2026-07-14", "2026-07-15"], + updatedAt: updatedAt) + + let cost = snapshot.toCostUsageTokenSnapshot() + #expect(cost.historyDays == 2) + #expect(cost.updatedAt == latestBucket) + #expect(cost.last30DaysCostUSD == 2) + #expect(cost.last30DaysTokens == 2) + + let empty = Self.coverageSnapshot(dailyDays: [], updatedAt: updatedAt) + .toCostUsageTokenSnapshot() + #expect(empty.historyDays == 1) + #expect(!empty.historyCoverageIsEstablished) + #expect(empty.updatedAt == fetchDay) + #expect(empty.last30DaysCostUSD == nil) + #expect(empty.last30DaysTokens == nil) + + let invalid = MistralUsageSnapshot( + totalCost: 1, + currency: "EUR", + currencySymbol: "€", + totalInputTokens: 1, + totalOutputTokens: 0, + totalCachedTokens: 0, + modelCount: 1, + daily: [Self.bucket(day: "not-a-day")], + startDate: nil, + endDate: nil, + updatedAt: updatedAt) + .toCostUsageTokenSnapshot() + #expect(invalid.historyDays == 1) + #expect(!invalid.historyCoverageIsEstablished) + #expect(invalid.updatedAt == fetchDay) + #expect(invalid.last30DaysCostUSD == nil) + #expect(invalid.last30DaysTokens == nil) + + let outsideWindow = Self.coverageSnapshot( + dailyDays: ["2026-07-01"], + updatedAt: updatedAt) + .toCostUsageTokenSnapshot(historyDays: 7) + #expect(!outsideWindow.historyCoverageIsEstablished) + #expect(outsideWindow.daily.isEmpty) + #expect(outsideWindow.last30DaysCostUSD == nil) + #expect(outsideWindow.last30DaysTokens == nil) + } + + @Test + func `metadata coverage uses UTC dates and stops at earlier boundary`() throws { + let formatter = ISO8601DateFormatter() + let start = try #require(formatter.date(from: "2026-07-01T23:59:59Z")) + let monthEnd = try #require(formatter.date(from: "2026-07-31T23:59:59Z")) + let updatedAt = try #require(formatter.date(from: "2026-07-16T00:00:01Z")) + let secondDay = try #require(formatter.date(from: "2026-07-02T00:00:01Z")) + let longRangeStart = try #require(formatter.date(from: "2020-01-01T00:00:00Z")) + + let currentMonth = Self.coverageSnapshot( + dailyDays: ["2026-07-16"], + startDate: start, + endDate: monthEnd, + updatedAt: updatedAt) + let endedRange = Self.coverageSnapshot( + dailyDays: ["2026-07-01", "2026-07-02"], + startDate: start, + endDate: secondDay, + updatedAt: updatedAt) + let longRange = Self.coverageSnapshot( + dailyDays: [], + startDate: longRangeStart, + endDate: monthEnd, + updatedAt: updatedAt) + + #expect(currentMonth.toCostUsageTokenSnapshot().historyDays == 16) + #expect(currentMonth.toCostUsageTokenSnapshot().historyLabel == "This month") + let endedCost = endedRange.toCostUsageTokenSnapshot() + #expect(endedCost.historyDays == 2) + #expect(endedCost.historyLabel == nil) + #expect(endedCost.updatedAt == formatter.date(from: "2026-07-02T00:00:00Z")) + #expect(endedRange.toUsageSnapshot().updatedAt == updatedAt) + #expect(longRange.toCostUsageTokenSnapshot(historyDays: 900).historyDays == 365) + } + + @Test + func `metadata preserves empty covered days while excluding rows before requested window`() throws { + let formatter = ISO8601DateFormatter() + let start = try #require(formatter.date(from: "2026-07-01T00:00:00Z")) + let end = try #require(formatter.date(from: "2026-07-31T23:59:59Z")) + let updatedAt = try #require(formatter.date(from: "2026-07-16T12:00:00Z")) + let snapshot = Self.coverageSnapshot( + dailyDays: ["2026-07-01", "2026-07-10", "2026-07-16"], + startDate: start, + endDate: end, + updatedAt: updatedAt) + + let cost = snapshot.toCostUsageTokenSnapshot(historyDays: 7) + #expect(cost.historyDays == 7) + #expect(cost.historyLabel == nil) + #expect(cost.daily.map(\.date) == ["2026-07-10", "2026-07-16"]) + #expect(cost.last30DaysCostUSD == 2) + #expect(cost.last30DaysTokens == 2) + } + + @Test + func `empty current month still reports metadata coverage`() throws { + let formatter = ISO8601DateFormatter() + let start = try #require(formatter.date(from: "2026-07-01T00:00:00Z")) + let end = try #require(formatter.date(from: "2026-07-31T23:59:59Z")) + let updatedAt = try #require(formatter.date(from: "2026-07-02T12:00:00Z")) + let snapshot = Self.coverageSnapshot( + dailyDays: [], + startDate: start, + endDate: end, + updatedAt: updatedAt) + + #expect(snapshot.toCostUsageTokenSnapshot().historyDays == 2) + #expect(snapshot.toCostUsageTokenSnapshot().historyLabel == "This month") + } + + @Test(arguments: [ + "not-a-day", + "2026-07-01junk", + "2026-07-01", + "2026-02-30", + " 2026-07-01", + ]) + func `invalid coverage provenance fails closed after requested clamp`(day: String) { + let snapshot = Self.coverageSnapshot( + dailyDays: [day], + updatedAt: Date()) + + #expect(snapshot.toCostUsageTokenSnapshot(historyDays: 900).historyDays == 1) + } + + @Test + func `malformed nonzero row keeps requested window unavailable`() throws { + let updatedAt = try #require(ISO8601DateFormatter().date(from: "2026-07-16T12:00:00Z")) + let snapshot = Self.coverageSnapshot( + dailyDays: ["2026-07-01junk", "2026-07-16"], + updatedAt: updatedAt) + + let cost = snapshot.toCostUsageTokenSnapshot(historyDays: 1) + #expect(cost.historyDays == 1) + #expect(cost.daily.map(\.date) == ["2026-07-01junk", "2026-07-16"]) + #expect(cost.daily.allSatisfy { $0.costUSD == nil && $0.totalTokens == nil }) + #expect(cost.sessionCostUSD == nil) + #expect(cost.sessionTokens == nil) + #expect(cost.last30DaysCostUSD == nil) + #expect(cost.last30DaysTokens == nil) + } + + @Test + func `negative aggregate token counter fails closed despite equal signed net`() { + let snapshot = Self.tokenValidationSnapshot( + totalInputTokens: -5, + totalOutputTokens: 15, + daily: [Self.tokenBucket(day: "2026-07-16", inputTokens: 10)]) + + Self.expectTokenDataUnavailable(snapshot.toCostUsageTokenSnapshot()) + } + + @Test + func `negative daily token counter fails closed despite equal signed net`() { + let snapshot = Self.tokenValidationSnapshot( + totalInputTokens: 10, + daily: [Self.tokenBucket(day: "2026-07-16", inputTokens: 15, cachedTokens: -5)]) + + Self.expectTokenDataUnavailable(snapshot.toCostUsageTokenSnapshot()) + } + + @Test + func `negative model token counter fails closed despite equal signed net`() { + let snapshot = Self.tokenValidationSnapshot( + totalInputTokens: 10, + daily: [ + Self.tokenBucket( + day: "2026-07-16", + inputTokens: 10, + modelInputTokens: 15, + modelOutputTokens: -5), + ]) + + Self.expectTokenDataUnavailable(snapshot.toCostUsageTokenSnapshot()) } @Test - func `clamps negative billing adjustments in cost token snapshot`() { + func `zero and positive token counters remain complete`() { + let snapshot = Self.tokenValidationSnapshot( + totalInputTokens: 4, + totalCachedTokens: 2, + totalOutputTokens: 4, + daily: [ + Self.tokenBucket(day: "2026-07-15", inputTokens: 0), + Self.tokenBucket(day: "2026-07-16", inputTokens: 4, cachedTokens: 2, outputTokens: 4), + ]) + + let cost = snapshot.toCostUsageTokenSnapshot() + #expect(cost.last30DaysTokens == 10) + #expect(cost.sessionTokens == 10) + #expect(cost.daily.map(\.totalTokens) == [0, 10]) + #expect(cost.daily.map { $0.modelBreakdowns?.first?.totalTokens } == [0, 10]) + #expect(cost.last30DaysCostUSD == 2) + } + + @Test + func `negative excluded cost bucket cannot prove selected empty window is zero`() throws { + let updatedAt = try #require(ISO8601DateFormatter().date(from: "2026-07-16T12:00:00Z")) + let snapshot = Self.costValidationSnapshot( + totalCost: 10, + daily: [ + Self.costBucket(day: "2026-07-14", cost: -5), + Self.costBucket(day: "2026-07-15", cost: 15), + ], + updatedAt: updatedAt) + + let cost = snapshot.toCostUsageTokenSnapshot(historyDays: 1) + #expect(cost.daily.isEmpty) + #expect(!cost.historyCoverageIsEstablished) + #expect(cost.last30DaysCostUSD == nil) + #expect(cost.sessionCostUSD == nil) + #expect(cost.last30DaysTokens == nil) + } + + @Test + func `negative model cost invalidates cost proof while preserving valid tokens`() { + let snapshot = Self.costValidationSnapshot( + totalCost: 1, + totalInputTokens: 10, + daily: [ + Self.costBucket( + day: "2026-07-16", + cost: 1, + modelCosts: [-1, 2], + tokens: 10), + ]) + + let cost = snapshot.toCostUsageTokenSnapshot() + #expect(cost.last30DaysCostUSD == nil) + #expect(cost.sessionCostUSD == nil) + #expect(cost.daily.first?.costUSD == nil) + #expect(cost.daily.first?.modelBreakdowns?.allSatisfy { $0.costUSD == nil } == true) + #expect(cost.last30DaysTokens == 10) + #expect(cost.sessionTokens == 10) + } + + @Test + func `zero and positive costs remain complete`() { + let snapshot = Self.costValidationSnapshot( + totalCost: 2, + daily: [ + Self.costBucket(day: "2026-07-15", cost: 0), + Self.costBucket(day: "2026-07-16", cost: 2), + ]) + + let cost = snapshot.toCostUsageTokenSnapshot() + #expect(cost.last30DaysCostUSD == 2) + #expect(cost.sessionCostUSD == 2) + #expect(cost.daily.map(\.costUSD) == [0, 2]) + #expect(cost.daily.map { $0.modelBreakdowns?.first?.costUSD } == [0, 2]) + #expect(cost.last30DaysTokens == 0) + } + + @Test + func `negative billing adjustment fails closed in cost token snapshot`() { let now = Date(timeIntervalSince1970: 1_700_179_200) let snapshot = MistralUsageSnapshot( - totalCost: -2, + totalCost: -1.5, currency: "EUR", currencySymbol: "€", totalInputTokens: 100, @@ -471,14 +762,17 @@ struct MistralUsageSnapshotConversionTests { updatedAt: now) let cost = snapshot.toCostUsageTokenSnapshot() - #expect(cost.sessionCostUSD == 0) - #expect(cost.last30DaysCostUSD == 0) - #expect(cost.daily.first?.costUSD == 0) - #expect(cost.daily.first?.modelBreakdowns?.first?.costUSD == 0) + #expect(cost.sessionCostUSD == nil) + #expect(cost.last30DaysCostUSD == nil) + #expect(cost.daily.first?.costUSD == nil) + #expect(cost.daily.first?.modelBreakdowns?.first?.costUSD == nil) + #expect(cost.last30DaysTokens == 125) + #expect(cost.sessionTokens == 125) + #expect(snapshot.toUsageSnapshot().identity?.loginMethod == "API spend: €0.0000 this month") } @Test - func `preserves net monthly cost when billing includes credits`() { + func `credit adjusted window fails closed without changing primary monthly spend`() { let now = Date(timeIntervalSince1970: 1_700_179_200) let snapshot = MistralUsageSnapshot( totalCost: 8, @@ -509,9 +803,141 @@ struct MistralUsageSnapshotConversionTests { updatedAt: now) let cost = snapshot.toCostUsageTokenSnapshot() - #expect(cost.last30DaysCostUSD == 8) - #expect(cost.sessionCostUSD == 0) - #expect(cost.daily.map(\.costUSD) == [10, 0]) + #expect(cost.last30DaysCostUSD == nil) + #expect(cost.sessionCostUSD == nil) + #expect(cost.daily.map(\.costUSD) == [nil, nil]) + #expect(snapshot.toUsageSnapshot().identity?.loginMethod == "API spend: €8.0000 this month") + } + + private static func bucket(day: String) -> MistralDailyUsageBucket { + MistralDailyUsageBucket( + day: day, + cost: 1, + inputTokens: 1, + cachedTokens: 0, + outputTokens: 0, + models: []) + } + + private static func tokenBucket( + day: String, + inputTokens: Int, + cachedTokens: Int = 0, + outputTokens: Int = 0, + modelInputTokens: Int? = nil, + modelCachedTokens: Int? = nil, + modelOutputTokens: Int? = nil) -> MistralDailyUsageBucket + { + MistralDailyUsageBucket( + day: day, + cost: 1, + inputTokens: inputTokens, + cachedTokens: cachedTokens, + outputTokens: outputTokens, + models: [ + .init( + name: "test-model", + cost: 1, + inputTokens: modelInputTokens ?? inputTokens, + cachedTokens: modelCachedTokens ?? cachedTokens, + outputTokens: modelOutputTokens ?? outputTokens), + ]) + } + + private static func costBucket( + day: String, + cost: Double, + modelCosts: [Double]? = nil, + tokens: Int = 0) -> MistralDailyUsageBucket + { + let costs = modelCosts ?? [cost] + return MistralDailyUsageBucket( + day: day, + cost: cost, + inputTokens: tokens, + cachedTokens: 0, + outputTokens: 0, + models: costs.enumerated().map { index, modelCost in + .init( + name: "test-model-\(index)", + cost: modelCost, + inputTokens: index == 0 ? tokens : 0, + cachedTokens: 0, + outputTokens: 0) + }) + } + + private static func costValidationSnapshot( + totalCost: Double, + totalInputTokens: Int = 0, + daily: [MistralDailyUsageBucket], + updatedAt: Date = Date(timeIntervalSince1970: 1_784_179_200)) -> MistralUsageSnapshot + { + MistralUsageSnapshot( + totalCost: totalCost, + currency: "EUR", + currencySymbol: "€", + totalInputTokens: totalInputTokens, + totalOutputTokens: 0, + totalCachedTokens: 0, + modelCount: daily.flatMap(\.models).count, + daily: daily, + startDate: nil, + endDate: nil, + updatedAt: updatedAt) + } + + private static func tokenValidationSnapshot( + totalInputTokens: Int, + totalCachedTokens: Int = 0, + totalOutputTokens: Int = 0, + daily: [MistralDailyUsageBucket]) -> MistralUsageSnapshot + { + MistralUsageSnapshot( + totalCost: Double(daily.count), + currency: "EUR", + currencySymbol: "€", + totalInputTokens: totalInputTokens, + totalOutputTokens: totalOutputTokens, + totalCachedTokens: totalCachedTokens, + modelCount: 1, + daily: daily, + startDate: nil, + endDate: nil, + updatedAt: Date(timeIntervalSince1970: 1_784_179_200)) + } + + private static func expectTokenDataUnavailable(_ snapshot: CostUsageTokenSnapshot) { + #expect(snapshot.last30DaysTokens == nil) + #expect(snapshot.sessionTokens == nil) + #expect(snapshot.daily.allSatisfy { + $0.inputTokens == nil + && $0.cacheReadTokens == nil + && $0.outputTokens == nil + && $0.totalTokens == nil + && $0.modelBreakdowns?.allSatisfy { $0.totalTokens == nil } == true + }) + #expect(snapshot.last30DaysCostUSD == 1) + } + + private static func coverageSnapshot( + dailyDays: [String], + startDate: Date? = nil, + endDate: Date? = nil, + updatedAt: Date) -> MistralUsageSnapshot + { + MistralUsageSnapshot( + totalCost: Double(dailyDays.count), + currency: "EUR", + currencySymbol: "€", + totalInputTokens: dailyDays.count, + totalOutputTokens: 0, + totalCachedTokens: 0, + modelCount: dailyDays.isEmpty ? 0 : 1, + daily: dailyDays.map(self.bucket(day:)), + startDate: startDate, + endDate: endDate, + updatedAt: updatedAt) } } diff --git a/Tests/CodexBarTests/PreferencesSelectionTests.swift b/Tests/CodexBarTests/PreferencesSelectionTests.swift index d58f14814f..f02b44c9f6 100644 --- a/Tests/CodexBarTests/PreferencesSelectionTests.swift +++ b/Tests/CodexBarTests/PreferencesSelectionTests.swift @@ -9,6 +9,7 @@ struct PreferencesSelectionTests { func `pane persistence tokens round-trip`() { let panes: [SettingsPane] = [ .general, + .usageSpend, .notifications, .menuBar, .menu, diff --git a/Tests/CodexBarTests/SpendDashboardClockRolloverTests.swift b/Tests/CodexBarTests/SpendDashboardClockRolloverTests.swift new file mode 100644 index 0000000000..b609ad751c --- /dev/null +++ b/Tests/CodexBarTests/SpendDashboardClockRolloverTests.swift @@ -0,0 +1,161 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct SpendDashboardClockRolloverTests { + @Test + func `reporting window advances and rescans source inputs`() async throws { + let loadedAt = try #require(ISO8601DateFormatter().date(from: "2026-07-16T12:00:00Z")) + let afterRollover = try #require(ISO8601DateFormatter().date(from: "2026-07-22T12:00:00Z")) + let loadCount = LockIsolated(0) + let clock = LockIsolated(loadedAt) + let configuration = Self.configuration + let initialInput = Self.input(day: "2026-07-15", cost: 4, updatedAt: loadedAt) + let rolloverInput = Self.input(day: "2026-07-22", cost: 6, updatedAt: afterRollover) + let controller = SpendDashboardController( + requestBuilder: { mode in + SpendDashboardLoadRequest( + configuration: configuration, + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [], + now: clock.value, + force: mode.forcesLoader) + }, + loader: { _ in + let count = loadCount.value + 1 + loadCount.setValue(count) + return SpendDashboardLoadResult( + inputs: [count == 1 ? initialInput : rolloverInput], + failedSourceIDs: []) + }, + nowProvider: { clock.value }) + + controller.update(configuration: configuration) + await Self.waitUntil { !controller.isRefreshing } + controller.selectDays(7) + #expect(controller.model.groups.first?.totalCost == 4) + let generation = controller.generation + + clock.setValue(afterRollover) + controller.refreshDateWindow() + await Self.waitUntil { !controller.isRefreshing } + + #expect(controller.generation == generation + 1) + #expect(loadCount.value == 2) + #expect(controller.model.groups.first?.totalCost == 6) + #expect(controller.model.groups.first?.dailyPoints.count == 1) + } + + @Test + func `rollover replaces an in flight load instead of dropping the rescan`() async throws { + let loadedAt = try #require(ISO8601DateFormatter().date(from: "2026-07-16T12:00:00Z")) + let afterRollover = try #require(ISO8601DateFormatter().date(from: "2026-07-22T12:00:00Z")) + let clock = LockIsolated(loadedAt) + let configuration = Self.configuration + let staleInput = Self.input(day: "2026-07-15", cost: 4, updatedAt: loadedAt) + let freshInput = Self.input(day: "2026-07-22", cost: 6, updatedAt: afterRollover) + let gate = SpendDashboardRolloverGate() + let controller = SpendDashboardController( + requestBuilder: { mode in + SpendDashboardLoadRequest( + configuration: configuration, + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [], + now: clock.value, + force: mode.forcesLoader) + }, + loader: { request in + await gate.load(request) + }, + nowProvider: { clock.value }) + + controller.update(configuration: configuration) + await Self.waitForPendingCount(1, gate: gate) + + clock.setValue(afterRollover) + controller.refreshDateWindow() + await Self.waitForPendingCount(2, gate: gate) + + await gate.resume(at: 0, result: .init(inputs: [staleInput], failedSourceIDs: [])) + await gate.resume(at: 1, result: .init(inputs: [freshInput], failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + + #expect(controller.generation == 2) + #expect(controller.model.groups.first?.totalCost == 6) + #expect(controller.model.groups.first?.dailyPoints.count == 1) + } + + private static let configuration = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.codex.rawValue], + codexAccountIdentities: ["rollover"]) + + private static func input(day: String, cost: Double, updatedAt: Date) -> SpendDashboardModel.ProviderInput { + let entry = CostUsageDailyReport.Entry( + date: day, + inputTokens: nil, + outputTokens: nil, + totalTokens: 10, + costUSD: cost, + modelsUsed: nil, + modelBreakdowns: nil) + let snapshot = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: 10, + last30DaysCostUSD: cost, + daily: [entry], + updatedAt: updatedAt) + return SpendDashboardModel.ProviderInput( + provider: .codex, + displayName: "Codex", + snapshot: snapshot) + } + + private static func waitUntil(_ condition: @MainActor () -> Bool) async { + for _ in 0..<1000 { + if condition() { + return + } + await Task.yield() + } + Issue.record("Timed out waiting for controller state") + } + + private static func waitForPendingCount(_ count: Int, gate: SpendDashboardRolloverGate) async { + for _ in 0..<1000 { + if await gate.pendingCount == count { + return + } + await Task.yield() + } + Issue.record("Timed out waiting for \(count) pending loads") + } +} + +private actor SpendDashboardRolloverGate { + private struct Pending { + let continuation: CheckedContinuation + } + + private var pending: [Pending] = [] + + var pendingCount: Int { + self.pending.count + } + + func load(_ request: SpendDashboardLoadRequest) async -> SpendDashboardLoadResult { + _ = request + return await withCheckedContinuation { continuation in + self.pending.append(Pending(continuation: continuation)) + } + } + + func resume(at index: Int, result: SpendDashboardLoadResult) { + self.pending[index].continuation.resume(returning: result) + } +} diff --git a/Tests/CodexBarTests/SpendDashboardControllerTests.swift b/Tests/CodexBarTests/SpendDashboardControllerTests.swift new file mode 100644 index 0000000000..17e398ce42 --- /dev/null +++ b/Tests/CodexBarTests/SpendDashboardControllerTests.swift @@ -0,0 +1,1261 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct SpendDashboardControllerTests { + @Test + func `empty codex history loads as successful inactive source`() async { + let now = Date(timeIntervalSince1970: 1_784_179_200) + let recorder = SpendDashboardCodexLoadRecorder() + let account = CodexSpendScanRequest( + id: "inactive", + displayName: "Codex", + source: .profileHome(path: "/synthetic/codex-home"), + homePath: "/synthetic/codex-home", + authFingerprint: nil, + authFileWasReadable: false, + cacheIdentity: "inactive-cache") + let request = SpendDashboardLoadRequest( + configuration: Self.configuration(account: "inactive|inactive-cache"), + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [account], + now: now, + force: false) + + let result = await SpendDashboardSource.load(request, codexSnapshotLoader: { context in + await recorder.record(context) + return CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: 0, + last30DaysCostUSD: 0, + historyDays: context.historyDays, + daily: [], + updatedAt: context.now) + }) + let contexts = await recorder.contexts + + #expect(result.inputs.count == 1) + #expect(result.inputs.first?.id == "codex:inactive") + #expect(result.inputs.first?.snapshot.daily.isEmpty == true) + #expect(result.failedSourceIDs.isEmpty) + #expect(contexts.count == 1) + #expect(contexts.first?.account == account) + #expect(contexts.first?.cacheRoot.lastPathComponent == "inactive-cache") + #expect(contexts.first?.now == now) + #expect(contexts.first?.force == false) + #expect(contexts.first?.historyDays == 30) + #expect(contexts.first?.refreshPricingInBackground == false) + #expect(contexts.first?.includePiSessions == false) + } + + @Test + func `Codex auth rotation invalidates stale spend while retaining unrelated providers`() async throws { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent( + "SpendDashboardControllerTests-auth-rotation-\(UUID().uuidString)", + isDirectory: true) + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: home) } + let authURL = CodexAuthFingerprint.authFileURL(homePath: home.path) + let originalAuth = Data("{\"profile\":\"owner-one\"}".utf8) + try originalAuth.write(to: authURL, options: .atomic) + let account = CodexSpendScanRequest( + id: "account", + displayName: "Codex", + source: .profileHome(path: home.path), + homePath: home.path, + authFingerprint: CodexAuthFingerprint.fingerprint(data: originalAuth), + authFileWasReadable: true, + cacheIdentity: "auth-rotation") + let gate = SpendDashboardCodexSnapshotGate() + let recorder = SpendDashboardLoadResultRecorder() + let configuration = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.codex.rawValue, UsageProvider.openai.rawValue], + codexAccountIdentities: ["account|auth-rotation"], + codexAccountDisplayNames: ["codex:account": "Codex"], + sourceOwnershipFingerprints: ["openai:stable"]) + let controller = SpendDashboardController( + requestBuilder: { mode in + SpendDashboardLoadRequest( + configuration: configuration, + capturedInputs: [Self.input(id: "openai", provider: .openai, cost: 2)], + unavailableSourceIDs: [], + codexRequests: [account], + now: Date(timeIntervalSince1970: 1_784_179_200), + force: mode.forcesLoader) + }, + loader: { request in + let result = await SpendDashboardSource.load(request, codexSnapshotLoader: { context in + await gate.load(context) + }) + await recorder.record(result) + return result + }) + + controller.update(configuration: configuration) + await Self.waitForCodexPendingCount(1, gate: gate) + await gate.resume(at: 0, snapshot: Self.input(cost: 6).snapshot) + await Self.waitUntil { !controller.isRefreshing } + #expect(controller.model.groups.first?.totalCost == 8) + + controller.refresh() + await Self.waitForCodexPendingCount(1, gate: gate) + let replacementAuth = Data("{\"profile\":\"owner-two\"}".utf8) + try replacementAuth.write(to: authURL, options: .atomic) + await gate.resume(at: 0, snapshot: Self.input(cost: 99).snapshot) + await Self.waitUntil { !controller.isRefreshing } + + let results = await recorder.results + #expect(results.last?.invalidatedSourceIDs == ["codex:account"]) + #expect(results.last?.failedSourceIDs == ["codex:account"]) + #expect(controller.failedSourceCount == 1) + #expect(controller.model.groups.first?.totalCost == 2) + #expect(Set(controller.model.groups.flatMap(\.providers).map(\.id)) == ["openai"]) + } + + @Test + func `replacement generation rejects stale completion`() async { + let gate = SpendDashboardLoaderGate() + let controller = Self.controller(gate: gate) + let firstConfiguration = Self.configuration(account: "first") + let secondConfiguration = Self.configuration(account: "second") + + controller.update(configuration: firstConfiguration) + await Self.waitForPendingCount(1, gate: gate) + controller.update(configuration: secondConfiguration) + await Self.waitForPendingCount(2, gate: gate) + + await gate.resume(at: 1, result: .init(inputs: [Self.input(cost: 2)], failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + #expect(controller.model.groups.first?.totalCost == 2) + #expect(controller.generation == 2) + + await gate.resume(at: 0, result: .init(inputs: [Self.input(cost: 1)], failedSourceIDs: [])) + await Task.yield() + #expect(controller.model.groups.first?.totalCost == 2) + } + + @Test + func `failed same configuration refresh retains last good model`() async { + let gate = SpendDashboardLoaderGate() + let controller = Self.controller(gate: gate) + let configuration = Self.configuration(account: "same") + + controller.update(configuration: configuration) + await Self.waitForPendingCount(1, gate: gate) + await gate.resume(at: 0, result: .init( + inputs: [ + Self.input(cost: 7), + Self.input(id: "claude", provider: .claude, cost: 3), + ], + failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + #expect(controller.model.groups.first?.totalCost == 10) + + controller.refresh() + await Self.waitForPendingCount(1, gate: gate) + await gate.resume(at: 0, result: .init(inputs: [Self.input(cost: 8)], failedSourceIDs: ["claude"])) + await Self.waitUntil { !controller.isRefreshing } + #expect(controller.model.groups.first?.totalCost == 11) + #expect(controller.model.groups.first?.providers.count == 2) + #expect(controller.failedSourceCount == 1) + } + + @Test + func `refresh retains only sources that actually failed`() async { + let gate = SpendDashboardLoaderGate() + let controller = Self.controller(gate: gate) + let configuration = Self.configuration(account: "same") + + controller.update(configuration: configuration) + await Self.waitForPendingCount(1, gate: gate) + await gate.resume(at: 0, result: .init( + inputs: [ + Self.input(cost: 7), + Self.input(id: "claude", provider: .claude, cost: 3), + Self.input(id: "openai", provider: .openai, cost: 2), + ], + failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + + controller.refresh() + await Self.waitForPendingCount(1, gate: gate) + await gate.resume(at: 0, result: .init( + inputs: [Self.input(cost: 8)], + failedSourceIDs: ["claude"])) + await Self.waitUntil { !controller.isRefreshing } + + let providerIDs = Set(controller.model.groups.flatMap(\.providers).map(\.id)) + #expect(providerIDs == ["codex", "claude"]) + #expect(controller.model.groups.first?.totalCost == 11) + } + + @Test + func `changed data revision retains failed source with same ownership`() async { + let gate = SpendDashboardLoaderGate() + let controller = Self.controller(gate: gate) + + controller.update(configuration: Self.configuration(account: "same", revision: "first")) + await Self.waitForPendingCount(1, gate: gate) + await gate.resume(at: 0, result: .init( + inputs: [ + Self.input(cost: 7), + Self.input(id: "claude", provider: .claude, cost: 3), + ], + failedSourceIDs: ["openai"])) + await Self.waitUntil { !controller.isRefreshing } + #expect(controller.failedSourceCount == 1) + + controller.update(configuration: Self.configuration(account: "same", revision: "second")) + await Self.waitForPendingCount(1, gate: gate) + #expect(controller.isRefreshing) + #expect(controller.model.groups.first?.totalCost == 10) + #expect(controller.failedSourceCount == 1) + await gate.resume(at: 0, result: .init( + inputs: [Self.input(cost: 8)], + failedSourceIDs: ["claude"])) + await Self.waitUntil { !controller.isRefreshing } + + #expect(controller.model.groups.first?.totalCost == 11) + #expect(Set(controller.model.groups.flatMap(\.providers).map(\.id)) == ["codex", "claude"]) + } + + @Test + func `snapshot spend replacement with unchanged metadata triggers reload`() async { + let gate = SpendDashboardLoaderGate() + let controller = Self.controller(gate: gate) + let firstInput = Self.input(provider: .claude, cost: 3) + let replacementInput = Self.input(provider: .claude, cost: 8) + let settings = testSettingsStore(suiteName: "SpendDashboardControllerTests-snapshot-replacement") + settings.costUsageEnabled = true + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .claude) + } + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + + store._setTokenSnapshotForTesting(firstInput.snapshot, provider: .claude) + let firstConfiguration = SpendDashboardSource.configuration(settings: settings, store: store) + store._setTokenSnapshotForTesting(replacementInput.snapshot, provider: .claude) + let replacementConfiguration = SpendDashboardSource.configuration(settings: settings, store: store) + + #expect(firstInput.snapshot.daily.count == replacementInput.snapshot.daily.count) + #expect(firstInput.snapshot.updatedAt == replacementInput.snapshot.updatedAt) + #expect(firstInput.snapshot.historyDays == replacementInput.snapshot.historyDays) + #expect(firstConfiguration.providerIDs == [UsageProvider.claude.rawValue]) + #expect(firstConfiguration.sourceRevisions != replacementConfiguration.sourceRevisions) + + controller.update(configuration: firstConfiguration) + await Self.waitForPendingCount(1, gate: gate) + await gate.resume(at: 0, result: .init(inputs: [firstInput], failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + #expect(controller.model.groups.first?.totalCost == 3) + + controller.update(configuration: replacementConfiguration) + await Self.waitForPendingCount(1, gate: gate) + await gate.resume(at: 0, result: .init(inputs: [replacementInput], failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + + #expect(controller.generation == 2) + #expect(controller.model.groups.first?.totalCost == 8) + } + + @Test + func `identical successful republication reloads and clears retained failure warning`() async { + let settings = testSettingsStore( + suiteName: "SpendDashboardControllerTests-identical-republication") + settings.costUsageEnabled = true + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .claude) + } + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + let snapshot = Self.input(id: "claude", provider: .claude, cost: 3).snapshot + store._setTokenSnapshotForTesting(snapshot, provider: .claude) + store._test_tokenUsageRefreshOverride = { _, _ in } + let controller = SpendDashboardController(requestBuilder: { mode in + await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) + }) + + let baselineConfiguration = SpendDashboardSource.configuration(settings: settings, store: store) + controller.update(configuration: baselineConfiguration) + await Self.waitUntil { !controller.isRefreshing } + #expect(controller.model.groups.first?.totalCost == 3) + + controller.refresh() + await Self.waitUntil { !controller.isRefreshing } + #expect(controller.model.groups.first?.totalCost == 3) + #expect(controller.failedSourceCount == 1) + + store._setTokenSnapshotForTesting(snapshot, provider: .claude) + let replacementConfiguration = SpendDashboardSource.configuration(settings: settings, store: store) + #expect(replacementConfiguration.sourceRevisions != baselineConfiguration.sourceRevisions) + controller.update(configuration: replacementConfiguration) + await Self.waitUntil { !controller.isRefreshing } + + #expect(controller.generation == 4) + #expect(controller.model.groups.first?.totalCost == 3) + #expect(controller.failedSourceCount == 0) + + let settledGeneration = controller.generation + controller.update(configuration: replacementConfiguration) + await Task.yield() + #expect(controller.generation == settledGeneration) + } + + @Test + func `capture request distinguishes confirmed empty provider from unavailable provider`() async { + let settings = testSettingsStore( + suiteName: "SpendDashboardControllerTests-confirmed-empty-capture") + settings.costUsageEnabled = true + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .claude) + } + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + store.publishConfirmedEmptyTokenSnapshot(for: .claude) + + let request = await SpendDashboardSource.makeRequest( + settings: settings, + store: store, + mode: .captureOnly) + + #expect(request.capturedInputs.isEmpty) + #expect(request.unavailableSourceIDs.isEmpty) + #expect(request.confirmedEmptySourceIDs == [UsageProvider.claude.rawValue]) + } + + @Test + func `changed provider ownership drops only stale source and retains unchanged failures`() async { + let gate = SpendDashboardLoaderGate() + let controller = Self.controller(gate: gate) + let firstConfiguration = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: ["codex", "claude", "openai"], + codexAccountIdentities: ["same|cache"], + sourceOwnershipFingerprints: ["claude:owner-one", "openai:owner"], + sourceRevisions: ["first"]) + let replacementConfiguration = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: ["codex", "claude", "openai"], + codexAccountIdentities: ["same|cache"], + sourceOwnershipFingerprints: ["claude:owner-two", "openai:owner"], + sourceRevisions: ["second"]) + + controller.update(configuration: firstConfiguration) + await Self.waitForPendingCount(1, gate: gate) + await gate.resume(at: 0, result: .init( + inputs: [ + Self.input(cost: 7), + Self.input(id: "claude", provider: .claude, cost: 3), + Self.input(id: "openai", provider: .openai, cost: 2), + ], + failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + + controller.update(configuration: replacementConfiguration) + await Self.waitForPendingCount(1, gate: gate) + #expect(controller.isRefreshing) + #expect(controller.model.groups.first?.totalCost == 9) + #expect(Set(controller.model.groups.flatMap(\.providers).map(\.id)) == ["codex", "openai"]) + #expect(controller.failedSourceCount == 0) + await gate.resume(at: 0, result: .init( + inputs: [Self.input(cost: 8)], + failedSourceIDs: ["claude", "openai"])) + await Self.waitUntil { !controller.isRefreshing } + + #expect(controller.model.groups.first?.totalCost == 10) + #expect(Set(controller.model.groups.flatMap(\.providers).map(\.id)) == ["codex", "openai"]) + #expect(controller.failedSourceCount == 2) + } + + @Test + func `changed provider ownership requires a confirmed fresh store snapshot`() async { + let settings = testSettingsStore(suiteName: "SpendDashboardControllerTests-owner-freshness") + settings.costUsageEnabled = true + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .claude) + } + settings.updateProviderConfig(provider: .claude) { config in + config.enterpriseHost = "owner-one.invalid" + } + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + store._setTokenSnapshotForTesting(Self.input(provider: .claude, cost: 3).snapshot, provider: .claude) + store._test_tokenUsageRefreshOverride = { _, _ in } + let controller = SpendDashboardController(requestBuilder: { mode in + await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) + }) + + let firstConfiguration = SpendDashboardSource.configuration(settings: settings, store: store) + controller.update(configuration: firstConfiguration) + await Self.waitUntil { !controller.isRefreshing } + #expect(controller.model.groups.first?.totalCost == 3) + + settings.updateProviderConfig(provider: .claude) { config in + config.enterpriseHost = "owner-two.invalid" + } + let replacementConfiguration = SpendDashboardSource.configuration(settings: settings, store: store) + #expect(firstConfiguration.sourceOwnershipFingerprints != replacementConfiguration.sourceOwnershipFingerprints) + + controller.update(configuration: replacementConfiguration) + #expect(controller.model.groups.isEmpty) + await Self.waitUntil { !controller.isRefreshing } + + #expect(controller.model.groups.isEmpty) + #expect(controller.failedSourceCount == 1) + #expect(store.tokenSnapshot(for: .claude)?.last30DaysCostUSD == 3) + + let reopenedController = SpendDashboardController(requestBuilder: { mode in + await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) + }) + reopenedController.update(configuration: replacementConfiguration) + await Self.waitUntil { !reopenedController.isRefreshing } + #expect(reopenedController.model.groups.isEmpty) + #expect(reopenedController.failedSourceCount == 1) + + let identicalSnapshot = store.tokenSnapshot(for: .claude) + store._test_tokenUsageRefreshOverride = { provider, _ in + guard provider == .claude, let identicalSnapshot else { return } + store._setTokenSnapshotForTesting(identicalSnapshot, provider: provider) + } + settings.updateProviderConfig(provider: .claude) { config in + config.enterpriseHost = "owner-three.invalid" + } + let thirdConfiguration = SpendDashboardSource.configuration(settings: settings, store: store) + reopenedController.update(configuration: thirdConfiguration) + await Self.waitUntil { !reopenedController.isRefreshing } + #expect(reopenedController.model.groups.first?.totalCost == 3) + #expect(reopenedController.failedSourceCount == 0) + } + + @Test + func `selected token account ownership ignores inactive edits and drops failed replacement`() async throws { + let settings = testSettingsStore(suiteName: "SpendDashboardControllerTests-token-account-owner") + settings.costUsageEnabled = true + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .mistral) + } + settings.addTokenAccount(provider: .mistral, label: "Primary", token: UUID().uuidString) + settings.addTokenAccount(provider: .mistral, label: "Backup", token: UUID().uuidString) + settings.setActiveTokenAccountIndex(0, for: .mistral) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + + let primaryConfiguration = SpendDashboardSource.configuration(settings: settings, store: store) + let accounts = settings.tokenAccounts(for: .mistral) + let backup = try #require(accounts.last) + settings.updateTokenAccount(provider: .mistral, accountID: backup.id, label: "Renamed backup") + let inactiveEditConfiguration = SpendDashboardSource.configuration(settings: settings, store: store) + #expect(primaryConfiguration.sourceOwnershipFingerprints == inactiveEditConfiguration + .sourceOwnershipFingerprints) + + settings.setActiveTokenAccountIndex(1, for: .mistral) + let selectedBackupConfiguration = SpendDashboardSource.configuration(settings: settings, store: store) + #expect(inactiveEditConfiguration.sourceOwnershipFingerprints != selectedBackupConfiguration + .sourceOwnershipFingerprints) + + store._setTokenSnapshotForTesting(Self.input(provider: .mistral, cost: 3).snapshot, provider: .mistral) + store._test_providerRefreshOverride = { _ in } + let controller = SpendDashboardController(requestBuilder: { mode in + await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) + }) + controller.update(configuration: selectedBackupConfiguration) + await Self.waitUntil { !controller.isRefreshing } + #expect(controller.model.groups.first?.totalCost == 3) + + let selectedBackup = try #require(settings.effectiveSelectedTokenAccount(for: .mistral)) + settings.updateTokenAccount( + provider: .mistral, + accountID: selectedBackup.id, + token: UUID().uuidString) + let replacementConfiguration = SpendDashboardSource.configuration(settings: settings, store: store) + #expect(selectedBackupConfiguration.sourceOwnershipFingerprints != replacementConfiguration + .sourceOwnershipFingerprints) + + controller.update(configuration: replacementConfiguration) + #expect(controller.model.groups.isEmpty) + await Self.waitUntil { !controller.isRefreshing } + + #expect(controller.model.groups.isEmpty) + #expect(controller.failedSourceCount == 1) + } + + @Test + func `ordinary force failure retains same owner last good snapshot with warning`() async { + let settings = testSettingsStore(suiteName: "SpendDashboardControllerTests-force-failure") + settings.costUsageEnabled = true + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .claude) + } + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + store._setTokenSnapshotForTesting(Self.input(provider: .claude, cost: 4).snapshot, provider: .claude) + store._test_tokenUsageRefreshOverride = { _, _ in } + let controller = SpendDashboardController(requestBuilder: { mode in + await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) + }) + controller.update(configuration: SpendDashboardSource.configuration(settings: settings, store: store)) + await Self.waitUntil { !controller.isRefreshing } + #expect(controller.model.groups.first?.totalCost == 4) + + controller.refresh() + await Self.waitUntil { !controller.isRefreshing } + + #expect(controller.model.groups.first?.totalCost == 4) + #expect(controller.failedSourceCount == 1) + } + + @Test + func `history scope change drops stale spend when replacement refresh is unconfirmed`() async { + let settings = testSettingsStore(suiteName: "SpendDashboardControllerTests-history-scope") + settings.costUsageEnabled = true + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .claude) + } + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + store._setTokenSnapshotForTesting(Self.input(provider: .claude, cost: 5).snapshot, provider: .claude) + store._test_tokenUsageRefreshOverride = { _, _ in } + let controller = SpendDashboardController(requestBuilder: { mode in + await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) + }) + let firstConfiguration = SpendDashboardSource.configuration(settings: settings, store: store) + controller.update(configuration: firstConfiguration) + await Self.waitUntil { !controller.isRefreshing } + #expect(controller.model.groups.first?.totalCost == 5) + + settings.costUsageHistoryDays = 7 + let replacementConfiguration = SpendDashboardSource.configuration(settings: settings, store: store) + #expect(firstConfiguration.sourceOwnershipFingerprints != replacementConfiguration.sourceOwnershipFingerprints) + #expect(store.tokenSnapshotForCurrentProviderConfig(for: .claude) == nil) + + controller.update(configuration: replacementConfiguration) + #expect(controller.model.groups.isEmpty) + await Self.waitUntil { !controller.isRefreshing } + #expect(controller.model.groups.isEmpty) + #expect(controller.failedSourceCount == 1) + } + + @Test + func `Vertex spend ownership includes Claude fallback enablement`() { + let settings = testSettingsStore(suiteName: "SpendDashboardControllerTests-vertex-scope") + settings.costUsageEnabled = true + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .vertexai) + } + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + store._setTokenSnapshotForTesting(Self.input(provider: .vertexai, cost: 6).snapshot, provider: .vertexai) + let firstConfiguration = SpendDashboardSource.configuration(settings: settings, store: store) + let firstVertexOwnership = firstConfiguration.sourceOwnershipFingerprints.first { + $0.hasPrefix("vertexai:") + } + #expect(firstVertexOwnership != nil) + + if let claudeMetadata = ProviderRegistry.shared.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: claudeMetadata, enabled: true) + } + let replacementConfiguration = SpendDashboardSource.configuration(settings: settings, store: store) + let replacementVertexOwnership = replacementConfiguration.sourceOwnershipFingerprints.first { + $0.hasPrefix("vertexai:") + } + + #expect(firstVertexOwnership != replacementVertexOwnership) + #expect(store.tokenSnapshotForCurrentProviderConfig(for: .vertexai) == nil) + } + + @Test + func `cost tracking disable and reenable cannot revive the prior snapshot`() async { + let settings = testSettingsStore(suiteName: "SpendDashboardControllerTests-cost-enable-epoch") + settings.costUsageEnabled = true + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .claude) + } + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + store._setTokenSnapshotForTesting(Self.input(provider: .claude, cost: 5).snapshot, provider: .claude) + store._test_tokenUsageRefreshOverride = { _, _ in } + let controller = SpendDashboardController(requestBuilder: { mode in + await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) + }) + controller.update(configuration: SpendDashboardSource.configuration(settings: settings, store: store)) + await Self.waitUntil { !controller.isRefreshing } + #expect(controller.model.groups.first?.totalCost == 5) + + settings.costUsageEnabled = false + controller.update(configuration: SpendDashboardSource.configuration(settings: settings, store: store)) + #expect(controller.model.groups.isEmpty) + settings.costUsageEnabled = true + let reenabledConfiguration = SpendDashboardSource.configuration(settings: settings, store: store) + #expect(store.tokenSnapshotForCurrentProviderConfig(for: .claude) == nil) + controller.update(configuration: reenabledConfiguration) + await Self.waitUntil { !controller.isRefreshing } + #expect(controller.model.groups.isEmpty) + #expect(controller.failedSourceCount == 1) + + let reopenedController = SpendDashboardController(requestBuilder: { mode in + await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) + }) + reopenedController.update(configuration: reenabledConfiguration) + await Self.waitUntil { !reopenedController.isRefreshing } + #expect(reopenedController.model.groups.isEmpty) + #expect(reopenedController.failedSourceCount == 1) + } + + @Test + func `force refresh coalesces volatile revisions and finishes every provider`() async { + let controllerBox = SpendDashboardControllerBox() + let refreshRecorder = SpendDashboardRefreshRecorder() + let initialConfiguration = Self.configuration(account: "same", revision: "initial") + let firstProviderConfiguration = Self.configuration(account: "same", revision: "claude-fresh") + let controller = SpendDashboardController( + requestBuilder: { mode in + if mode == .forceRefresh { + await refreshRecorder.append(.claude) + controllerBox.controller?.update(configuration: firstProviderConfiguration) + await refreshRecorder.append(.openai) + } + return SpendDashboardLoadRequest( + configuration: firstProviderConfiguration, + capturedInputs: [ + Self.input(id: "claude", provider: .claude, cost: 3), + Self.input(id: "openai", provider: .openai, cost: 4), + ], + unavailableSourceIDs: [], + codexRequests: [], + now: Date(timeIntervalSince1970: 1_784_179_200), + force: mode.forcesLoader) + }, + loader: { request in + SpendDashboardLoadResult(inputs: request.capturedInputs, failedSourceIDs: []) + }) + controllerBox.controller = controller + + controller.update(configuration: initialConfiguration, force: true) + await Self.waitUntil { !controller.isRefreshing } + + #expect(await refreshRecorder.providers == [.claude, .openai]) + #expect(controller.configuration == firstProviderConfiguration) + #expect(controller.generation == 2) + #expect(controller.model.groups.first?.totalCost == 7) + #expect(Set(controller.model.groups.flatMap(\.providers).map(\.id)) == ["claude", "openai"]) + } + + @Test + func `force refresh reconciles loader drift through capture barrier without second loader`() async { + let gate = SpendDashboardLoaderGate() + let forceRecorder = SpendDashboardForceRecorder() + let initialConfiguration = Self.configuration(account: "same", revision: "initial") + let latestConfiguration = Self.configuration(account: "same", revision: "latest") + let controller = SpendDashboardController( + requestBuilder: { mode in + await forceRecorder.append(mode) + if mode == .forceRefresh { + return Self.request(configuration: initialConfiguration, force: true) + } + return SpendDashboardLoadRequest( + configuration: latestConfiguration, + capturedInputs: [Self.input(id: "claude", provider: .claude, cost: 2)], + unavailableSourceIDs: [], + codexRequests: [], + now: Date(timeIntervalSince1970: 1_784_179_200), + force: false) + }, + loader: { request in await gate.load(request) }) + + controller.update(configuration: initialConfiguration, force: true) + await Self.waitForPendingCount(1, gate: gate) + controller.update(configuration: latestConfiguration) + await gate.resume(at: 0, result: .init(inputs: [Self.input(cost: 1)], failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + + #expect(controller.configuration == latestConfiguration) + #expect(controller.generation == 2) + #expect(controller.model.groups.first?.totalCost == 2) + #expect(await forceRecorder.values == [.forceRefresh, .captureOnly]) + #expect(await gate.pendingCount == 0) + } + + @Test + func `disablement cancels pending work and clears safely`() async { + let gate = SpendDashboardLoaderGate() + let controller = Self.controller(gate: gate) + controller.update(configuration: Self.configuration(account: "enabled")) + await Self.waitForPendingCount(1, gate: gate) + + controller.update(configuration: SpendDashboardConfiguration( + costUsageEnabled: false, + providerIDs: [UsageProvider.codex.rawValue], + codexAccountIdentities: ["enabled"])) + #expect(!controller.isRefreshing) + #expect(controller.model.groups.isEmpty) + + await gate.resume(at: 0, result: .init(inputs: [Self.input(cost: 99)], failedSourceIDs: [])) + await Task.yield() + #expect(controller.model.groups.isEmpty) + } + + @Test + func `range selection persists only supported windows`() throws { + let suite = "SpendDashboardControllerTests-days" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + let controller = SpendDashboardController( + userDefaults: defaults, + requestBuilder: { mode in + Self.request( + configuration: Self.configuration(account: "unused"), + force: mode.forcesLoader) + }) + + #expect(controller.selectedDays == 30) + controller.selectDays(7) + #expect(controller.selectedDays == 7) + #expect(defaults.integer(forKey: "settingsSpendDashboardDays") == 7) + controller.selectDays(9) + #expect(controller.selectedDays == 30) + } + + private static func controller(gate: SpendDashboardLoaderGate) -> SpendDashboardController { + let controllerBox = SpendDashboardControllerBox() + let captureStore = SpendDashboardCapturedInputStore() + let controller = SpendDashboardController( + requestBuilder: { mode in + let configuration = controllerBox.controller?.configuration + ?? Self.configuration(account: "pending") + return await SpendDashboardLoadRequest( + configuration: configuration, + capturedInputs: mode == .captureOnly ? captureStore.inputs : [], + unavailableSourceIDs: [], + codexRequests: [], + now: Date(timeIntervalSince1970: 1_784_179_200), + force: mode.forcesLoader) + }, + loader: { request in + let result = await gate.load(request) + await captureStore.replace(with: result.inputs) + return result + }) + controllerBox.controller = controller + return controller + } + + private static func request( + configuration: SpendDashboardConfiguration, + force: Bool) -> SpendDashboardLoadRequest + { + SpendDashboardLoadRequest( + configuration: configuration, + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [], + now: Date(timeIntervalSince1970: 1_784_179_200), + force: force) + } + + private static func configuration( + account: String, + revision: String = "", + sourceOwnershipFingerprint: String = "") -> SpendDashboardConfiguration + { + SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.codex.rawValue], + codexAccountIdentities: [account], + sourceOwnershipFingerprints: [sourceOwnershipFingerprint], + sourceRevisions: [revision]) + } + + private static func input( + id: String? = nil, + provider: UsageProvider = .codex, + cost: Double) -> SpendDashboardModel.ProviderInput + { + let entry = CostUsageDailyReport.Entry( + date: "2026-07-15", + inputTokens: nil, + outputTokens: nil, + totalTokens: 10, + costUSD: cost, + modelsUsed: nil, + modelBreakdowns: nil) + let snapshot = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: 10, + last30DaysCostUSD: cost, + daily: [entry], + updatedAt: Date(timeIntervalSince1970: 1_784_179_200)) + return SpendDashboardModel.ProviderInput( + id: id, + provider: provider, + displayName: provider.rawValue, + snapshot: snapshot) + } + + private static func waitForPendingCount(_ count: Int, gate: SpendDashboardLoaderGate) async { + for _ in 0..<1000 { + if await gate.pendingCount == count { + return + } + await Task.yield() + } + Issue.record("Timed out waiting for \(count) pending loads") + } + + private static func waitForCodexPendingCount(_ count: Int, gate: SpendDashboardCodexSnapshotGate) async { + for _ in 0..<1000 { + if await gate.pendingCount == count { + return + } + await Task.yield() + } + Issue.record("Timed out waiting for \(count) pending Codex loads") + } + + private static func waitUntil(_ condition: @MainActor () -> Bool) async { + for _ in 0..<1000 { + if condition() { + return + } + await Task.yield() + } + Issue.record("Timed out waiting for controller state") + } +} + +@MainActor +struct SpendDashboardRequestTimeTests { + @Test + func `default request time resolves after provider refresh boundary`() async throws { + let (settings, store) = Self.store(suiteName: "SpendDashboardRequestTimeTests-capture") + let refreshFinished = LockIsolated(false) + store._test_tokenUsageRefreshOverride = { _, _ in + refreshFinished.setValue(true) + } + let afterMidnight = try #require(ISO8601DateFormatter().date(from: "2026-07-17T00:00:01Z")) + + let request = await SpendDashboardSource.makeRequest( + settings: settings, + store: store, + mode: .forceRefresh, + nowProvider: { + #expect(refreshFinished.value) + return afterMidnight + }) + + #expect(request.now == afterMidnight) + } + + @Test + func `explicit request time remains authoritative after refresh`() async throws { + let (settings, store) = Self.store(suiteName: "SpendDashboardRequestTimeTests-explicit") + store._test_tokenUsageRefreshOverride = { _, _ in } + let injected = try #require(ISO8601DateFormatter().date(from: "2026-07-16T23:59:59Z")) + + let request = await SpendDashboardSource.makeRequest( + settings: settings, + store: store, + mode: .forceRefresh, + now: injected, + nowProvider: { + Issue.record("Explicit request time must not read the default clock") + return Date.distantFuture + }) + + #expect(request.now == injected) + } + + private static func store(suiteName: String) -> (SettingsStore, UsageStore) { + let settings = testSettingsStore(suiteName: suiteName) + settings.costUsageEnabled = true + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .claude) + } + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + return (settings, store) + } +} + +@MainActor +struct SpendDashboardControllerRevisionTests { + private struct CompletenessReloadCase { + let name: String + let snapshot: CostUsageTokenSnapshot + let expectedTokens: Int? + let expectedCost: Double? + let expectedCompleteness: SpendDashboardModel.ModelHistoryCompleteness + } + + @Test + func `snapshot revision includes every dashboard completeness metric`() { + let baseline = Self.completenessSnapshot() + let baselineRevision = Self.sourceRevision( + snapshot: baseline, + suiteName: "SpendDashboardControllerTests-completeness-revision-baseline") + let mutations: [(String, CostUsageTokenSnapshot)] = [ + ("history coverage", Self.completenessSnapshot(historyCoverageIsEstablished: false)), + ("last 30 day tokens", Self.completenessSnapshot(last30DaysTokens: 1)), + ("last 30 day cost", Self.completenessSnapshot(last30DaysCostUSD: 1)), + ("entry input tokens", Self.completenessSnapshot(entryInputTokens: 1)), + ("entry cache read tokens", Self.completenessSnapshot(entryCacheReadTokens: 1)), + ("entry cache creation tokens", Self.completenessSnapshot(entryCacheCreationTokens: 1)), + ("entry output tokens", Self.completenessSnapshot(entryOutputTokens: 1)), + ("entry request count", Self.completenessSnapshot(entryRequestCount: 1)), + ("breakdown request count", Self.completenessSnapshot(breakdownRequestCount: 1)), + ("breakdown standard cost", Self.completenessSnapshot(breakdownStandardCostUSD: 1)), + ("breakdown priority cost", Self.completenessSnapshot(breakdownPriorityCostUSD: 1)), + ("breakdown standard tokens", Self.completenessSnapshot(breakdownStandardTokens: 1)), + ("breakdown priority tokens", Self.completenessSnapshot(breakdownPriorityTokens: 1)), + ] + + for (index, mutation) in mutations.enumerated() { + let revision = Self.sourceRevision( + snapshot: mutation.1, + suiteName: "SpendDashboardControllerTests-completeness-revision-\(index)") + #expect(revision != baselineRevision, "\(mutation.0) must affect the snapshot revision") + } + } + + @Test + func `same timestamp completeness mutations reload with metric specific validity`() async { + let mutations: [CompletenessReloadCase] = [ + .init( + name: "last 30 day aggregates", + snapshot: Self.completenessSnapshot( + date: "malformed", + last30DaysTokens: 1, + last30DaysCostUSD: 1), + expectedTokens: nil, + expectedCost: nil, + expectedCompleteness: .incomplete), + .init( + name: "entry request count", + snapshot: Self.completenessSnapshot(date: "malformed", entryRequestCount: 1), + expectedTokens: 0, + expectedCost: 0, + expectedCompleteness: .complete), + .init( + name: "breakdown standard cost", + snapshot: Self.completenessSnapshot(date: "malformed", breakdownStandardCostUSD: 1), + expectedTokens: 0, + expectedCost: nil, + expectedCompleteness: .incomplete), + ] + + for (index, mutation) in mutations.enumerated() { + let baseline = Self.completenessSnapshot(date: "malformed") + let (settings, store) = Self.revisionStore( + suiteName: "SpendDashboardControllerTests-completeness-reload-\(index)") + let baselineConfiguration = Self.configuration(snapshot: baseline, settings: settings, store: store) + let replacementConfiguration = Self.configuration( + snapshot: mutation.snapshot, + settings: settings, + store: store) + let gate = SpendDashboardLoaderGate() + let controller = Self.controller(gate: gate) + + #expect( + baselineConfiguration.sourceRevisions != replacementConfiguration.sourceRevisions, + "\(mutation.name) must invalidate the dashboard request") + + controller.update(configuration: baselineConfiguration) + await Self.waitForPendingCount(1, gate: gate) + await gate.resume(at: 0, result: .init( + inputs: [Self.input(provider: .claude, snapshot: baseline)], + failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + #expect(controller.model.groups.first?.providers.first?.totalTokens == 0) + #expect(controller.model.groups.first?.modelHistoryCompleteness == .complete) + + controller.update(configuration: replacementConfiguration) + await Self.waitForPendingCount(1, gate: gate) + await gate.resume(at: 0, result: .init( + inputs: [Self.input(provider: .claude, snapshot: mutation.snapshot)], + failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + + #expect(controller.generation == 2, "\(mutation.name) must trigger a replacement load") + #expect(controller.model.groups.first?.providers.first?.totalTokens == mutation.expectedTokens) + #expect(controller.model.groups.first?.providers.first?.totalCost == mutation.expectedCost) + #expect( + controller.model.groups.first?.modelHistoryCompleteness == mutation.expectedCompleteness) + #expect(controller.model.groups.first?.dailyPoints.isEmpty == true) + } + } + + private static func controller(gate: SpendDashboardLoaderGate) -> SpendDashboardController { + let controllerBox = SpendDashboardControllerBox() + let controller = SpendDashboardController( + requestBuilder: { mode in + let configuration = controllerBox.controller?.configuration + ?? SpendDashboardConfiguration( + costUsageEnabled: false, + providerIDs: [], + codexAccountIdentities: []) + return SpendDashboardLoadRequest( + configuration: configuration, + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [], + now: Date(timeIntervalSince1970: 1_784_179_200), + force: mode.forcesLoader) + }, + loader: { request in await gate.load(request) }) + controllerBox.controller = controller + return controller + } + + private static func revisionStore(suiteName: String) -> (SettingsStore, UsageStore) { + let settings = testSettingsStore(suiteName: suiteName) + settings.costUsageEnabled = true + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled( + provider: provider, + metadata: metadata, + enabled: provider == .claude) + } + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + return (settings, store) + } + + private static func configuration( + snapshot: CostUsageTokenSnapshot, + settings: SettingsStore, + store: UsageStore) -> SpendDashboardConfiguration + { + store._setTokenSnapshotForTesting(snapshot, provider: .claude) + return SpendDashboardSource.configuration(settings: settings, store: store) + } + + private static func sourceRevision( + snapshot: CostUsageTokenSnapshot, + suiteName: String) -> [String] + { + let (settings, store) = Self.revisionStore(suiteName: suiteName) + return Self.configuration(snapshot: snapshot, settings: settings, store: store).sourceRevisions + } + + private static func completenessSnapshot( + date: String = "2026-07-15", + historyCoverageIsEstablished: Bool = true, + last30DaysTokens: Int? = 0, + last30DaysCostUSD: Double? = 0, + entryInputTokens: Int? = nil, + entryCacheReadTokens: Int? = nil, + entryCacheCreationTokens: Int? = nil, + entryOutputTokens: Int? = nil, + entryRequestCount: Int? = nil, + breakdownRequestCount: Int? = nil, + breakdownStandardCostUSD: Double? = nil, + breakdownPriorityCostUSD: Double? = nil, + breakdownStandardTokens: Int? = nil, + breakdownPriorityTokens: Int? = nil) -> CostUsageTokenSnapshot + { + let breakdown = CostUsageDailyReport.ModelBreakdown( + modelName: "", + costUSD: 0, + totalTokens: 0, + requestCount: breakdownRequestCount, + standardCostUSD: breakdownStandardCostUSD, + priorityCostUSD: breakdownPriorityCostUSD, + standardTokens: breakdownStandardTokens, + priorityTokens: breakdownPriorityTokens) + let entry = CostUsageDailyReport.Entry( + date: date, + inputTokens: entryInputTokens, + outputTokens: entryOutputTokens, + cacheReadTokens: entryCacheReadTokens, + cacheCreationTokens: entryCacheCreationTokens, + totalTokens: 0, + requestCount: entryRequestCount, + costUSD: 0, + modelsUsed: nil, + modelBreakdowns: [breakdown]) + return CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: last30DaysTokens, + last30DaysCostUSD: last30DaysCostUSD, + historyCoverageIsEstablished: historyCoverageIsEstablished, + daily: [entry], + updatedAt: Date(timeIntervalSince1970: 1_784_179_200)) + } + + private static func input( + provider: UsageProvider, + snapshot: CostUsageTokenSnapshot) -> SpendDashboardModel.ProviderInput + { + SpendDashboardModel.ProviderInput( + provider: provider, + displayName: provider.rawValue, + snapshot: snapshot) + } + + private static func waitForPendingCount(_ count: Int, gate: SpendDashboardLoaderGate) async { + for _ in 0..<1000 { + if await gate.pendingCount == count { + return + } + await Task.yield() + } + Issue.record("Timed out waiting for \(count) pending loads") + } + + private static func waitUntil(_ condition: @MainActor () -> Bool) async { + for _ in 0..<1000 { + if condition() { + return + } + await Task.yield() + } + Issue.record("Timed out waiting for controller state") + } +} + +@MainActor +private final class SpendDashboardControllerBox { + var controller: SpendDashboardController? +} + +private actor SpendDashboardRefreshRecorder { + private(set) var providers: [UsageProvider] = [] + + func append(_ provider: UsageProvider) { + self.providers.append(provider) + } +} + +private actor SpendDashboardForceRecorder { + private(set) var values: [SpendDashboardRequestBuildMode] = [] + + func append(_ mode: SpendDashboardRequestBuildMode) { + self.values.append(mode) + } +} + +private actor SpendDashboardCapturedInputStore { + private(set) var inputs: [SpendDashboardModel.ProviderInput] = [] + + func replace(with inputs: [SpendDashboardModel.ProviderInput]) { + self.inputs = inputs + } +} + +private actor SpendDashboardCodexLoadRecorder { + private(set) var contexts: [CodexSpendSnapshotLoadContext] = [] + + func record(_ context: CodexSpendSnapshotLoadContext) { + self.contexts.append(context) + } +} + +private actor SpendDashboardLoadResultRecorder { + private(set) var results: [SpendDashboardLoadResult] = [] + + func record(_ result: SpendDashboardLoadResult) { + self.results.append(result) + } +} + +private actor SpendDashboardCodexSnapshotGate { + private var continuations: [CheckedContinuation] = [] + + var pendingCount: Int { + self.continuations.count + } + + func load(_ context: CodexSpendSnapshotLoadContext) async -> CostUsageTokenSnapshot { + _ = context + return await withCheckedContinuation { continuation in + self.continuations.append(continuation) + } + } + + func resume(at index: Int, snapshot: CostUsageTokenSnapshot) { + self.continuations.remove(at: index).resume(returning: snapshot) + } +} + +private actor SpendDashboardLoaderGate { + private var continuations: [CheckedContinuation] = [] + + var pendingCount: Int { + self.continuations.count + } + + func load(_ request: SpendDashboardLoadRequest) async -> SpendDashboardLoadResult { + _ = request + return await withCheckedContinuation { continuation in + self.continuations.append(continuation) + } + } + + func resume(at index: Int, result: SpendDashboardLoadResult) { + self.continuations.remove(at: index).resume(returning: result) + } +} diff --git a/Tests/CodexBarTests/SpendDashboardDateTruthTests.swift b/Tests/CodexBarTests/SpendDashboardDateTruthTests.swift new file mode 100644 index 0000000000..0bf77f4eb4 --- /dev/null +++ b/Tests/CodexBarTests/SpendDashboardDateTruthTests.swift @@ -0,0 +1,829 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct SpendDashboardDateTruthTests { + private struct MalformedMetricCase { + let name: String + let breakdown: CostUsageDailyReport.ModelBreakdown + let totalCost: Double? + let totalTokens: Int? + let modelHistory: SpendDashboardModel.ModelHistoryCompleteness + let chartCost: Double? + } + + @Test + func `Mistral UTC buckets map into Pacific dashboard days at midnight UTC`() throws { + var pacific = Calendar(identifier: .gregorian) + pacific.timeZone = try #require(TimeZone(identifier: "America/Los_Angeles")) + let now = try #require(ISO8601DateFormatter().date(from: "2026-07-02T00:01:00Z")) + let june30 = try #require(pacific.date(from: DateComponents(year: 2026, month: 6, day: 30))) + let july1 = try #require(pacific.date(from: DateComponents(year: 2026, month: 7, day: 1))) + let snapshot = Self.snapshot( + currency: "EUR", + entries: [ + Self.entry(day: "2026-07-01", cost: 1, tokens: 10), + Self.entry(day: "2026-07-02", cost: 2, tokens: 20), + ], + historyDays: 2, + updatedAt: now) + let group = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .mistral, displayName: "Mistral", snapshot: snapshot)], + requestedDays: 7, + now: now, + calendar: pacific).groups.first) + + #expect(group.totalCost == 3) + #expect(group.totalTokens == 30) + #expect(group.coveredDayCount == 2) + #expect(group.dailyPoints.map(\.day) == [june30, july1]) + #expect(group.dailyPoints.map(\.cost) == [1, 2]) + } + + @Test + func `Mistral coverage end preserves UTC bucket day after Pacific midnight`() throws { + var pacific = Calendar(identifier: .gregorian) + pacific.timeZone = try #require(TimeZone(identifier: "America/Los_Angeles")) + let now = try #require(ISO8601DateFormatter().date(from: "2026-07-02T08:00:00Z")) + let mistral = SpendDashboardModel.ProviderInput( + provider: .mistral, + displayName: "Mistral", + snapshot: Self.snapshot( + currency: "USD", + entries: [], + historyDays: 2, + updatedAt: now)) + let local = SpendDashboardModel.ProviderInput( + provider: .claude, + displayName: "Claude", + snapshot: Self.snapshot( + currency: "USD", + entries: [Self.entry(day: "2026-07-02", cost: 1)], + historyDays: 1, + updatedAt: now)) + let group = try #require(SpendDashboardModel.build( + inputs: [mistral, local], + requestedDays: 7, + now: now, + calendar: pacific).groups.first) + + #expect(group.coveredDayCount == 0) + #expect(group.providers.first(where: { $0.provider == .mistral })?.coveredDayCount == 2) + #expect(group.providers.first(where: { $0.provider == .claude })?.coveredDayCount == 1) + } + + @Test + func `Mistral ended range stays on observed UTC days instead of publishing recent zeros`() throws { + let formatter = ISO8601DateFormatter() + let updatedAt = try #require(formatter.date(from: "2026-07-16T12:00:00Z")) + let startDate = try #require(formatter.date(from: "2026-07-01T00:00:00Z")) + let endDate = try #require(formatter.date(from: "2026-07-02T00:00:01Z")) + let usage = MistralUsageSnapshot( + totalCost: 3, + currency: "USD", + currencySymbol: "$", + totalInputTokens: 30, + totalOutputTokens: 0, + totalCachedTokens: 0, + modelCount: 1, + daily: [ + Self.mistralBucket(day: "2026-07-01", cost: 1, tokens: 10), + Self.mistralBucket(day: "2026-07-02", cost: 2, tokens: 20), + ], + startDate: startDate, + endDate: endDate, + updatedAt: updatedAt) + let snapshot = usage.toCostUsageTokenSnapshot(historyDays: 7) + + let earlierGroup = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .mistral, displayName: "Mistral", snapshot: snapshot)], + requestedDays: 30, + now: updatedAt, + calendar: Self.calendar).groups.first) + #expect(earlierGroup.providers.first?.coveredDayCount == 2) + #expect(earlierGroup.providers.first?.totalCost == 3) + #expect(earlierGroup.dailyPoints.map(\.day) == [startDate, Self.calendar.startOfDay(for: endDate)]) + + let recentGroup = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .mistral, displayName: "Mistral", snapshot: snapshot)], + requestedDays: 7, + now: updatedAt, + calendar: Self.calendar).groups.first) + #expect(recentGroup.providers.first?.coveredDayCount == 0) + #expect(recentGroup.providers.first?.totalCost == nil) + #expect(recentGroup.providers.first?.totalTokens == nil) + #expect(recentGroup.dailyPoints.isEmpty) + } + + @Test + func `metadata free Mistral coverage preserves stale valid billing buckets`() throws { + let formatter = ISO8601DateFormatter() + let updatedAt = try #require(formatter.date(from: "2026-07-16T12:00:00Z")) + let july14 = try #require(formatter.date(from: "2026-07-14T00:00:00Z")) + let july15 = try #require(formatter.date(from: "2026-07-15T00:00:00Z")) + let usage = MistralUsageSnapshot( + totalCost: 3, + currency: "EUR", + currencySymbol: "€", + totalInputTokens: 30, + totalOutputTokens: 0, + totalCachedTokens: 0, + modelCount: 1, + daily: [ + Self.mistralBucket(day: "2026-07-14", cost: 1, tokens: 10), + Self.mistralBucket(day: "2026-07-15", cost: 2, tokens: 20), + ], + startDate: nil, + endDate: nil, + updatedAt: updatedAt) + let snapshot = usage.toCostUsageTokenSnapshot() + let group = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .mistral, displayName: "Mistral", snapshot: snapshot)], + requestedDays: 30, + now: updatedAt, + calendar: Self.calendar).groups.first) + + #expect(snapshot.updatedAt == july15) + #expect(group.coveredDayCount == 2) + #expect(group.totalCost == 3) + #expect(group.totalTokens == 30) + #expect(group.dailyPoints.map(\.day) == [july14, july15]) + #expect(group.dailyPoints.map(\.cost) == [1, 2]) + } + + @Test + func `Mistral without established coverage cannot publish a current zero day`() throws { + let snapshot = MistralUsageSnapshot( + totalCost: 0, + currency: "EUR", + currencySymbol: "€", + totalInputTokens: 0, + totalOutputTokens: 0, + totalCachedTokens: 0, + modelCount: 0, + daily: [], + startDate: nil, + endDate: nil, + updatedAt: Self.now) + .toCostUsageTokenSnapshot(historyDays: 7) + let group = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .mistral, displayName: "Mistral", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(!snapshot.historyCoverageIsEstablished) + #expect(group.coveredDayCount == 0) + #expect(group.providers.first?.coveredDayCount == 0) + #expect(group.totalCost == nil) + #expect(group.totalTokens == nil) + #expect(group.dailyPoints.isEmpty) + } + + @Test + func `unknown currency spend cannot enter a known currency group`() throws { + let model = SpendDashboardModel.build( + inputs: [ + Self.input(id: "usd", provider: .claude, currency: "USD", cost: 2), + Self.input(id: "blank", provider: .mistral, currency: " ", cost: 100), + Self.input(id: "unknown", provider: .openai, currency: "XXX", cost: 200), + Self.input(id: "eur", provider: .codex, currency: "EUR", cost: 3), + ], + requestedDays: 30, + now: Self.now, + calendar: Self.calendar) + + #expect(model.groups.map(\.currencyCode) == ["EUR", "USD"]) + let eur = try #require(model.groups.first(where: { $0.currencyCode == "EUR" })) + let usd = try #require(model.groups.first(where: { $0.currencyCode == "USD" })) + #expect(eur.providers.map(\.id) == ["eur"]) + #expect(eur.totalCost == 3) + #expect(usd.providers.map(\.id) == ["usd"]) + #expect(usd.totalCost == 2) + } + + @Test + func `date with a valid prefix and trailing junk fails closed`() throws { + let snapshot = Self.snapshot(currency: "USD", entries: [ + Self.entry(day: "2026-07-16junk", cost: 2, tokens: 20), + ]) + let group = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.providers.first?.totalCost == nil) + #expect(group.providers.first?.totalTokens == nil) + #expect(group.modelHistoryCompleteness == .incomplete) + #expect(group.dailyPoints.isEmpty) + } + + @Test + func `malformed rows validate cost and tokens independently`() throws { + let cases: [MalformedMetricCase] = [ + .init( + name: "cost", + breakdown: .init(modelName: "spend", costUSD: 1, totalTokens: 0), + totalCost: nil, + totalTokens: 30, + modelHistory: .incomplete, + chartCost: nil), + .init( + name: "tokens", + breakdown: .init(modelName: "tokens", costUSD: 0, totalTokens: 1), + totalCost: 3, + totalTokens: nil, + modelHistory: .complete, + chartCost: 3), + .init( + name: "requests", + breakdown: .init(modelName: "requests", costUSD: 0, totalTokens: 0, requestCount: 1), + totalCost: 3, + totalTokens: 30, + modelHistory: .complete, + chartCost: 3), + ] + + for testCase in cases { + let snapshot = Self.snapshot(currency: "USD", entries: [ + Self.entry(day: "2026-07-16", cost: 3, tokens: 30), + Self.entryWithBreakdowns( + day: "malformed", + breakdowns: [testCase.breakdown]), + ]) + let group = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.providers.first?.totalCost == testCase.totalCost, Comment(rawValue: testCase.name)) + #expect(group.providers.first?.totalTokens == testCase.totalTokens, Comment(rawValue: testCase.name)) + #expect(group.modelHistoryCompleteness == testCase.modelHistory, Comment(rawValue: testCase.name)) + #expect(group.models.map(\.totalCost) == (testCase.totalCost == nil ? [] : [3])) + #expect(group.dailyPoints.first?.cost == testCase.chartCost, Comment(rawValue: testCase.name)) + } + } + + @Test + func `omitted rows preserve independent metrics sources and currencies`() throws { + let omissions = [(day: "malformed", historyDays: 30), (day: "2026-07-15", historyDays: 1)] + + for omission in omissions { + let tokenInvalid = SpendDashboardModel.ProviderInput( + id: "token-invalid", + provider: .claude, + displayName: "Token invalid", + snapshot: Self.snapshot( + currency: "USD", + entries: [ + Self.entry(day: "2026-07-16", cost: 3, tokens: 30), + Self.entry(day: omission.day, cost: 0, tokens: nil, model: nil), + ], + historyDays: omission.historyDays)) + let costInvalid = SpendDashboardModel.ProviderInput( + id: "cost-invalid", + provider: .openai, + displayName: "Cost invalid", + snapshot: Self.snapshot( + currency: "CAD", + entries: [ + Self.entry(day: "2026-07-16", cost: 2, tokens: 20), + Self.entry(day: omission.day, cost: nil, tokens: 0, model: nil), + ], + historyDays: omission.historyDays)) + let groups = SpendDashboardModel.build( + inputs: [ + tokenInvalid, + Self.input(id: "healthy-usd", provider: .codex, currency: "USD", cost: 4), + costInvalid, + Self.input(id: "healthy-cad", provider: .mistral, currency: "CAD", cost: 5), + Self.input(id: "healthy-eur", provider: .bedrock, currency: "EUR", cost: 6), + ], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups + let cad = try #require(groups.first(where: { $0.currencyCode == "CAD" })) + let eur = try #require(groups.first(where: { $0.currencyCode == "EUR" })) + let usd = try #require(groups.first(where: { $0.currencyCode == "USD" })) + + #expect(usd.totalCost == 7) + #expect(usd.totalTokens == nil) + #expect(usd.modelHistoryCompleteness == .complete) + #expect(usd.models.map(\.totalCost) == [4, 3]) + #expect(usd.models.first(where: { $0.provider == .claude })?.totalTokens == nil) + #expect(usd.models.first(where: { $0.provider == .codex })?.totalTokens == 10) + #expect(usd.dailyPoints.map(\.sourceID) == ["healthy-usd", "token-invalid"]) + #expect(SpendDailyChartPresentation( + dailyPoints: usd.dailyPoints, + aggregateTotal: usd.totalCost).content == .chart) + + #expect(cad.totalCost == nil) + #expect(cad.totalTokens == 30) + #expect(cad.modelHistoryCompleteness == .incomplete) + #expect(cad.models.isEmpty) + #expect(cad.dailyPoints.map(\.sourceID) == ["healthy-cad"]) + #expect(SpendDailyChartPresentation( + dailyPoints: cad.dailyPoints, + aggregateTotal: cad.totalCost).content == .chart) + + #expect(eur.totalCost == 6) + #expect(eur.totalTokens == 10) + #expect(eur.modelHistoryCompleteness == .complete) + #expect(eur.models.map(\.totalCost) == [6]) + #expect(eur.dailyPoints.map(\.sourceID) == ["healthy-eur"]) + } + } + + @Test + func `complete model costs survive invalid aggregate and per-model tokens`() throws { + let negative = SpendDashboardModel.ProviderInput( + id: "negative", + provider: .mistral, + displayName: "Negative", + snapshot: Self.snapshot(currency: "USD", entries: [ + Self.entryWithBreakdowns( + day: "2026-07-16", + totalCost: 5, + totalTokens: -1, + breakdowns: [.init(modelName: "negative", costUSD: 5, totalTokens: -1)]), + ])) + let overflow = SpendDashboardModel.ProviderInput( + id: "overflow", + provider: .claude, + displayName: "Overflow", + snapshot: Self.snapshot(currency: "USD", entries: [ + Self.entryWithBreakdowns( + day: "2026-07-16", + totalCost: 3, + totalTokens: .max, + breakdowns: [.init(modelName: "overflow", costUSD: 3, totalTokens: .max)]), + Self.entryWithBreakdowns( + day: "2026-07-15", + totalCost: 4, + totalTokens: .max, + breakdowns: [.init(modelName: "overflow", costUSD: 4, totalTokens: .max)]), + ])) + let mismatch = SpendDashboardModel.ProviderInput( + id: "mismatch", + provider: .openai, + displayName: "Mismatch", + snapshot: Self.snapshot(currency: "USD", entries: [ + Self.entryWithBreakdowns( + day: "2026-07-16", + totalCost: 6, + totalTokens: 60, + breakdowns: [.init(modelName: "mismatch", costUSD: 6, totalTokens: 10)]), + ])) + let valid = SpendDashboardModel.ProviderInput( + id: "valid", + provider: .codex, + displayName: "Valid", + snapshot: Self.snapshot(currency: "USD", entries: [ + Self.entryWithBreakdowns( + day: "2026-07-16", + totalCost: 2, + totalTokens: 2, + breakdowns: [.init(modelName: "valid", costUSD: 2, totalTokens: 2)]), + ])) + let group = try #require(SpendDashboardModel.build( + inputs: [negative, overflow, mismatch, valid], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.totalCost == 20) + #expect(group.totalTokens == nil) + #expect(group.modelHistoryCompleteness == .complete) + #expect(group.models.map(\.modelName) == ["overflow", "mismatch", "negative", "valid"]) + #expect(group.models.map(\.totalCost) == [7, 6, 5, 2]) + #expect(group.models.map(\.totalTokens) == [nil, nil, nil, 2]) + #expect(Set(group.dailyPoints.map(\.sourceID)) == ["mismatch", "negative", "overflow", "valid"]) + } + + @Test + func `malformed source is omitted without hiding healthy currency peers`() throws { + let malformed = SpendDashboardModel.ProviderInput( + id: "malformed", + provider: .claude, + displayName: "Malformed", + snapshot: Self.snapshot(currency: "USD", entries: [ + Self.entry(day: "2026-07-16", cost: 3, tokens: 30), + Self.entry(day: "not-a-day", cost: 7, tokens: 70), + ])) + let healthyUSD = Self.input(id: "healthy-usd", provider: .codex, currency: "USD", cost: 4) + let healthyEUR = Self.input(id: "healthy-eur", provider: .openai, currency: "EUR", cost: 5) + let groups = SpendDashboardModel.build( + inputs: [malformed, healthyUSD, healthyEUR], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups + let eur = try #require(groups.first(where: { $0.currencyCode == "EUR" })) + let usd = try #require(groups.first(where: { $0.currencyCode == "USD" })) + + #expect(usd.providers.first(where: { $0.id == "malformed" })?.totalCost == nil) + #expect(usd.providers.first(where: { $0.id == "malformed" })?.totalTokens == nil) + #expect(usd.totalCost == nil) + #expect(usd.totalTokens == nil) + #expect(usd.modelHistoryCompleteness == .incomplete) + #expect(usd.models.isEmpty) + #expect(usd.dailyPoints.map(\.sourceID) == ["healthy-usd"]) + #expect(usd.dailyPoints.map(\.cost) == [4]) + #expect(eur.totalCost == 5) + #expect(eur.totalTokens == 10) + #expect(eur.modelHistoryCompleteness == .complete) + #expect(eur.dailyPoints.map(\.sourceID) == ["healthy-eur"]) + } + + @Test + func `coverage contradiction fails source closed across every aggregate`() throws { + let contradictions = [ + Self.entry(day: "2026-07-15", cost: 7, tokens: 70), + Self.entry(day: "2026-07-15", cost: nil, tokens: nil, model: nil), + ] + + for contradiction in contradictions { + let snapshot = Self.snapshot( + currency: "USD", + entries: [ + Self.entry(day: "2026-07-16", cost: 3, tokens: 30), + contradiction, + ], + historyDays: 1) + let group = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.providers.first?.coveredDayCount == 1) + #expect(group.providers.first?.totalCost == nil) + #expect(group.providers.first?.totalTokens == nil) + #expect(group.totalCost == nil) + #expect(group.totalTokens == nil) + #expect(group.modelHistoryCompleteness == .incomplete) + #expect(group.models.isEmpty) + #expect(group.dailyPoints.isEmpty) + } + } + + @Test + func `entries inside declared coverage aggregate normally`() throws { + let snapshot = Self.snapshot( + currency: "USD", + entries: [ + Self.entry(day: "2026-07-15", cost: 7, tokens: 70), + Self.entry(day: "2026-07-16", cost: 3, tokens: 30), + ], + historyDays: 2) + let group = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.totalCost == 10) + #expect(group.totalTokens == 100) + #expect(group.modelHistoryCompleteness == .complete) + #expect(group.models.map(\.totalCost) == [10]) + #expect(group.dailyPoints.map(\.cost) == [7, 3]) + } + + @Test + func `aggregate contradictions fail only the affected metric`() throws { + let entry = Self.entry(day: "2026-07-16", cost: 3, tokens: 30) + let costContradiction = Self.snapshot( + currency: "USD", + entries: [entry], + last30DaysTokens: 30, + last30DaysCostUSD: 10) + let tokenContradiction = Self.snapshot( + currency: "USD", + entries: [entry], + last30DaysTokens: 100, + last30DaysCostUSD: 3) + + let costGroup = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: costContradiction)], + requestedDays: 30, + now: Self.now, + calendar: Self.calendar).groups.first) + let tokenGroup = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: tokenContradiction)], + requestedDays: 30, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(costGroup.totalCost == nil) + #expect(costGroup.totalTokens == 30) + #expect(costGroup.dailyPoints.isEmpty) + #expect(costGroup.modelHistoryCompleteness == .incomplete) + #expect(costGroup.models.isEmpty) + + #expect(tokenGroup.totalCost == 3) + #expect(tokenGroup.totalTokens == nil) + #expect(tokenGroup.dailyPoints.map(\.cost) == [3]) + #expect(tokenGroup.modelHistoryCompleteness == .complete) + #expect(tokenGroup.models.map(\.totalCost) == [3]) + #expect(tokenGroup.models.map(\.totalTokens) == [nil]) + } + + @Test + func `matching full history aggregates allow shorter selected window`() throws { + let snapshot = Self.snapshot( + currency: "USD", + entries: [ + Self.entry(day: "2026-07-06", cost: 7, tokens: 70), + Self.entry(day: "2026-07-16", cost: 3, tokens: 30), + ], + historyDays: 30, + last30DaysTokens: 100, + last30DaysCostUSD: 10) + let group = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.totalCost == 3) + #expect(group.totalTokens == 30) + #expect(group.models.map(\.totalCost) == [3]) + #expect(group.models.map(\.totalTokens) == [30]) + #expect(group.dailyPoints.map(\.cost) == [3]) + } + + @Test + func `out of request usage and proven zero outside coverage are harmless`() throws { + let snapshot = Self.snapshot( + currency: "USD", + entries: [ + Self.entry(day: "2026-07-01", cost: 100, tokens: 1000), + Self.entry(day: "2026-07-15", cost: 0, tokens: 0, model: nil), + Self.entry(day: "2026-07-16", cost: 3, tokens: 30), + ], + historyDays: 1) + let group = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.totalCost == 3) + #expect(group.totalTokens == 30) + #expect(group.modelHistoryCompleteness == .complete) + #expect(group.models.map(\.totalCost) == [3]) + #expect(group.dailyPoints.map(\.cost) == [3]) + } + + @Test + func `coverage contradiction omits only its source and currency`() throws { + let contradictory = SpendDashboardModel.ProviderInput( + id: "contradictory", + provider: .claude, + displayName: "Contradictory", + snapshot: Self.snapshot( + currency: "USD", + entries: [ + Self.entry(day: "2026-07-15", cost: 7, tokens: 70), + Self.entry(day: "2026-07-16", cost: 3, tokens: 30), + ], + historyDays: 1)) + let healthyUSD = Self.input(id: "healthy-usd", provider: .codex, currency: "USD", cost: 4) + let healthyEUR = Self.input(id: "healthy-eur", provider: .openai, currency: "EUR", cost: 5) + let groups = SpendDashboardModel.build( + inputs: [contradictory, healthyUSD, healthyEUR], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups + let eur = try #require(groups.first(where: { $0.currencyCode == "EUR" })) + let usd = try #require(groups.first(where: { $0.currencyCode == "USD" })) + + #expect(usd.totalCost == nil) + #expect(usd.modelHistoryCompleteness == .incomplete) + #expect(usd.models.isEmpty) + #expect(usd.dailyPoints.map(\.sourceID) == ["healthy-usd"]) + #expect(usd.dailyPoints.map(\.cost) == [4]) + #expect(eur.totalCost == 5) + #expect(eur.modelHistoryCompleteness == .complete) + #expect(eur.models.map(\.totalCost) == [5]) + #expect(eur.dailyPoints.map(\.sourceID) == ["healthy-eur"]) + } + + @Test + func `empty Mistral history with incomplete aggregates stays unavailable`() throws { + let snapshot = Self.mistralSnapshot(totalCost: 5, totalTokens: 50) + #expect(snapshot.last30DaysCostUSD == nil) + #expect(snapshot.last30DaysTokens == nil) + let group = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .mistral, displayName: "Mistral", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(!snapshot.historyCoverageIsEstablished) + #expect(group.coveredDayCount == 0) + #expect(group.totalCost == nil) + #expect(group.totalTokens == nil) + #expect(group.modelHistoryCompleteness == .incomplete) + #expect(group.models.isEmpty) + } + + @Test + func `empty Mistral history with declared coverage preserves explicit zeros`() throws { + let snapshot = Self.mistralSnapshot(totalCost: 0, totalTokens: 0, establishesCoverage: true) + #expect(snapshot.historyCoverageIsEstablished) + #expect(snapshot.last30DaysCostUSD == 0) + #expect(snapshot.last30DaysTokens == 0) + let group = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .mistral, displayName: "Mistral", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.totalCost == 0) + #expect(group.totalTokens == 0) + #expect(group.modelHistoryCompleteness == .complete) + #expect(group.models.isEmpty) + } + + @Test + func `malformed zero row cannot prove contradictory nonzero aggregates`() throws { + let snapshot = Self.snapshot( + currency: "USD", + entries: [Self.entry(day: "malformed", cost: 0, tokens: 0, model: nil)], + historyDays: 1, + last30DaysTokens: 1, + last30DaysCostUSD: 1) + let group = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.totalCost == nil) + #expect(group.totalTokens == nil) + #expect(group.modelHistoryCompleteness == .incomplete) + #expect(group.dailyPoints.isEmpty) + } + + @Test + func `empty history metric proof stays independent and currency scoped`() throws { + let costOnly = SpendDashboardModel.ProviderInput( + id: "cost-only", + provider: .mistral, + displayName: "Cost only", + snapshot: Self.mistralSnapshot( + totalCost: 0, + totalTokens: 50, + currency: "USD", + establishesCoverage: true)) + let completeUSD = SpendDashboardModel.ProviderInput( + id: "complete-usd", + provider: .claude, + displayName: "Complete USD", + snapshot: Self.snapshot( + currency: "USD", + entries: [], + historyDays: 1, + last30DaysTokens: 0, + last30DaysCostUSD: 0)) + let tokenOnly = SpendDashboardModel.ProviderInput( + id: "token-only", + provider: .mistral, + displayName: "Token only", + snapshot: Self.mistralSnapshot( + totalCost: 5, + totalTokens: 0, + currency: "EUR", + establishesCoverage: true)) + let groups = SpendDashboardModel.build( + inputs: [costOnly, completeUSD, tokenOnly], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups + let eur = try #require(groups.first(where: { $0.currencyCode == "EUR" })) + let usd = try #require(groups.first(where: { $0.currencyCode == "USD" })) + + #expect(usd.totalCost == 0) + #expect(usd.totalTokens == nil) + #expect(usd.modelHistoryCompleteness == .complete) + #expect(eur.totalCost == nil) + #expect(eur.totalTokens == 0) + #expect(eur.modelHistoryCompleteness == .incomplete) + } + + private static func input( + id: String, + provider: UsageProvider, + currency: String, + cost: Double) -> SpendDashboardModel.ProviderInput + { + SpendDashboardModel.ProviderInput( + id: id, + provider: provider, + displayName: provider.rawValue, + snapshot: self.snapshot( + currency: currency, + entries: [self.entry(day: "2026-07-16", cost: cost)])) + } + + private static func snapshot( + currency: String, + entries: [CostUsageDailyReport.Entry], + historyDays: Int = 30, + last30DaysTokens: Int? = nil, + last30DaysCostUSD: Double? = nil, + updatedAt: Date = now) -> CostUsageTokenSnapshot + { + CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: last30DaysTokens, + last30DaysCostUSD: last30DaysCostUSD, + currencyCode: currency, + historyDays: historyDays, + daily: entries, + updatedAt: updatedAt) + } + + private static func mistralSnapshot( + totalCost: Double, + totalTokens: Int, + currency: String = "USD", + establishesCoverage: Bool = false) -> CostUsageTokenSnapshot + { + MistralUsageSnapshot( + totalCost: totalCost, + currency: currency, + currencySymbol: currency, + totalInputTokens: totalTokens, + totalOutputTokens: 0, + totalCachedTokens: 0, + modelCount: 0, + daily: [], + startDate: establishesCoverage ? self.now : nil, + endDate: establishesCoverage ? self.now : nil, + updatedAt: self.now) + .toCostUsageTokenSnapshot(historyDays: 7) + } + + private static func entry( + day: String, + cost: Double?, + tokens: Int? = 10, + model: String? = "test-model") -> CostUsageDailyReport.Entry + { + CostUsageDailyReport.Entry( + date: day, + inputTokens: nil, + outputTokens: nil, + totalTokens: tokens, + costUSD: cost, + modelsUsed: nil, + modelBreakdowns: model.map { + [.init(modelName: $0, costUSD: cost, totalTokens: tokens)] + }) + } + + private static func entryWithBreakdowns( + day: String, + totalCost: Double = 0, + totalTokens: Int = 0, + breakdowns: [CostUsageDailyReport.ModelBreakdown]) -> CostUsageDailyReport.Entry + { + CostUsageDailyReport.Entry( + date: day, + inputTokens: nil, + outputTokens: nil, + totalTokens: totalTokens, + costUSD: totalCost, + modelsUsed: nil, + modelBreakdowns: breakdowns) + } + + private static func mistralBucket(day: String, cost: Double, tokens: Int) -> MistralDailyUsageBucket { + MistralDailyUsageBucket( + day: day, + cost: cost, + inputTokens: tokens, + cachedTokens: 0, + outputTokens: 0, + models: [ + .init( + name: "test-model", + cost: cost, + inputTokens: tokens, + cachedTokens: 0, + outputTokens: 0), + ]) + } + + private static let now = Date(timeIntervalSince1970: 1_784_179_200) + private static var calendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + } +} diff --git a/Tests/CodexBarTests/SpendDashboardForceStateMachineTests.swift b/Tests/CodexBarTests/SpendDashboardForceStateMachineTests.swift new file mode 100644 index 0000000000..e0c1f152c6 --- /dev/null +++ b/Tests/CodexBarTests/SpendDashboardForceStateMachineTests.swift @@ -0,0 +1,888 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct SpendDashboardForceStateMachineTests { + @Test + func `A forced failures dominate stale capture and retain only trusted old rows`() async { + let initial = Self.configuration(owner: "owner", revision: "R") + let latest = Self.configuration(owner: "owner", revision: "L") + let oldInputs = [ + Self.input(id: "claude", provider: .claude, cost: 3), + Self.input(id: "codex:a", provider: .codex, cost: 5), + ] + let failedIDs: Set = ["claude", "codex:a"] + let builder = SpendDashboardBuildScript([ + .init(mode: .refreshMissing, request: Self.request(initial, mode: .refreshMissing)), + .init( + mode: .forceRefresh, + request: Self.request( + initial, + mode: .forceRefresh, + codexAccount: true)), + .init( + mode: .captureOnly, + request: Self.request( + latest, + mode: .captureOnly, + inputs: [ + Self.input(id: "claude", provider: .claude, cost: 90), + Self.input(id: "codex:a", provider: .codex, cost: 90), + ])), + ]) + let loader = SpendDashboardStateLoaderGate() + let controller = SpendDashboardController( + requestBuilder: { mode in await builder.next(mode) }, + loader: { request in await loader.load(request) }) + + controller.update(configuration: initial) + await Self.waitForLoader(loader) + await loader.resume(SpendDashboardLoadResult(inputs: oldInputs, failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + + controller.refresh() + await Self.waitForLoader(loader) + controller.update(configuration: latest) + await loader.resume(SpendDashboardLoadResult(inputs: [], failedSourceIDs: failedIDs)) + await Self.waitUntil { !controller.isRefreshing } + + #expect(builder.modes == [.refreshMissing, .forceRefresh, .captureOnly]) + #expect(await loader.forces == [false, true]) + #expect(controller.failedSourceCount == 2) + #expect(controller.model.groups.first?.totalCost == 8) + } + + @Test + func `B capture drift wins for providers while forced Codex success carries`() async { + let initial = Self.configuration(owner: "owner", revision: "R") + let latest = Self.configuration(owner: "owner", revision: "L") + let builder = SpendDashboardBuildScript([ + .init( + mode: .forceRefresh, + request: Self.request( + initial, + mode: .forceRefresh, + confirmedEmptySourceIDs: ["claude"], + codexAccount: true)), + .init( + mode: .captureOnly, + request: Self.request( + latest, + mode: .captureOnly, + inputs: [Self.input(id: "claude", provider: .claude, cost: 7)])), + ]) + let loader = SpendDashboardStateLoaderGate() + let controller = SpendDashboardController( + requestBuilder: { mode in await builder.next(mode) }, + loader: { request in await loader.load(request) }) + + controller.update(configuration: initial, force: true) + await Self.waitForLoader(loader) + controller.update(configuration: latest) + await loader.resume(SpendDashboardLoadResult( + inputs: [Self.input(id: "codex:a", provider: .codex, cost: 5)], + failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + + #expect(builder.modes == [.forceRefresh, .captureOnly]) + #expect(await loader.forces == [true]) + #expect(controller.configuration == latest) + #expect(controller.model.groups.first?.totalCost == 12) + } + + @Test + func `C same owner barrier churn repeats capture only and preserves failures`() async { + let initial = Self.configuration(owner: "owner", revision: "R") + let first = Self.configuration(owner: "owner", revision: "L") + let second = Self.configuration(owner: "owner", revision: "M") + let third = Self.configuration(owner: "owner", revision: "N") + let latest = Self.configuration(owner: "owner", revision: "O") + let firstCaptureGate = SpendDashboardStateBuildGate() + let secondCaptureGate = SpendDashboardStateBuildGate() + let thirdCaptureGate = SpendDashboardStateBuildGate() + let builder = SpendDashboardBuildScript([ + .init(mode: .forceRefresh, request: Self.request(initial, mode: .forceRefresh)), + .init( + mode: .captureOnly, + request: Self.request( + first, + mode: .captureOnly, + confirmedEmptySourceIDs: ["claude"]), + gate: firstCaptureGate), + .init( + mode: .captureOnly, + request: Self.request( + second, + mode: .captureOnly, + unavailableSourceIDs: ["claude"]), + gate: secondCaptureGate), + .init( + mode: .captureOnly, + request: Self.request( + third, + mode: .captureOnly, + inputs: [Self.input(id: "claude", provider: .claude, cost: 3)]), + gate: thirdCaptureGate), + .init( + mode: .captureOnly, + request: Self.request( + latest, + mode: .captureOnly, + confirmedEmptySourceIDs: ["claude"])), + ]) + let loader = SpendDashboardStateLoaderGate() + let controller = SpendDashboardController( + requestBuilder: { mode in await builder.next(mode) }, + loader: { request in await loader.load(request) }) + + controller.update(configuration: initial, force: true) + await Self.waitForLoader(loader) + controller.update(configuration: first) + await loader.resume(SpendDashboardLoadResult(inputs: [], failedSourceIDs: ["openai"])) + await Self.waitForBuildGate(firstCaptureGate) + controller.update(configuration: second) + await firstCaptureGate.resume() + await Self.waitForBuildGate(secondCaptureGate) + controller.update(configuration: third) + await secondCaptureGate.resume() + await Self.waitForBuildGate(thirdCaptureGate) + controller.update(configuration: latest) + await thirdCaptureGate.resume() + await Self.waitUntil { !controller.isRefreshing } + + #expect(builder.modes == [ + .forceRefresh, + .captureOnly, + .captureOnly, + .captureOnly, + .captureOnly, + ]) + #expect(await loader.forces == [true]) + #expect(controller.configuration == latest) + #expect(controller.failedSourceCount == 1) + #expect(controller.model.groups.isEmpty) + } + + @Test + func `D mandatory barrier catches delayed observation without later reload`() async { + let initial = Self.configuration(owner: "owner", revision: "R") + let latest = Self.configuration(owner: "owner", revision: "L") + let builder = SpendDashboardBuildScript([ + .init(mode: .forceRefresh, request: Self.request(initial, mode: .forceRefresh)), + .init( + mode: .captureOnly, + request: Self.request( + latest, + mode: .captureOnly, + inputs: [Self.input(id: "claude", provider: .claude, cost: 7)])), + ]) + let loader = SpendDashboardStateLoaderGate() + let controller = SpendDashboardController( + requestBuilder: { mode in await builder.next(mode) }, + loader: { request in await loader.load(request) }) + + controller.update(configuration: initial, force: true) + await Self.waitForLoader(loader) + await loader.resume(SpendDashboardLoadResult( + inputs: [Self.input(id: "claude", provider: .claude, cost: 1)], + failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + + let settledGeneration = controller.generation + controller.update(configuration: latest) + await Task.yield() + + #expect(builder.modes == [.forceRefresh, .captureOnly]) + #expect(await loader.forces == [true]) + #expect(controller.configuration == latest) + #expect(controller.generation == settledGeneration) + #expect(controller.model.groups.first?.totalCost == 7) + } + + @Test + func `E owner change during barrier discards carry and forces new owner`() async { + let firstOwner = Self.configuration(owner: "owner-one", revision: "R") + let firstOwnerLatest = Self.configuration(owner: "owner-one", revision: "S") + let secondOwner = Self.configuration(owner: "owner-two", revision: "L") + let learnedEmptyGate = SpendDashboardStateBuildGate() + let oldBarrierGate = SpendDashboardStateBuildGate() + let builder = SpendDashboardBuildScript([ + .init( + mode: .forceRefresh, + request: Self.request(firstOwner, mode: .forceRefresh, codexAccount: true)), + .init( + mode: .captureOnly, + request: Self.request( + firstOwner, + mode: .captureOnly, + confirmedEmptySourceIDs: ["claude"]), + gate: learnedEmptyGate), + .init( + mode: .captureOnly, + request: Self.request(firstOwnerLatest, mode: .captureOnly), + gate: oldBarrierGate), + .init(mode: .forceRefresh, request: Self.request(secondOwner, mode: .forceRefresh)), + .init( + mode: .captureOnly, + request: Self.request( + secondOwner, + mode: .captureOnly, + inputs: [Self.input(id: "claude", provider: .claude, cost: 8)])), + ]) + let loader = SpendDashboardStateLoaderGate() + let controller = SpendDashboardController( + requestBuilder: { mode in await builder.next(mode) }, + loader: { request in await loader.load(request) }) + + controller.update(configuration: firstOwner, force: true) + await Self.waitForLoader(loader) + await loader.resume(SpendDashboardLoadResult( + inputs: [Self.input(id: "codex:a", provider: .codex, cost: 5)], + failedSourceIDs: [])) + await Self.waitForBuildGate(learnedEmptyGate) + controller.update(configuration: firstOwnerLatest) + await learnedEmptyGate.resume() + await Self.waitForBuildGate(oldBarrierGate) + + controller.update(configuration: secondOwner) + await Self.waitForLoader(loader) + await loader.resume(SpendDashboardLoadResult( + inputs: [Self.input(id: "claude", provider: .claude, cost: 7)], + failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + await oldBarrierGate.resume() + await Task.yield() + + #expect(builder.modes == [.forceRefresh, .captureOnly, .captureOnly, .forceRefresh, .captureOnly]) + #expect(await loader.forces == [true, true]) + #expect(controller.configuration == secondOwner) + #expect(controller.model.groups.first?.totalCost == 8) + #expect(controller.model.groups.flatMap(\.providers).allSatisfy { $0.id != "codex:a" }) + } + + @Test + func `F confirmed empty capture wins over forced provider success`() async { + let configuration = Self.configuration(owner: "owner", revision: "R") + let oldInput = Self.input(id: "claude", provider: .claude, cost: 4) + let builder = SpendDashboardBuildScript([ + .init(mode: .refreshMissing, request: Self.request(configuration, mode: .refreshMissing)), + .init(mode: .forceRefresh, request: Self.request(configuration, mode: .forceRefresh)), + .init( + mode: .captureOnly, + request: Self.request( + configuration, + mode: .captureOnly, + confirmedEmptySourceIDs: ["claude"])), + ]) + let loader = SpendDashboardStateLoaderGate() + let controller = SpendDashboardController( + requestBuilder: { mode in await builder.next(mode) }, + loader: { request in await loader.load(request) }) + + controller.update(configuration: configuration) + await Self.waitForLoader(loader) + await loader.resume(SpendDashboardLoadResult(inputs: [oldInput], failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + + controller.refresh() + await Self.waitForLoader(loader) + await loader.resume(SpendDashboardLoadResult( + inputs: [Self.input(id: "claude", provider: .claude, cost: 6)], + failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + + #expect(builder.modes == [.refreshMissing, .forceRefresh, .captureOnly]) + #expect(await loader.forces == [false, true]) + #expect(controller.model.groups.isEmpty) + #expect(controller.failedSourceCount == 0) + } + + @Test + func `G forced Codex invalidation suppresses stale capture and retained row`() async { + let configuration = Self.configuration(owner: "owner", revision: "R") + let codexInput = Self.input(id: "codex:a", provider: .codex, cost: 4) + let builder = SpendDashboardBuildScript([ + .init(mode: .refreshMissing, request: Self.request(configuration, mode: .refreshMissing)), + .init( + mode: .forceRefresh, + request: Self.request(configuration, mode: .forceRefresh, codexAccount: true)), + .init( + mode: .captureOnly, + request: Self.request( + configuration, + mode: .captureOnly, + inputs: [Self.input(id: "codex:a", provider: .codex, cost: 99)])), + ]) + let loader = SpendDashboardStateLoaderGate() + let controller = SpendDashboardController( + requestBuilder: { mode in await builder.next(mode) }, + loader: { request in await loader.load(request) }) + + controller.update(configuration: configuration) + await Self.waitForLoader(loader) + await loader.resume(SpendDashboardLoadResult(inputs: [codexInput], failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + + controller.refresh() + await Self.waitForLoader(loader) + await loader.resume(SpendDashboardLoadResult( + inputs: [], + failedSourceIDs: ["codex:a"], + invalidatedSourceIDs: ["codex:a"])) + await Self.waitUntil { !controller.isRefreshing } + + #expect(builder.modes == [.refreshMissing, .forceRefresh, .captureOnly]) + #expect(await loader.forces == [false, true]) + #expect(controller.model.groups.isEmpty) + #expect(controller.failedSourceCount == 1) + } + + @Test + func `H ordinary update uses refresh missing and one loader without barrier`() async { + let configuration = Self.configuration(owner: "owner", revision: "R") + let input = Self.input(id: "claude", provider: .claude, cost: 3) + let builder = SpendDashboardBuildScript([ + .init( + mode: .refreshMissing, + request: Self.request(configuration, mode: .refreshMissing, inputs: [input])), + ]) + let loader = SpendDashboardStateLoaderGate() + let controller = SpendDashboardController( + requestBuilder: { mode in await builder.next(mode) }, + loader: { request in await loader.load(request) }) + + controller.update(configuration: configuration) + await Self.waitForLoader(loader) + await loader.resume(SpendDashboardLoadResult(inputs: [input], failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + + #expect(builder.modes == [.refreshMissing]) + #expect(await loader.forces == [false]) + #expect(controller.generation == 1) + #expect(controller.model.groups.first?.totalCost == 3) + } + + @Test + func `I empty provider published during Codex scan clears retained spend without later reload`() async { + let initial = Self.configuration(owner: "owner", revision: "R") + let confirmedEmpty = Self.configuration(owner: "owner", revision: "E") + let oldProviderInput = Self.input(id: "claude", provider: .claude, cost: 4) + let codexInput = Self.input(id: "codex:a", provider: .codex, cost: 2) + let builder = SpendDashboardBuildScript([ + .init( + mode: .refreshMissing, + request: Self.request(initial, mode: .refreshMissing, inputs: [oldProviderInput])), + .init( + mode: .forceRefresh, + request: Self.request( + initial, + mode: .forceRefresh, + unavailableSourceIDs: ["claude"], + codexAccount: true)), + .init( + mode: .captureOnly, + request: Self.request( + confirmedEmpty, + mode: .captureOnly, + confirmedEmptySourceIDs: ["claude"])), + ]) + let codexGate = SpendDashboardStateCodexGate() + let loaderRecorder = SpendDashboardStateLoadRecorder() + let controller = SpendDashboardController( + requestBuilder: { mode in await builder.next(mode) }, + loader: { request in + await loaderRecorder.record(request) + return await SpendDashboardSource.load(request, codexSnapshotLoader: { _ in + await codexGate.load() + }) + }) + + controller.update(configuration: initial) + await Self.waitUntil { !controller.isRefreshing } + #expect(controller.model.groups.first?.totalCost == 4) + + controller.refresh() + await Self.waitForCodexGate(codexGate) + controller.update(configuration: confirmedEmpty) + await codexGate.resume(codexInput.snapshot) + await Self.waitUntil { !controller.isRefreshing } + + let settledGeneration = controller.generation + let settledLoadCount = await loaderRecorder.count + controller.update(configuration: confirmedEmpty) + await Task.yield() + + #expect(builder.modes == [.refreshMissing, .forceRefresh, .captureOnly]) + #expect(settledLoadCount == 2) + #expect(await loaderRecorder.count == settledLoadCount) + #expect(controller.generation == settledGeneration) + #expect(controller.configuration == confirmedEmpty) + #expect(controller.failedSourceCount == 1) + #expect(controller.model.groups.first?.totalCost == 2) + #expect(controller.model.groups.flatMap(\.providers).map(\.id) == ["codex:a"]) + } + + @Test + func `J forced empty survives unavailable capture churn without restoring old spend`() async { + let initial = Self.configuration(owner: "owner", revision: "R") + let unavailable = Self.configuration(owner: "owner", revision: "U") + let latest = Self.configuration(owner: "owner", revision: "M") + let oldProviderInput = Self.input(id: "claude", provider: .claude, cost: 4) + let codexInput = Self.input(id: "codex:a", provider: .codex, cost: 2) + let captureGate = SpendDashboardStateBuildGate() + let builder = SpendDashboardBuildScript([ + .init( + mode: .refreshMissing, + request: Self.request(initial, mode: .refreshMissing, inputs: [oldProviderInput])), + .init( + mode: .forceRefresh, + request: Self.request( + initial, + mode: .forceRefresh, + confirmedEmptySourceIDs: ["claude"], + codexAccount: true)), + .init( + mode: .captureOnly, + request: Self.request( + unavailable, + mode: .captureOnly, + unavailableSourceIDs: ["claude"]), + gate: captureGate), + .init( + mode: .captureOnly, + request: Self.request( + latest, + mode: .captureOnly, + unavailableSourceIDs: ["claude"])), + ]) + let codexGate = SpendDashboardStateCodexGate() + let loaderRecorder = SpendDashboardStateLoadRecorder() + let controller = SpendDashboardController( + requestBuilder: { mode in await builder.next(mode) }, + loader: { request in + await loaderRecorder.record(request) + return await SpendDashboardSource.load(request, codexSnapshotLoader: { _ in + await codexGate.load() + }) + }) + + controller.update(configuration: initial) + await Self.waitUntil { !controller.isRefreshing } + #expect(controller.model.groups.first?.totalCost == 4) + + controller.refresh() + await Self.waitForCodexGate(codexGate) + controller.update(configuration: unavailable) + await codexGate.resume(codexInput.snapshot) + await Self.waitForBuildGate(captureGate) + controller.update(configuration: latest) + await captureGate.resume() + await Self.waitUntil { !controller.isRefreshing } + + let settledGeneration = controller.generation + controller.update(configuration: latest) + await Task.yield() + + #expect(builder.modes == [.refreshMissing, .forceRefresh, .captureOnly, .captureOnly]) + #expect(await loaderRecorder.forces == [false, true]) + #expect(controller.generation == settledGeneration) + #expect(controller.configuration == latest) + #expect(controller.failedSourceCount == 1) + #expect(controller.model.groups.first?.totalCost == 2) + #expect(controller.model.groups.flatMap(\.providers).map(\.id) == ["codex:a"]) + } + + @Test + func `K learned empty survives superseded capture then unavailable barrier`() async { + let initial = Self.configuration(owner: "owner", revision: "R") + let confirmedEmpty = Self.configuration(owner: "owner", revision: "E") + let unavailable = Self.configuration(owner: "owner", revision: "U") + let oldProviderInput = Self.input(id: "claude", provider: .claude, cost: 4) + let forcedProviderInput = Self.input(id: "claude", provider: .claude, cost: 6) + let learnedEmptyGate = SpendDashboardStateBuildGate() + let builder = SpendDashboardBuildScript([ + .init(mode: .refreshMissing, request: Self.request(initial, mode: .refreshMissing)), + .init(mode: .forceRefresh, request: Self.request(initial, mode: .forceRefresh)), + .init( + mode: .captureOnly, + request: Self.request( + confirmedEmpty, + mode: .captureOnly, + confirmedEmptySourceIDs: ["claude"]), + gate: learnedEmptyGate), + .init( + mode: .captureOnly, + request: Self.request( + unavailable, + mode: .captureOnly, + unavailableSourceIDs: ["claude"])), + ]) + let loader = SpendDashboardStateLoaderGate() + let controller = SpendDashboardController( + requestBuilder: { mode in await builder.next(mode) }, + loader: { request in await loader.load(request) }) + + controller.update(configuration: initial) + await Self.waitForLoader(loader) + await loader.resume(SpendDashboardLoadResult(inputs: [oldProviderInput], failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + + controller.refresh() + await Self.waitForLoader(loader) + controller.update(configuration: confirmedEmpty) + await loader.resume(SpendDashboardLoadResult(inputs: [forcedProviderInput], failedSourceIDs: [])) + await Self.waitForBuildGate(learnedEmptyGate) + controller.update(configuration: unavailable) + await learnedEmptyGate.resume() + await Self.waitUntil { !controller.isRefreshing } + + let settledGeneration = controller.generation + controller.update(configuration: unavailable) + await Task.yield() + + #expect(builder.modes == [.refreshMissing, .forceRefresh, .captureOnly, .captureOnly]) + #expect(await loader.forces == [false, true]) + #expect(controller.generation == settledGeneration) + #expect(controller.configuration == unavailable) + #expect(controller.failedSourceCount == 1) + #expect(controller.model.groups.isEmpty) + } + + @Test + func `L fresh nonempty after empty survives later unavailable despite forced failure`() async { + let initial = Self.configuration(owner: "owner", revision: "R") + let confirmedEmpty = Self.configuration(owner: "owner", revision: "E") + let fresh = Self.configuration(owner: "owner", revision: "N") + let unavailable = Self.configuration(owner: "owner", revision: "U") + let oldProviderInput = Self.input(id: "claude", provider: .claude, cost: 4) + let freshProviderInput = Self.input(id: "claude", provider: .claude, cost: 7) + let emptyGate = SpendDashboardStateBuildGate() + let freshGate = SpendDashboardStateBuildGate() + let builder = SpendDashboardBuildScript([ + .init(mode: .refreshMissing, request: Self.request(initial, mode: .refreshMissing)), + .init(mode: .forceRefresh, request: Self.request(initial, mode: .forceRefresh)), + .init( + mode: .captureOnly, + request: Self.request( + confirmedEmpty, + mode: .captureOnly, + confirmedEmptySourceIDs: ["claude"]), + gate: emptyGate), + .init( + mode: .captureOnly, + request: Self.request( + fresh, + mode: .captureOnly, + inputs: [freshProviderInput]), + gate: freshGate), + .init( + mode: .captureOnly, + request: Self.request( + unavailable, + mode: .captureOnly, + unavailableSourceIDs: ["claude"])), + ]) + let loader = SpendDashboardStateLoaderGate() + let controller = SpendDashboardController( + requestBuilder: { mode in await builder.next(mode) }, + loader: { request in await loader.load(request) }) + + controller.update(configuration: initial) + await Self.waitForLoader(loader) + await loader.resume(SpendDashboardLoadResult(inputs: [oldProviderInput], failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + + controller.refresh() + await Self.waitForLoader(loader) + controller.update(configuration: confirmedEmpty) + await loader.resume(SpendDashboardLoadResult(inputs: [], failedSourceIDs: ["claude"])) + await Self.waitForBuildGate(emptyGate) + controller.update(configuration: fresh) + await emptyGate.resume() + await Self.waitForBuildGate(freshGate) + controller.update(configuration: unavailable) + await freshGate.resume() + await Self.waitUntil { !controller.isRefreshing } + + let settledGeneration = controller.generation + controller.update(configuration: unavailable) + await Task.yield() + + #expect(builder.modes == [ + .refreshMissing, + .forceRefresh, + .captureOnly, + .captureOnly, + .captureOnly, + ]) + #expect(await loader.forces == [false, true]) + #expect(controller.generation == settledGeneration) + #expect(controller.configuration == unavailable) + #expect(controller.failedSourceCount == 1) + #expect(controller.model.groups.first?.totalCost == 7) + #expect(controller.model.groups.flatMap(\.providers).map(\.id) == ["claude"]) + } + + @Test + func `M newer source publication supersedes failed force stale row`() async { + let initial = Self.configuration(owner: "owner", revision: "claude:snapshot:1:old") + let latest = Self.configuration(owner: "owner", revision: "claude:snapshot:2:fresh") + let oldProviderInput = Self.input(id: "claude", provider: .claude, cost: 4) + let freshProviderInput = Self.input(id: "claude", provider: .claude, cost: 7) + let builder = SpendDashboardBuildScript([ + .init( + mode: .refreshMissing, + request: Self.request(initial, mode: .refreshMissing, inputs: [oldProviderInput])), + .init(mode: .forceRefresh, request: Self.request(initial, mode: .forceRefresh)), + .init( + mode: .captureOnly, + request: Self.request(latest, mode: .captureOnly, inputs: [freshProviderInput])), + ]) + let loader = SpendDashboardStateLoaderGate() + let controller = SpendDashboardController( + requestBuilder: { mode in await builder.next(mode) }, + loader: { request in await loader.load(request) }) + + controller.update(configuration: initial) + await Self.waitForLoader(loader) + await loader.resume(SpendDashboardLoadResult(inputs: [oldProviderInput], failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + + controller.refresh() + await Self.waitForLoader(loader) + await loader.resume(SpendDashboardLoadResult(inputs: [], failedSourceIDs: ["claude"])) + await Self.waitUntil { !controller.isRefreshing } + + #expect(builder.modes == [.refreshMissing, .forceRefresh, .captureOnly]) + #expect(await loader.forces == [false, true]) + #expect(controller.configuration == latest) + #expect(controller.failedSourceCount == 1) + #expect(controller.model.groups.first?.totalCost == 7) + #expect(controller.model.groups.flatMap(\.providers).map(\.id) == ["claude"]) + } + + private static func configuration(owner: String, revision: String) -> SpendDashboardConfiguration { + SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.codex.rawValue, UsageProvider.claude.rawValue], + codexAccountIdentities: ["a|\(owner)"], + sourceOwnershipFingerprints: ["claude:\(owner)"], + sourceRevisions: [revision]) + } + + private static func request( + _ configuration: SpendDashboardConfiguration, + mode: SpendDashboardRequestBuildMode, + inputs: [SpendDashboardModel.ProviderInput] = [], + unavailableSourceIDs: Set = [], + confirmedEmptySourceIDs: Set = [], + codexAccount: Bool = false) -> SpendDashboardLoadRequest + { + SpendDashboardLoadRequest( + configuration: configuration, + capturedInputs: inputs, + unavailableSourceIDs: unavailableSourceIDs, + confirmedEmptySourceIDs: confirmedEmptySourceIDs, + codexRequests: codexAccount ? [self.codexRequest()] : [], + now: Date(timeIntervalSince1970: 1_784_179_200), + force: mode.forcesLoader) + } + + private static func codexRequest() -> CodexSpendScanRequest { + CodexSpendScanRequest( + id: "a", + displayName: "Codex", + source: .profileHome(path: "/synthetic/codex-a"), + homePath: "/synthetic/codex-a", + authFingerprint: nil, + authFileWasReadable: false, + cacheIdentity: "synthetic-a") + } + + private static func input( + id: String, + provider: UsageProvider, + cost: Double) -> SpendDashboardModel.ProviderInput + { + let entry = CostUsageDailyReport.Entry( + date: "2026-07-15", + inputTokens: nil, + outputTokens: nil, + totalTokens: 10, + costUSD: cost, + modelsUsed: nil, + modelBreakdowns: nil) + let snapshot = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: 10, + last30DaysCostUSD: cost, + daily: [entry], + updatedAt: Date(timeIntervalSince1970: 1_784_179_200)) + return SpendDashboardModel.ProviderInput( + id: id, + provider: provider, + displayName: provider.rawValue, + modelProviderName: provider == .codex ? "Codex" : nil, + snapshot: snapshot) + } + + private static func waitForLoader(_ loader: SpendDashboardStateLoaderGate) async { + for _ in 0..<1000 { + if await loader.pendingCount == 1 { + return + } + await Task.yield() + } + Issue.record("Timed out waiting for dashboard loader") + } + + private static func waitForBuildGate(_ gate: SpendDashboardStateBuildGate) async { + for _ in 0..<1000 { + if await gate.isSuspended { + return + } + await Task.yield() + } + Issue.record("Timed out waiting for dashboard build gate") + } + + private static func waitForCodexGate(_ gate: SpendDashboardStateCodexGate) async { + for _ in 0..<1000 { + if await gate.isSuspended { + return + } + await Task.yield() + } + Issue.record("Timed out waiting for dashboard Codex gate") + } + + private static func waitUntil(_ condition: @MainActor () -> Bool) async { + for _ in 0..<1000 { + if condition() { + return + } + await Task.yield() + } + Issue.record("Timed out waiting for dashboard controller") + } +} + +@MainActor +private final class SpendDashboardBuildScript { + struct Step { + let mode: SpendDashboardRequestBuildMode + let request: SpendDashboardLoadRequest + let gate: SpendDashboardStateBuildGate? + + init( + mode: SpendDashboardRequestBuildMode, + request: SpendDashboardLoadRequest, + gate: SpendDashboardStateBuildGate? = nil) + { + self.mode = mode + self.request = request + self.gate = gate + } + } + + private var steps: [Step] + private(set) var modes: [SpendDashboardRequestBuildMode] = [] + + init(_ steps: [Step]) { + self.steps = steps + } + + func next(_ mode: SpendDashboardRequestBuildMode) async -> SpendDashboardLoadRequest { + guard !self.steps.isEmpty else { + Issue.record("Unexpected dashboard build mode: \(mode)") + return SpendDashboardLoadRequest( + configuration: SpendDashboardConfiguration( + costUsageEnabled: false, + providerIDs: [], + codexAccountIdentities: []), + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [], + now: Date(timeIntervalSince1970: 1_784_179_200), + force: mode.forcesLoader) + } + let step = self.steps.removeFirst() + self.modes.append(mode) + #expect(mode == step.mode) + if let gate = step.gate { + await gate.suspend() + } + return step.request + } +} + +private actor SpendDashboardStateBuildGate { + private var continuation: CheckedContinuation? + + var isSuspended: Bool { + self.continuation != nil + } + + func suspend() async { + await withCheckedContinuation { continuation in + self.continuation = continuation + } + } + + func resume() { + self.continuation?.resume() + self.continuation = nil + } +} + +private actor SpendDashboardStateLoaderGate { + private var continuations: [CheckedContinuation] = [] + private(set) var forces: [Bool] = [] + + var pendingCount: Int { + self.continuations.count + } + + func load(_ request: SpendDashboardLoadRequest) async -> SpendDashboardLoadResult { + self.forces.append(request.force) + return await withCheckedContinuation { continuation in + self.continuations.append(continuation) + } + } + + func resume(_ result: SpendDashboardLoadResult) { + self.continuations.removeFirst().resume(returning: result) + } +} + +private actor SpendDashboardStateCodexGate { + private var continuation: CheckedContinuation? + + var isSuspended: Bool { + self.continuation != nil + } + + func load() async -> CostUsageTokenSnapshot { + await withCheckedContinuation { continuation in + self.continuation = continuation + } + } + + func resume(_ snapshot: CostUsageTokenSnapshot) { + self.continuation?.resume(returning: snapshot) + self.continuation = nil + } +} + +private actor SpendDashboardStateLoadRecorder { + private(set) var count = 0 + private(set) var forces: [Bool] = [] + + func record(_ request: SpendDashboardLoadRequest) { + self.count += 1 + self.forces.append(request.force) + } +} diff --git a/Tests/CodexBarTests/SpendDashboardModelTests.swift b/Tests/CodexBarTests/SpendDashboardModelTests.swift new file mode 100644 index 0000000000..e8b092416e --- /dev/null +++ b/Tests/CodexBarTests/SpendDashboardModelTests.swift @@ -0,0 +1,867 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct SpendDashboardModelTests { + @Test + func `count labels avoid plural agreement and localize numbers`() { + CodexBarLocalizationOverride.$appLanguage.withValue("en") { + #expect(spendDashboardRefreshFailureText(1) == "Refresh failures: 1") + #expect(spendDashboardRefreshFailureText(2) == "Refresh failures: 2") + #expect(spendDashboardCoverageText(covered: 3, requested: 7) == "Coverage: 3 / 7") + } + CodexBarLocalizationOverride.$appLanguage.withValue("de") { + #expect(spendDashboardRefreshFailureText(1234) == "Fehlgeschlagene Aktualisierungen: 1.234") + #expect(spendDashboardCoverageText(covered: 3, requested: 30) == "Abdeckung: 3 / 30") + } + CodexBarLocalizationOverride.$appLanguage.withValue("fa") { + #expect(codexBarLocalizedInteger(12) == "۱۲") + #expect(spendDashboardDayRangeText(7) == "۷ روز") + #expect(spendDashboardDayRangeText(30) == "۳۰ روز") + #expect(spendDashboardRankText(1234) == "#۱٬۲۳۴") + #expect(spendDashboardRefreshFailureText(2) == "\(L("Refresh failures")): ۲") + #expect(spendDashboardCoverageText(covered: 3, requested: 30) == "پوشش: ۳ / ۳۰") + } + } + + @Test + func `Codex account indices use app locale numerals`() throws { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("SpendDashboardModelTests-index-locale-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: home) } + let account = CodexVisibleAccount( + id: "locale-account", + email: "locale@example.com", + authFingerprint: nil, + storedAccountID: nil, + selectionSource: .profileHome(path: home.path), + isActive: true, + isLive: true, + canReauthenticate: true, + canRemove: true) + + let persian = CodexBarLocalizationOverride.$appLanguage.withValue("fa") { + SpendDashboardSource.codexRequest( + account: account, + homePath: home.path, + providerName: "Codex", + index: 1, + count: 2)?.displayName + } + let arabic = CodexBarLocalizationOverride.$appLanguage.withValue("ar") { + SpendDashboardSource.codexRequest( + account: account, + homePath: home.path, + providerName: "Codex", + index: 1, + count: 2)?.displayName + } + + #expect(persian == "Codex · #۲") + #expect(arabic == "Codex · #٢") + } + + @Test + func `dashboard source contract includes only cost capable descriptors`() { + let providers = Set(ProviderDescriptorRegistry.all + .filter(\.tokenCost.supportsTokenCost) + .map(\.id)) + #expect(providers == [.codex, .claude, .vertexai, .openai, .mistral, .bedrock]) + } + + @Test + func `native currencies stay separate and rank only within their currency`() throws { + let model = SpendDashboardModel.build( + inputs: [ + Self.input(id: "usd-low", provider: .claude, currency: "usd", cost: 2), + Self.input(id: "eur", provider: .openai, currency: "EUR", cost: 100), + Self.input(id: "usd-high", provider: .codex, currency: "USD", cost: 8), + ], + requestedDays: 30, + now: Self.now, + calendar: Self.calendar) + + #expect(model.groups.map(\.currencyCode) == ["EUR", "USD"]) + let eur = try #require(model.groups.first) + #expect(eur.providers.map(\.id) == ["eur"]) + #expect(eur.providers.map(\.rank) == [1]) + #expect(eur.totalCost == 100) + #expect(eur.models.map(\.modelName) == ["test-model"]) + #expect(eur.models.map(\.totalCost) == [100]) + let usd = try #require(model.groups.last) + #expect(usd.providers.map(\.id) == ["usd-high", "usd-low"]) + #expect(usd.providers.map(\.rank) == [1, 2]) + #expect(usd.totalCost == 10) + #expect(usd.models.allSatisfy { $0.modelName == "test-model" }) + #expect(usd.models.compactMap(\.totalCost).reduce(0, +) == 10) + } + + @Test + func `windows anchor to injected now and report covered days honestly`() throws { + let snapshot = Self.snapshot( + currency: "USD", + entries: [ + Self.entry(day: "2026-07-16", cost: 1), + Self.entry(day: "2026-07-09", cost: 2), + Self.entry(day: "2026-07-08", cost: 4), + Self.entry(day: "2026-08-01", cost: 100), + ]) + let input = SpendDashboardModel.ProviderInput(provider: .claude, displayName: "Claude", snapshot: snapshot) + + let sevenDays = SpendDashboardModel.build( + inputs: [input], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar) + let group = try #require(sevenDays.groups.first) + #expect(group.totalCost == 1) + #expect(group.coveredDayCount == 7) + #expect(group.providers.first?.coveredDayCount == 7) + + let thirtyDays = SpendDashboardModel.build( + inputs: [input], + requestedDays: 30, + now: Self.now, + calendar: Self.calendar) + #expect(thirtyDays.groups.first?.totalCost == 7) + #expect(thirtyDays.groups.first?.coveredDayCount == 30) + + let futureSnapshot = Self.snapshot( + currency: "USD", + entries: [Self.entry(day: "2026-07-16", cost: 1)], + updatedAt: Date(timeIntervalSince1970: 1_900_000_000)) + let futureModel = SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: futureSnapshot)], + requestedDays: 30, + now: Self.now, + calendar: Self.calendar) + #expect(futureModel.groups.first?.coveredDayCount == 0) + + let shortSnapshot = Self.snapshot( + currency: "USD", + entries: [Self.entry(day: "2026-07-16", cost: 1)], + historyDays: 7) + let shortModel = SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: shortSnapshot)], + requestedDays: 30, + now: Self.now, + calendar: Self.calendar) + #expect(shortModel.groups.first?.coveredDayCount == 7) + } + + @Test + func `chart domain uses the exact requested window despite sparse points`() throws { + let input = SpendDashboardModel.ProviderInput( + provider: .claude, + displayName: "Claude", + snapshot: Self.snapshot( + currency: "USD", + entries: [Self.entry(day: "2026-07-16", cost: 1)])) + let sevenDays = try #require(SpendDashboardModel.build( + inputs: [input], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + let thirtyDays = try #require(SpendDashboardModel.build( + inputs: [input], + requestedDays: 30, + now: Self.now, + calendar: Self.calendar).groups.first) + let anchor = Self.calendar.startOfDay(for: Self.now) + let sevenDayStart = try #require(Self.calendar.date(byAdding: .day, value: -6, to: anchor)) + let thirtyDayStart = try #require(Self.calendar.date(byAdding: .day, value: -29, to: anchor)) + let end = try #require(Self.calendar.date(byAdding: .day, value: 1, to: anchor)) + + #expect(sevenDays.dailyPoints.map(\.day) == [anchor]) + #expect(thirtyDays.dailyPoints.map(\.day) == [anchor]) + #expect(sevenDays.chartDomain == sevenDayStart...end) + #expect(thirtyDays.chartDomain == thirtyDayStart...end) + } + + @Test + func `currency coverage intersects disjoint provider windows`() throws { + let earlier = try SpendDashboardModel.ProviderInput( + id: "earlier", + provider: .claude, + displayName: "Earlier", + snapshot: Self.snapshot( + currency: "USD", + entries: [Self.entry(day: "2026-07-09", cost: 2)], + historyDays: 7, + updatedAt: #require(Self.calendar.date(byAdding: .day, value: -7, to: Self.now)))) + let later = SpendDashboardModel.ProviderInput( + id: "later", + provider: .codex, + displayName: "Later", + snapshot: Self.snapshot( + currency: "USD", + entries: [Self.entry(day: "2026-07-16", cost: 3)], + historyDays: 7)) + let group = try #require(SpendDashboardModel.build( + inputs: [earlier, later], + requestedDays: 30, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.coveredDayCount == 0) + #expect(group.providers.allSatisfy { $0.coveredDayCount == 7 }) + #expect(group.totalCost == 5) + #expect(group.providers.map(\.id) == ["later", "earlier"]) + #expect(group.dailyPoints.map(\.sourceID) == ["earlier", "later"]) + } + + @Test + func `currency coverage counts only overlapping provider days`() throws { + let earlier = try SpendDashboardModel.ProviderInput( + id: "earlier", + provider: .claude, + displayName: "Earlier", + snapshot: Self.snapshot( + currency: "USD", + entries: [Self.entry(day: "2026-07-12", cost: 2)], + historyDays: 7, + updatedAt: #require(Self.calendar.date(byAdding: .day, value: -4, to: Self.now)))) + let later = SpendDashboardModel.ProviderInput( + id: "later", + provider: .codex, + displayName: "Later", + snapshot: Self.snapshot( + currency: "USD", + entries: [Self.entry(day: "2026-07-16", cost: 3)], + historyDays: 7)) + let group = try #require(SpendDashboardModel.build( + inputs: [earlier, later], + requestedDays: 30, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.coveredDayCount == 3) + #expect(group.providers.allSatisfy { $0.coveredDayCount == 7 }) + #expect(group.totalCost == 5) + } + + @Test + func `uncovered same currency source hides partial model ranking`() throws { + let covered = Self.input(id: "covered", provider: .claude, currency: "USD", cost: 4) + let uncovered = SpendDashboardModel.ProviderInput( + id: "uncovered", + provider: .codex, + displayName: "Uncovered", + snapshot: Self.snapshot( + currency: "USD", + entries: [Self.entry(day: "2026-08-01", cost: 10)], + historyDays: 1, + updatedAt: Date(timeIntervalSince1970: 1_785_542_400))) // 2026-08-01 00:00:00 UTC + let group = try #require(SpendDashboardModel.build( + inputs: [covered, uncovered], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.totalCost == nil) + #expect(group.totalTokens == nil) + #expect(group.modelHistoryCompleteness == .incomplete) + #expect(group.models.isEmpty) + } + + @Test + func `only uncovered source reports model breakdown unavailable`() throws { + let uncovered = SpendDashboardModel.ProviderInput( + provider: .claude, + displayName: "Claude", + snapshot: Self.snapshot( + currency: "USD", + entries: [Self.entry(day: "2026-08-01", cost: 10)], + historyDays: 1, + updatedAt: Date(timeIntervalSince1970: 1_785_542_400))) // 2026-08-01 00:00:00 UTC + let group = try #require(SpendDashboardModel.build( + inputs: [uncovered], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.coveredDayCount == 0) + #expect(group.totalCost == nil) + #expect(group.totalTokens == nil) + #expect(group.modelHistoryCompleteness == .incomplete) + #expect(group.models.isEmpty) + } + + @Test + func `uncovered source affects only its own currency model history`() throws { + let covered = Self.input(id: "covered", provider: .claude, currency: "USD", cost: 4) + let uncovered = SpendDashboardModel.ProviderInput( + id: "uncovered", + provider: .codex, + displayName: "Uncovered", + snapshot: Self.snapshot( + currency: "EUR", + entries: [Self.entry(day: "2026-08-01", cost: 10)], + historyDays: 1, + updatedAt: Date(timeIntervalSince1970: 1_785_542_400))) // 2026-08-01 00:00:00 UTC + let groups = SpendDashboardModel.build( + inputs: [covered, uncovered], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups + let eur = try #require(groups.first(where: { $0.currencyCode == "EUR" })) + let usd = try #require(groups.first(where: { $0.currencyCode == "USD" })) + + #expect(eur.modelHistoryCompleteness == .incomplete) + #expect(eur.models.isEmpty) + #expect(usd.modelHistoryCompleteness == .complete) + #expect(usd.models.map(\.totalCost) == [4]) + } + + @Test + func `ISO history stays Gregorian while preserving the injected timezone`() throws { + let timeZone = try #require(TimeZone(secondsFromGMT: 7 * 60 * 60)) + var gregorian = Calendar(identifier: .gregorian) + gregorian.timeZone = timeZone + let now = try #require(gregorian.date(from: DateComponents( + year: 2026, + month: 7, + day: 16, + hour: 12))) + var buddhist = Calendar(identifier: .buddhist) + buddhist.timeZone = timeZone + let snapshot = Self.snapshot( + currency: "USD", + entries: [Self.entry(day: "2026-07-16", cost: 4)], + updatedAt: now) + let group = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: snapshot)], + requestedDays: 7, + now: now, + calendar: buddhist).groups.first) + + #expect(group.totalCost == 4) + #expect(group.coveredDayCount == 7) + #expect(group.dailyPoints.map(\.day) == [gregorian.startOfDay(for: now)]) + } + + @Test + func `daily values aggregate once and produce deterministic nonoverlapping stacks`() throws { + let first = SpendDashboardModel.ProviderInput( + id: "a", + provider: .claude, + displayName: "Claude", + snapshot: Self.snapshot(currency: "USD", entries: [ + Self.entry(day: "2026-07-16", cost: 2), + Self.entry(day: "2026-07-16", cost: 3), + ])) + let second = SpendDashboardModel.ProviderInput( + id: "b", + provider: .codex, + displayName: "Codex", + snapshot: Self.snapshot(currency: "USD", entries: [Self.entry(day: "2026-07-16", cost: 4)])) + let group = try #require(SpendDashboardModel.build( + inputs: [second, first], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.dailyPoints.map(\.sourceID) == ["a", "b"]) + #expect(group.dailyPoints.map(\.cost) == [5, 4]) + #expect(group.dailyPoints.map(\.stackStart) == [0, 5]) + #expect(group.dailyPoints.map(\.stackEnd) == [5, 9]) + } + + @Test + func `invalid costs and arithmetic overflow never become spend`() throws { + let invalid = SpendDashboardModel.ProviderInput( + id: "invalid", + provider: .claude, + displayName: "Claude", + snapshot: Self.snapshot(currency: "USD", entries: [ + Self.entry(day: "2026-07-16", cost: -.infinity, tokens: .max), + Self.entry(day: "2026-07-15", cost: -.nan, tokens: .max), + Self.entry(day: "2026-07-14", cost: -1), + Self.entry(day: "2026-06-31", cost: 99), + ])) + let hugeA = Self.input(id: "huge-a", provider: .codex, currency: "USD", cost: .greatestFiniteMagnitude) + let hugeB = Self.input(id: "huge-b", provider: .openai, currency: "USD", cost: .greatestFiniteMagnitude) + let group = try #require(SpendDashboardModel.build( + inputs: [invalid, hugeA, hugeB], + requestedDays: 30, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.providers.first(where: { $0.id == "invalid" })?.totalCost == nil) + #expect(group.totalCost == nil) + #expect(group.totalTokens == nil) + #expect(group.dailyPoints.isEmpty) + } + + @Test + func `malformed date mixed with valid usage fails the source closed`() throws { + let snapshot = Self.snapshot(currency: "USD", entries: [ + Self.entry(day: "2026-07-16", cost: 4, tokens: 40), + Self.entry(day: "not-a-day", cost: 2, tokens: 20), + ]) + let group = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.providers.first?.totalCost == nil) + #expect(group.providers.first?.totalTokens == nil) + #expect(group.totalCost == nil) + #expect(group.totalTokens == nil) + #expect(group.modelHistoryCompleteness == .incomplete) + #expect(group.models.isEmpty) + #expect(group.dailyPoints.isEmpty) + } + + @Test + func `malformed date only with unknown usage is unavailable not zero`() throws { + let snapshot = Self.snapshot(currency: "USD", entries: [ + Self.entry(day: "2026-02-30", cost: nil, tokens: nil, model: nil), + ]) + let group = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.providers.first?.totalCost == nil) + #expect(group.providers.first?.totalTokens == nil) + #expect(group.totalCost == nil) + #expect(group.totalTokens == nil) + #expect(group.modelHistoryCompleteness == .incomplete) + #expect(group.models.isEmpty) + #expect(group.dailyPoints.isEmpty) + } + + @Test + func `explicit zero malformed date is ignored without affecting valid window rows`() throws { + let snapshot = Self.snapshot(currency: "USD", entries: [ + Self.entry(day: "malformed", cost: 0, tokens: 0, model: nil), + Self.entryWithBreakdowns( + day: "also-malformed", + totalCost: 0, + totalTokens: 0, + breakdowns: [.init(modelName: "zero", costUSD: 0, totalTokens: 0, requestCount: 0)]), + Self.entry(day: "2026-07-16", cost: 3, tokens: 30), + Self.entry(day: "2026-07-01", cost: 99, tokens: 990), + ]) + let group = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.providers.first?.totalCost == 3) + #expect(group.providers.first?.totalTokens == 30) + #expect(group.totalCost == 3) + #expect(group.totalTokens == 30) + #expect(group.modelHistoryCompleteness == .complete) + #expect(group.models.map(\.totalCost) == [3]) + #expect(group.dailyPoints.map(\.cost) == [3]) + } + + @Test + func `mixed invalid entry metrics make source and group totals unavailable`() throws { + let inputs = [ + SpendDashboardModel.ProviderInput( + id: "missing", + provider: .claude, + displayName: "Missing", + snapshot: Self.snapshot(currency: "USD", entries: [ + Self.entry(day: "2026-07-16", cost: 1, tokens: 1), + Self.entry(day: "2026-07-15", cost: nil, tokens: nil), + ])), + SpendDashboardModel.ProviderInput( + id: "negative", + provider: .codex, + displayName: "Negative", + snapshot: Self.snapshot(currency: "USD", entries: [ + Self.entry(day: "2026-07-16", cost: 1, tokens: 1), + Self.entry(day: "2026-07-15", cost: -1, tokens: -1), + ])), + SpendDashboardModel.ProviderInput( + id: "nonfinite", + provider: .openai, + displayName: "Nonfinite", + snapshot: Self.snapshot(currency: "USD", entries: [ + Self.entry(day: "2026-07-16", cost: 1, tokens: 1), + Self.entry(day: "2026-07-15", cost: .infinity, tokens: 1), + ])), + SpendDashboardModel.ProviderInput( + id: "overflow", + provider: .mistral, + displayName: "Overflow", + snapshot: Self.snapshot(currency: "USD", entries: [ + Self.entry(day: "2026-07-16", cost: .greatestFiniteMagnitude, tokens: .max), + Self.entry(day: "2026-07-15", cost: .greatestFiniteMagnitude, tokens: .max), + ])), + ] + let group = try #require(SpendDashboardModel.build( + inputs: inputs, + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.providers.allSatisfy { $0.totalCost == nil }) + #expect(group.providers.first(where: { $0.id == "nonfinite" })?.totalTokens == 2) + #expect(group.providers.filter { $0.id != "nonfinite" }.allSatisfy { $0.totalTokens == nil }) + #expect(group.totalCost == nil) + #expect(group.totalTokens == nil) + } + + @Test + func `invalid model breakdowns make model history unavailable`() throws { + let snapshot = Self.snapshot(currency: "USD", entries: [ + Self.entryWithBreakdowns( + day: "2026-07-16", + breakdowns: [ + .init(modelName: "complete", costUSD: 2, totalTokens: 2), + .init(modelName: "missing", costUSD: 4, totalTokens: 4), + .init(modelName: "negative", costUSD: 4, totalTokens: 4), + .init(modelName: "overflow", costUSD: .greatestFiniteMagnitude, totalTokens: .max), + ]), + Self.entryWithBreakdowns( + day: "2026-07-15", + breakdowns: [ + .init(modelName: "complete", costUSD: 1, totalTokens: 1), + .init(modelName: "missing", costUSD: nil, totalTokens: nil), + .init(modelName: "negative", costUSD: -1, totalTokens: -1), + .init(modelName: "overflow", costUSD: .greatestFiniteMagnitude, totalTokens: .max), + ]), + ]) + let group = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.modelHistoryCompleteness == .incomplete) + #expect(group.models.isEmpty) + } + + @Test + func `partial contributing model history is unavailable instead of a lower bound`() throws { + let snapshot = Self.snapshot(currency: "USD", entries: [ + Self.entry(day: "2026-07-16", cost: 4, tokens: 40, model: nil), + Self.entry(day: "2026-07-15", cost: 2, tokens: 20), + ]) + let group = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.modelHistoryCompleteness == .incomplete) + #expect(group.models.isEmpty) + #expect(group.totalCost == 6) + } + + @Test + func `zero usage without a breakdown keeps model history complete`() throws { + let snapshot = Self.snapshot(currency: "USD", entries: [ + Self.entryWithBreakdowns(day: "2026-07-16", breakdowns: []), + Self.entry(day: "2026-07-15", cost: 2, tokens: 20), + ]) + let group = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.modelHistoryCompleteness == .complete) + #expect(group.models.map(\.modelName) == ["test-model"]) + #expect(group.models.map(\.totalCost) == [2]) + } + + @Test + func `unknown usage without a breakdown makes model history unavailable`() throws { + let snapshot = Self.snapshot(currency: "USD", entries: [ + Self.entry(day: "2026-07-16", cost: nil, tokens: nil, model: nil), + Self.entry(day: "2026-07-15", cost: 2, tokens: 20), + ]) + let group = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.modelHistoryCompleteness == .incomplete) + #expect(group.models.isEmpty) + } + + @Test + func `blank model names fail closed unless their usage is explicitly zero`() throws { + let incomplete = Self.snapshot(currency: "USD", entries: [Self.entryWithBreakdowns( + day: "2026-07-16", + totalCost: 3, + totalTokens: 30, + breakdowns: [ + .init(modelName: " \n ", costUSD: 2, totalTokens: 20), + .init(modelName: "named", costUSD: 1, totalTokens: 10), + ])]) + let complete = Self.snapshot(currency: "USD", entries: [Self.entryWithBreakdowns( + day: "2026-07-16", + totalCost: 1, + totalTokens: 10, + breakdowns: [ + .init(modelName: " \n ", costUSD: 0, totalTokens: 0), + .init(modelName: "named", costUSD: 1, totalTokens: 10), + ])]) + let incompleteGroup = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: incomplete)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + let completeGroup = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: complete)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(incompleteGroup.modelHistoryCompleteness == .incomplete) + #expect(incompleteGroup.models.isEmpty) + #expect(completeGroup.modelHistoryCompleteness == .complete) + #expect(completeGroup.models.map(\.modelName) == ["named"]) + } + + @Test + func `partial named breakdown totals make model history unavailable`() throws { + let snapshot = Self.snapshot(currency: "USD", entries: [Self.entryWithBreakdowns( + day: "2026-07-16", + totalCost: 10, + totalTokens: 100, + breakdowns: [.init(modelName: "partial", costUSD: 4, totalTokens: 40)])]) + let group = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.modelHistoryCompleteness == .incomplete) + #expect(group.models.isEmpty) + } + + @Test + func `incomplete duplicate day sources do not render partial chart stacks`() throws { + let missing = SpendDashboardModel.ProviderInput( + id: "missing", + provider: .claude, + displayName: "Missing", + snapshot: Self.snapshot(currency: "USD", entries: [ + Self.entry(day: "2026-07-16", cost: 2), + Self.entry(day: "2026-07-16", cost: nil), + ])) + let overflow = SpendDashboardModel.ProviderInput( + id: "overflow", + provider: .codex, + displayName: "Overflow", + snapshot: Self.snapshot(currency: "USD", entries: [ + Self.entry(day: "2026-07-16", cost: .greatestFiniteMagnitude), + Self.entry(day: "2026-07-16", cost: .greatestFiniteMagnitude), + ])) + let complete = Self.input(id: "complete", provider: .openai, currency: "USD", cost: 3) + let group = try #require(SpendDashboardModel.build( + inputs: [missing, overflow, complete], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.dailyPoints.map(\.sourceID) == ["complete"]) + #expect(group.dailyPoints.map(\.cost) == [3]) + #expect(group.dailyPoints.map(\.stackStart) == [0]) + #expect(group.dailyPoints.map(\.stackEnd) == [3]) + } + + @Test + func `covered inactive sources contribute zero without hiding active totals`() throws { + let inactive = SpendDashboardModel.ProviderInput( + id: "inactive", + provider: .claude, + displayName: "Inactive", + snapshot: Self.snapshot(currency: "USD", entries: [ + Self.entry(day: "2026-07-16", cost: 0, tokens: 0, model: nil), + ])) + let active = Self.input(id: "active", provider: .codex, currency: "USD", cost: 10) + let group = try #require(SpendDashboardModel.build( + inputs: [inactive, active], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + let inactiveRow = try #require(group.providers.first(where: { $0.id == "inactive" })) + #expect(inactiveRow.totalCost == 0) + #expect(inactiveRow.totalTokens == 0) + #expect(inactiveRow.coveredDayCount == 7) + #expect(group.totalCost == 10) + #expect(group.totalTokens == 10) + #expect(group.providers.map(\.id) == ["active", "inactive"]) + #expect(group.modelHistoryCompleteness == .complete) + #expect(group.models.map(\.totalCost) == [10]) + } + + @Test + func `unpriced history stays unavailable instead of becoming zero`() throws { + let snapshot = Self.snapshot( + currency: "CAD", + entries: [Self.entry(day: "2026-07-16", cost: nil, tokens: 12)]) + let model = SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar) + let group = try #require(model.groups.first) + + #expect(group.totalCost == nil) + #expect(group.totalTokens == 12) + #expect(group.providers.first?.totalCost == nil) + } + + @Test + func `Codex requests freeze source home auth and cache identity`() throws { + let id = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE")) + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("SpendDashboardModelTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: home) } + let account = CodexVisibleAccount( + id: "account", + email: "test@example.com", + authFingerprint: "ABC123", + storedAccountID: id, + selectionSource: .managedAccount(id: id), + isActive: true, + isLive: false, + canReauthenticate: true, + canRemove: true) + let request = try #require(SpendDashboardSource.codexRequest( + account: account, + homePath: home.path, + providerName: "Codex", + index: 1, + count: 2)) + + #expect(request.source == .managedAccount(id: id)) + #expect(request.homePath == home.path) + #expect(request.authFingerprint == "abc123") + #expect(!request.authFileWasReadable) + #expect(request.displayName == "Codex · #2") + #expect(request.cacheIdentity.count == 64) + #expect(SpendDashboardSource.scanDays == 30) + #expect(SpendDashboardSource.codexRequest( + account: account, + homePath: "relative/path", + providerName: "Codex", + index: 0, + count: 1) == nil) + #expect(SpendDashboardSource.codexRequest( + account: account, + homePath: home.appendingPathComponent("missing", isDirectory: true).path, + providerName: "Codex", + index: 0, + count: 1) == nil) + + let changed = CodexVisibleAccount( + id: account.id, + email: account.email, + authFingerprint: "different", + storedAccountID: id, + selectionSource: account.selectionSource, + isActive: account.isActive, + isLive: account.isLive, + canReauthenticate: account.canReauthenticate, + canRemove: account.canRemove) + let changedRequest = try #require(SpendDashboardSource.codexRequest( + account: changed, + homePath: request.homePath, + providerName: "Codex", + index: 1, + count: 2)) + #expect(changedRequest.cacheIdentity != request.cacheIdentity) + + let authData = Data("{\"tokens\":\"synthetic\"}".utf8) + try authData.write(to: CodexAuthFingerprint.authFileURL(homePath: home.path)) + let exact = try #require(SpendDashboardSource.codexRequest( + account: account, + homePath: home.path, + providerName: "Codex", + index: 0, + count: 1)) + #expect(exact.authFingerprint == CodexAuthFingerprint.fingerprint(data: authData)) + #expect(exact.authFileWasReadable) + #expect(exact.cacheIdentity != request.cacheIdentity) + } + + private static func input( + id: String, + provider: UsageProvider, + currency: String, + cost: Double) -> SpendDashboardModel.ProviderInput + { + SpendDashboardModel.ProviderInput( + id: id, + provider: provider, + displayName: provider.rawValue, + snapshot: self.snapshot(currency: currency, entries: [self.entry(day: "2026-07-16", cost: cost)])) + } + + private static func snapshot( + currency: String, + entries: [CostUsageDailyReport.Entry], + historyDays: Int = 30, + updatedAt: Date = now) -> CostUsageTokenSnapshot + { + CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: nil, + last30DaysCostUSD: nil, + currencyCode: currency, + historyDays: historyDays, + daily: entries, + updatedAt: updatedAt) + } + + private static func entry( + day: String, + cost: Double?, + tokens: Int? = 10, + model: String? = "test-model") -> CostUsageDailyReport.Entry + { + CostUsageDailyReport.Entry( + date: day, + inputTokens: nil, + outputTokens: nil, + totalTokens: tokens, + costUSD: cost, + modelsUsed: nil, + modelBreakdowns: model.map { + [.init(modelName: $0, costUSD: cost, totalTokens: tokens)] + }) + } + + private static func entryWithBreakdowns( + day: String, + totalCost: Double = 0, + totalTokens: Int = 0, + breakdowns: [CostUsageDailyReport.ModelBreakdown]) -> CostUsageDailyReport.Entry + { + CostUsageDailyReport.Entry( + date: day, + inputTokens: nil, + outputTokens: nil, + totalTokens: totalTokens, + costUSD: totalCost, + modelsUsed: nil, + modelBreakdowns: breakdowns) + } + + private static let now = Date(timeIntervalSince1970: 1_784_179_200) // 2026-07-16 00:00:00 UTC + private static var calendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + } +} diff --git a/Tests/CodexBarTests/SpendDashboardSourceConcurrencyTests.swift b/Tests/CodexBarTests/SpendDashboardSourceConcurrencyTests.swift new file mode 100644 index 0000000000..fda8fb8878 --- /dev/null +++ b/Tests/CodexBarTests/SpendDashboardSourceConcurrencyTests.swift @@ -0,0 +1,733 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct SpendDashboardSourceConcurrencyTests { + @Test + func `Codex batch revalidates completed and failed accounts after later scans`() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("SpendDashboardSourceConcurrencyTests-auth-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let completed = try Self.makeAccount(id: "completed", root: root) + let failed = try Self.makeAccount(id: "failed", root: root) + let later = try Self.makeAccount(id: "later", root: root) + let completedSnapshot = Self.input(cost: 1).snapshot + let laterSnapshot = Self.input(cost: 2).snapshot + let gate = SpendDashboardCodexBatchGate() + let request = SpendDashboardLoadRequest( + configuration: SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.codex.rawValue], + codexAccountIdentities: [completed, failed, later].map { "\($0.id)|\($0.cacheIdentity)" }), + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [completed, failed, later], + now: Date(timeIntervalSince1970: 1_784_179_200), + force: true) + + let loadTask = Task { + await SpendDashboardSource.load(request, codexSnapshotLoader: { context in + switch context.account.id { + case completed.id: + completedSnapshot + case failed.id: + throw SpendDashboardSyntheticError.failed + default: + await gate.load() + } + }) + } + await Self.waitForCodexGate(gate) + let replacementAuth = Data("{\"profile\":\"replacement-owner\"}".utf8) + try replacementAuth.write( + to: CodexAuthFingerprint.authFileURL(homePath: completed.homePath), + options: .atomic) + try replacementAuth.write( + to: CodexAuthFingerprint.authFileURL(homePath: failed.homePath), + options: .atomic) + await gate.resume(snapshot: laterSnapshot) + + let result = await loadTask.value + #expect(result.inputs.map(\.id) == ["codex:later"]) + #expect(result.failedSourceIDs == ["codex:completed", "codex:failed"]) + #expect(result.invalidatedSourceIDs == ["codex:completed", "codex:failed"]) + } + + @Test + func `Codex ownership change retains failed unchanged sibling only`() async { + let gate = SpendDashboardResultBatchGate() + let initial = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.codex.rawValue], + codexAccountIdentities: ["a|owner-a", "b|owner-b"]) + let replacement = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.codex.rawValue], + codexAccountIdentities: ["a|owner-a-replacement", "b|owner-b"]) + let requestSequence = SpendDashboardRequestSequence([ + .init(configuration: initial), + .init(configuration: replacement), + ]) + let controller = SpendDashboardController( + requestBuilder: { mode in await requestSequence.next(mode: mode) }, + loader: { request in await gate.load(request) }) + + controller.update(configuration: initial) + await Self.waitForResultGate(gate) + await gate.resume(result: SpendDashboardLoadResult( + inputs: [ + Self.input(id: "codex:a", cost: 3), + Self.input(id: "codex:b", cost: 5), + ], + failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + #expect(controller.model.groups.first?.totalCost == 8) + + controller.update(configuration: replacement) + await Self.waitForResultGate(gate) + #expect(controller.model.groups.first?.totalCost == 5) + #expect(Set(controller.model.groups.flatMap(\.providers).map(\.id)) == ["codex:b"]) + await gate.resume(result: SpendDashboardLoadResult( + inputs: [], + failedSourceIDs: ["codex:a", "codex:b"])) + await Self.waitUntil { !controller.isRefreshing } + + #expect(controller.model.groups.first?.totalCost == 5) + #expect(Set(controller.model.groups.flatMap(\.providers).map(\.id)) == ["codex:b"]) + #expect(controller.failedSourceCount == 2) + } + + @Test + func `Codex removal relabels retained failed account from second to first`() async throws { + let gate = SpendDashboardResultBatchGate() + let requestGate = SpendDashboardProviderBatchGate() + let initialRequests = [ + Self.scanRequest(id: "a", displayName: "Codex · #1"), + Self.scanRequest(id: "b", displayName: "Codex · #2"), + Self.scanRequest(id: "c", displayName: "Codex · #3"), + ] + let replacementRequests = [ + Self.scanRequest(id: "b", displayName: "Codex · #1"), + Self.scanRequest(id: "c", displayName: "Codex · #2"), + ] + let initial = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.codex.rawValue], + codexAccountIdentities: ["a|owner-a", "b|owner-b", "c|owner-c"], + codexAccountDisplayNames: [ + "codex:a": "Codex · #1", + "codex:b": "Codex · #2", + "codex:c": "Codex · #3", + ]) + let replacement = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.codex.rawValue], + codexAccountIdentities: ["b|owner-b", "c|owner-c"], + codexAccountDisplayNames: [ + "codex:b": "Codex · #1", + "codex:c": "Codex · #2", + ]) + let requestSequence = SpendDashboardRequestSequence( + [ + .init(configuration: initial, codexRequests: initialRequests), + .init(configuration: replacement, codexRequests: replacementRequests), + ], + suspendAt: 1, + gate: requestGate) + let controller = SpendDashboardController( + requestBuilder: { mode in await requestSequence.next(mode: mode) }, + loader: { request in await gate.load(request) }) + + controller.update(configuration: initial) + await Self.waitForResultGate(gate) + await gate.resume(result: SpendDashboardLoadResult( + inputs: [ + Self.input(id: "codex:a", cost: 3, displayName: "Codex · #1"), + Self.input(id: "codex:b", cost: 5, displayName: "Codex · #2"), + Self.input(id: "codex:c", cost: 7, displayName: "Codex · #3"), + ], + failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + + controller.update(configuration: replacement) + let pendingRows = try #require(controller.model.groups.first?.providers) + #expect(Dictionary(uniqueKeysWithValues: pendingRows.map { ($0.id, $0.displayName) }) == [ + "codex:b": "Codex · #1", + "codex:c": "Codex · #2", + ]) + await Self.waitForProviderGate(requestGate) + #expect(await gate.pendingCount == 0) + await requestGate.resume() + await Self.waitForResultGate(gate) + await gate.resume(result: SpendDashboardLoadResult( + inputs: [Self.input(id: "codex:c", cost: 8, displayName: "Codex · #2")], + failedSourceIDs: ["codex:b"])) + await Self.waitUntil { !controller.isRefreshing } + + let finalRows = try #require(controller.model.groups.first?.providers) + #expect(Dictionary(uniqueKeysWithValues: finalRows.map { ($0.id, $0.displayName) }) == [ + "codex:b": "Codex · #1", + "codex:c": "Codex · #2", + ]) + #expect(finalRows.first { $0.id == "codex:b" }?.totalCost == 5) + #expect(controller.failedSourceCount == 1) + } + + @Test + func `request revision captured before coalesced update cannot publish stale inputs`() async { + let requestGate = SpendDashboardProviderBatchGate() + let recorder = SpendDashboardRequestRecorder() + let initial = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.claude.rawValue], + codexAccountIdentities: [], + sourceOwnershipFingerprints: ["claude:owner"], + sourceRevisions: ["R"]) + let replacement = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.claude.rawValue], + codexAccountIdentities: [], + sourceOwnershipFingerprints: ["claude:owner"], + sourceRevisions: ["R+1"]) + let requestSequence = SpendDashboardRequestSequence( + [ + .init(configuration: initial, capturedInputs: [Self.input(provider: .claude, cost: 1)]), + .init(configuration: replacement, capturedInputs: [Self.input(provider: .claude, cost: 2)]), + .init(configuration: replacement, capturedInputs: [Self.input(provider: .claude, cost: 2)]), + ], + suspendAt: 0, + gate: requestGate) + let controller = SpendDashboardController( + requestBuilder: { mode in await requestSequence.next(mode: mode) }, + loader: { request in + await recorder.record(request) + return SpendDashboardLoadResult(inputs: request.capturedInputs, failedSourceIDs: []) + }) + + controller.update(configuration: initial, force: true) + await Self.waitForProviderGate(requestGate) + controller.update(configuration: replacement) + await requestGate.resume() + await Self.waitUntil { !controller.isRefreshing } + + #expect(controller.configuration == replacement) + #expect(controller.generation == 2) + #expect(controller.model.groups.first?.totalCost == 2) + #expect(requestSequence.modes == [.forceRefresh, .captureOnly]) + #expect(await recorder.configurations == [initial]) + #expect(await recorder.forces == [true]) + } + + @Test + func `force adopts builder published revision without losing Codex scan intent`() async { + let recorder = SpendDashboardRequestRecorder() + let initial = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.codex.rawValue, UsageProvider.claude.rawValue], + codexAccountIdentities: ["account|owner"], + sourceOwnershipFingerprints: ["claude:owner"], + sourceRevisions: ["R"]) + let replacement = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.codex.rawValue, UsageProvider.claude.rawValue], + codexAccountIdentities: ["account|owner"], + sourceOwnershipFingerprints: ["claude:owner"], + sourceRevisions: ["R+1"]) + let requestSequence = SpendDashboardRequestSequence([ + .init(configuration: replacement, capturedInputs: [Self.input(provider: .claude, cost: 2)]), + .init(configuration: replacement, capturedInputs: [Self.input(provider: .claude, cost: 2)]), + ]) + let controller = SpendDashboardController( + requestBuilder: { mode in await requestSequence.next(mode: mode) }, + loader: { request in + await recorder.record(request) + return SpendDashboardLoadResult(inputs: request.capturedInputs, failedSourceIDs: []) + }) + + controller.update(configuration: initial, force: true) + await Self.waitUntil { !controller.isRefreshing } + + #expect(controller.configuration == replacement) + #expect(controller.generation == 2) + #expect(controller.model.groups.first?.totalCost == 2) + #expect(requestSequence.modes == [.forceRefresh, .captureOnly]) + #expect(await recorder.forces == [true]) + } + + @Test + func `forced builder owner mismatch reruns replacement builder and rejects cached request`() async { + let requestGate = SpendDashboardProviderBatchGate() + let recorder = SpendDashboardRequestRecorder() + let initial = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.codex.rawValue, UsageProvider.claude.rawValue], + codexAccountIdentities: ["account|owner"], + sourceOwnershipFingerprints: ["claude:owner-one"], + sourceRevisions: ["R"]) + let replacement = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.codex.rawValue, UsageProvider.claude.rawValue], + codexAccountIdentities: ["account|owner"], + sourceOwnershipFingerprints: ["claude:owner-two"], + sourceRevisions: ["R+1"]) + let cachedInput = Self.input(provider: .claude, cost: 1) + let freshInput = Self.input(provider: .claude, cost: 3) + let requestSequence = SpendDashboardRequestSequence( + [ + .init(configuration: replacement, capturedInputs: [cachedInput]), + .init(configuration: replacement, capturedInputs: [freshInput]), + .init(configuration: replacement, capturedInputs: [freshInput]), + ], + suspendAt: 1, + gate: requestGate) + let controller = SpendDashboardController( + requestBuilder: { mode in await requestSequence.next(mode: mode) }, + loader: { request in + await recorder.record(request) + return SpendDashboardLoadResult(inputs: request.capturedInputs, failedSourceIDs: []) + }) + + controller.update(configuration: initial, force: true) + await Self.waitForProviderGate(requestGate) + + #expect(controller.configuration == replacement) + #expect(controller.generation == 2) + #expect(controller.model.groups.isEmpty) + #expect(requestSequence.modes == [.forceRefresh, .forceRefresh]) + #expect(await recorder.configurations.isEmpty) + + await requestGate.resume() + await Self.waitUntil { !controller.isRefreshing } + + #expect(controller.configuration == replacement) + #expect(controller.generation == 3) + #expect(controller.model.groups.first?.totalCost == 3) + #expect(requestSequence.modes == [.forceRefresh, .forceRefresh, .captureOnly]) + #expect(await recorder.configurations == [replacement]) + #expect(await recorder.forces == [true]) + } + + @Test + func `ownership replacement while force builder is pending reruns builder and loader forced`() async { + let requestGate = SpendDashboardProviderBatchGate() + let recorder = SpendDashboardRequestRecorder() + let initial = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.codex.rawValue, UsageProvider.claude.rawValue], + codexAccountIdentities: ["account|owner"], + sourceOwnershipFingerprints: ["claude:owner-one"], + sourceRevisions: ["R"]) + let replacement = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.codex.rawValue, UsageProvider.claude.rawValue], + codexAccountIdentities: ["account|owner"], + sourceOwnershipFingerprints: ["claude:owner-two"], + sourceRevisions: ["R+1"]) + let replacementInput = Self.input(provider: .codex, cost: 4) + let requestSequence = SpendDashboardRequestSequence( + [ + .init(configuration: initial, capturedInputs: [Self.input(provider: .codex, cost: 1)]), + .init(configuration: replacement, capturedInputs: [replacementInput]), + .init(configuration: replacement, capturedInputs: [replacementInput]), + ], + suspendAt: 0, + gate: requestGate) + let controller = SpendDashboardController( + requestBuilder: { mode in await requestSequence.next(mode: mode) }, + loader: { request in + await recorder.record(request) + return SpendDashboardLoadResult(inputs: request.capturedInputs, failedSourceIDs: []) + }) + + controller.update(configuration: initial, force: true) + await Self.waitForProviderGate(requestGate) + controller.update(configuration: replacement) + await Self.waitUntil { !controller.isRefreshing } + await requestGate.resume() + await Task.yield() + + #expect(controller.configuration == replacement) + #expect(controller.generation == 3) + #expect(controller.model.groups.first?.totalCost == 4) + #expect(requestSequence.modes == [.forceRefresh, .forceRefresh, .captureOnly]) + #expect(await recorder.configurations == [replacement]) + #expect(await recorder.forces == [true]) + } + + @Test + func `ownership replacement after force builder completes reruns builder and loader forced`() async { + let loaderGate = SpendDashboardRecordedResultGate() + let initial = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.codex.rawValue, UsageProvider.claude.rawValue], + codexAccountIdentities: ["account|owner"], + sourceOwnershipFingerprints: ["claude:owner-one"], + sourceRevisions: ["R"]) + let replacement = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.codex.rawValue, UsageProvider.claude.rawValue], + codexAccountIdentities: ["account|owner"], + sourceOwnershipFingerprints: ["claude:owner-two"], + sourceRevisions: ["R+1"]) + let requestSequence = SpendDashboardRequestSequence([ + .init(configuration: initial), + .init(configuration: replacement), + .init( + configuration: replacement, + capturedInputs: [Self.input(provider: .codex, cost: 5)]), + ]) + let controller = SpendDashboardController( + requestBuilder: { mode in await requestSequence.next(mode: mode) }, + loader: { request in await loaderGate.load(request) }) + + controller.update(configuration: initial, force: true) + await Self.waitForRecordedResultGate(loaderGate, pendingCount: 1) + controller.update(configuration: replacement) + await Self.waitForRecordedResultGate(loaderGate, pendingCount: 2) + + #expect(requestSequence.modes == [.forceRefresh, .forceRefresh]) + #expect(await loaderGate.configurations == [initial, replacement]) + #expect(await loaderGate.forces == [true, true]) + + await loaderGate.resume( + at: 1, + result: SpendDashboardLoadResult( + inputs: [Self.input(provider: .codex, cost: 5)], + failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + await loaderGate.resume( + at: 0, + result: SpendDashboardLoadResult( + inputs: [Self.input(provider: .codex, cost: 99)], + failedSourceIDs: [])) + await Task.yield() + + #expect(controller.configuration == replacement) + #expect(controller.generation == 3) + #expect(controller.model.groups.first?.totalCost == 5) + } + + @Test + func `force request recaptures earlier provider after later refresh suspends`() async throws { + let settings = testSettingsStore(suiteName: "SpendDashboardSourceConcurrencyTests-force-recapture") + settings.costUsageEnabled = true + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled( + provider: provider, + metadata: metadata, + enabled: provider == .claude || provider == .mistral) + } + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + let providers = SpendDashboardSource.costCapableProviders(store: store) + #expect(providers == [.claude, .mistral]) + let firstProvider = UsageProvider.claude + let laterProvider = UsageProvider.mistral + store._setTokenSnapshotForTesting( + Self.input(provider: firstProvider, cost: 1).snapshot, + provider: firstProvider) + store._setTokenSnapshotForTesting( + Self.input(provider: laterProvider, cost: 2).snapshot, + provider: laterProvider) + + let gate = SpendDashboardProviderBatchGate() + store._test_tokenUsageRefreshOverride = { provider, _ in + #expect(provider == firstProvider) + store._setTokenSnapshotForTesting( + Self.input(provider: provider, cost: 10).snapshot, + provider: provider) + } + store._test_providerRefreshOverride = { provider in + #expect(provider == laterProvider) + await gate.suspend() + store._setTokenSnapshotForTesting( + Self.input(provider: provider, cost: 20).snapshot, + provider: provider) + } + + let requestTask = Task { @MainActor in + await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: .forceRefresh) + } + await Self.waitForProviderGate(gate) + store._setTokenSnapshotForTesting( + Self.input(provider: firstProvider, cost: 11).snapshot, + provider: firstProvider) + await gate.resume() + + let request = await requestTask.value + let firstInput = try #require(request.capturedInputs.first { $0.provider == firstProvider }) + let laterInput = try #require(request.capturedInputs.first { $0.provider == laterProvider }) + #expect(firstInput.snapshot.last30DaysCostUSD == 11) + #expect(laterInput.snapshot.last30DaysCostUSD == 20) + #expect(request.unavailableSourceIDs.isEmpty) + #expect(request.configuration == SpendDashboardSource.configuration(settings: settings, store: store)) + } + + private static func makeAccount(id: String, root: URL) throws -> CodexSpendScanRequest { + let home = root.appendingPathComponent(id, isDirectory: true) + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + let auth = Data("{\"profile\":\"\(id)-owner\"}".utf8) + try auth.write(to: CodexAuthFingerprint.authFileURL(homePath: home.path), options: .atomic) + return CodexSpendScanRequest( + id: id, + displayName: "Codex · \(id)", + source: .profileHome(path: home.path), + homePath: home.path, + authFingerprint: CodexAuthFingerprint.fingerprint(data: auth), + authFileWasReadable: true, + cacheIdentity: "\(id)-cache") + } + + private static func scanRequest(id: String, displayName: String) -> CodexSpendScanRequest { + CodexSpendScanRequest( + id: id, + displayName: displayName, + source: .profileHome(path: "/synthetic/\(id)"), + homePath: "/synthetic/\(id)", + authFingerprint: nil, + authFileWasReadable: false, + cacheIdentity: "\(id)-cache") + } + + private static func input( + id: String? = nil, + provider: UsageProvider = .codex, + cost: Double, + displayName: String? = nil) -> SpendDashboardModel.ProviderInput + { + let entry = CostUsageDailyReport.Entry( + date: "2026-07-15", + inputTokens: nil, + outputTokens: nil, + totalTokens: 10, + costUSD: cost, + modelsUsed: nil, + modelBreakdowns: nil) + let snapshot = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: 10, + last30DaysCostUSD: cost, + daily: [entry], + updatedAt: Date(timeIntervalSince1970: 1_784_179_200)) + return SpendDashboardModel.ProviderInput( + id: id, + provider: provider, + displayName: displayName ?? provider.rawValue, + modelProviderName: provider == .codex ? "Codex" : nil, + snapshot: snapshot) + } + + private static func waitForCodexGate(_ gate: SpendDashboardCodexBatchGate) async { + for _ in 0..<1000 { + if await gate.isSuspended { + return + } + await Task.yield() + } + Issue.record("Timed out waiting for pending Codex load") + } + + private static func waitForProviderGate(_ gate: SpendDashboardProviderBatchGate) async { + for _ in 0..<1000 { + if await gate.isSuspended { + return + } + await Task.yield() + } + Issue.record("Timed out waiting for pending provider refresh") + } + + private static func waitForResultGate(_ gate: SpendDashboardResultBatchGate) async { + for _ in 0..<1000 { + if await gate.pendingCount == 1 { + return + } + await Task.yield() + } + Issue.record("Timed out waiting for pending dashboard load") + } + + private static func waitForRecordedResultGate( + _ gate: SpendDashboardRecordedResultGate, + pendingCount: Int) async + { + for _ in 0..<1000 { + if await gate.pendingCount == pendingCount { + return + } + await Task.yield() + } + Issue.record("Timed out waiting for \(pendingCount) recorded dashboard loads") + } + + private static func waitUntil(_ condition: @MainActor () -> Bool) async { + for _ in 0..<1000 { + if condition() { + return + } + await Task.yield() + } + Issue.record("Timed out waiting for dashboard state") + } +} + +private enum SpendDashboardSyntheticError: Error { + case failed +} + +@MainActor +private final class SpendDashboardRequestSequence { + struct Item { + let configuration: SpendDashboardConfiguration + let capturedInputs: [SpendDashboardModel.ProviderInput] + let codexRequests: [CodexSpendScanRequest] + + init( + configuration: SpendDashboardConfiguration, + capturedInputs: [SpendDashboardModel.ProviderInput] = [], + codexRequests: [CodexSpendScanRequest] = []) + { + self.configuration = configuration + self.capturedInputs = capturedInputs + self.codexRequests = codexRequests + } + } + + private var items: [Item] + private let suspendAt: Int? + private let gate: SpendDashboardProviderBatchGate? + private var index = 0 + private(set) var modes: [SpendDashboardRequestBuildMode] = [] + + init( + _ items: [Item], + suspendAt: Int? = nil, + gate: SpendDashboardProviderBatchGate? = nil) + { + self.items = items + self.suspendAt = suspendAt + self.gate = gate + } + + func next(mode: SpendDashboardRequestBuildMode) async -> SpendDashboardLoadRequest { + let item = self.items.removeFirst() + let index = self.index + self.index += 1 + self.modes.append(mode) + if index == self.suspendAt { + await self.gate?.suspend() + } + return SpendDashboardLoadRequest( + configuration: item.configuration, + capturedInputs: item.capturedInputs, + unavailableSourceIDs: [], + codexRequests: item.codexRequests, + now: Date(timeIntervalSince1970: 1_784_179_200), + force: mode.forcesLoader) + } +} + +private actor SpendDashboardRequestRecorder { + private(set) var configurations: [SpendDashboardConfiguration] = [] + private(set) var forces: [Bool] = [] + + func record(_ request: SpendDashboardLoadRequest) { + self.configurations.append(request.configuration) + self.forces.append(request.force) + } +} + +private actor SpendDashboardRecordedResultGate { + private var requests: [SpendDashboardLoadRequest] = [] + private var continuations: [CheckedContinuation] = [] + + var pendingCount: Int { + self.continuations.count + } + + var configurations: [SpendDashboardConfiguration] { + self.requests.map(\.configuration) + } + + var forces: [Bool] { + self.requests.map(\.force) + } + + func load(_ request: SpendDashboardLoadRequest) async -> SpendDashboardLoadResult { + self.requests.append(request) + return await withCheckedContinuation { continuation in + self.continuations.append(continuation) + } + } + + func resume(at index: Int, result: SpendDashboardLoadResult) { + self.continuations.remove(at: index).resume(returning: result) + } +} + +private actor SpendDashboardCodexBatchGate { + private var continuation: CheckedContinuation? + + var isSuspended: Bool { + self.continuation != nil + } + + func load() async -> CostUsageTokenSnapshot { + await withCheckedContinuation { continuation in + self.continuation = continuation + } + } + + func resume(snapshot: CostUsageTokenSnapshot) { + self.continuation?.resume(returning: snapshot) + self.continuation = nil + } +} + +private actor SpendDashboardProviderBatchGate { + private var continuation: CheckedContinuation? + + var isSuspended: Bool { + self.continuation != nil + } + + func suspend() async { + await withCheckedContinuation { continuation in + self.continuation = continuation + } + } + + func resume() { + self.continuation?.resume() + self.continuation = nil + } +} + +private actor SpendDashboardResultBatchGate { + private var continuations: [CheckedContinuation] = [] + + var pendingCount: Int { + self.continuations.count + } + + func load(_ request: SpendDashboardLoadRequest) async -> SpendDashboardLoadResult { + _ = request + return await withCheckedContinuation { continuation in + self.continuations.append(continuation) + } + } + + func resume(result: SpendDashboardLoadResult) { + self.continuations.removeFirst().resume(returning: result) + } +} diff --git a/Tests/CodexBarTests/SpendDashboardTokenProvenanceTests.swift b/Tests/CodexBarTests/SpendDashboardTokenProvenanceTests.swift new file mode 100644 index 0000000000..88e594eca4 --- /dev/null +++ b/Tests/CodexBarTests/SpendDashboardTokenProvenanceTests.swift @@ -0,0 +1,430 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct SpendDashboardTokenProvenanceTests { + @Test + func `direct token scan rejects stale config completion`() async { + let (settings, store) = Self.makeStore(provider: .bedrock) + settings.updateProviderConfig(provider: .bedrock) { $0.region = "us-east-1" } + let gate = SpendDashboardProvenanceGate() + store._test_tokenUsageSnapshotLoaderOverride = { _, _, _, _, _ in + let call = await gate.enter() + return Self.tokenSnapshot(cost: call == 1 ? 1 : 2) + } + + let refresh = Task { @MainActor in + await store.refreshTokenUsageNow(for: .bedrock, force: true) + } + await gate.waitForCalls(1) + settings.updateProviderConfig(provider: .bedrock) { $0.region = "us-west-2" } + await gate.releaseFirst() + await refresh.value + await gate.waitForCalls(2) + await Self.waitUntil { + store.tokenSnapshotForCurrentProviderConfig(for: .bedrock)?.snapshot.last30DaysCostUSD == 2 + } + + #expect(store.tokenSnapshotForCurrentProviderConfig(for: .bedrock)?.snapshot.last30DaysCostUSD == 2) + #expect(store.tokenSnapshotPublicationRevision(for: .bedrock) == 1) + } + + @Test + func `direct token scan rejects completion across disable and reenable epoch`() async { + let (settings, store) = Self.makeStore(provider: .bedrock) + let gate = SpendDashboardProvenanceGate() + store._test_tokenUsageSnapshotLoaderOverride = { _, _, _, _, _ in + let call = await gate.enter() + return Self.tokenSnapshot(cost: call == 1 ? 1 : 2) + } + + let refresh = Task { @MainActor in + await store.refreshTokenUsageNow(for: .bedrock, force: true) + } + await gate.waitForCalls(1) + settings.costUsageEnabled = false + settings.costUsageEnabled = true + await gate.releaseFirst() + await refresh.value + await gate.waitForCalls(2) + await Self.waitUntil { + store.tokenSnapshotForCurrentProviderConfig(for: .bedrock)?.snapshot.last30DaysCostUSD == 2 + } + + #expect(store.tokenSnapshotForCurrentProviderConfig(for: .bedrock)?.snapshot.last30DaysCostUSD == 2) + #expect(store.tokenSnapshotPublicationRevision(for: .bedrock) == 1) + } + + @Test + func `direct token scan refreshes changed provider config within ttl`() async { + let (settings, store) = Self.makeStore(provider: .bedrock) + settings.updateProviderConfig(provider: .bedrock) { $0.region = "us-east-1" } + var loadCount = 0 + store._test_tokenUsageSnapshotLoaderOverride = { _, _, _, _, _ in + loadCount += 1 + return Self.tokenSnapshot(cost: Double(loadCount)) + } + + await store.refreshTokenUsageNow(for: .bedrock, force: true) + #expect(store.tokenSnapshotForCurrentProviderConfig(for: .bedrock)?.snapshot.last30DaysCostUSD == 1) + + settings.updateProviderConfig(provider: .bedrock) { $0.region = "us-west-2" } + await store.refreshTokenUsageNow(for: .bedrock, force: false) + + #expect(loadCount == 2) + #expect(store.tokenSnapshotForCurrentProviderConfig(for: .bedrock)?.snapshot.last30DaysCostUSD == 2) + #expect(store.tokenSnapshotPublicationRevision(for: .bedrock) == 2) + } + + @Test + func `provider derived snapshot rejects completion from old history scope`() async { + let (settings, store) = Self.makeStore(provider: .mistral) + let gate = SpendDashboardProvenanceGate() + store._test_providerFetchOutcomeOverride = { _ in + _ = await gate.enter() + return Self.mistralOutcome(cost: 4) + } + + let refresh = Task { @MainActor in + await store.refreshProvider(.mistral) + } + await gate.waitForCalls(1) + settings.costUsageHistoryDays = 7 + await gate.releaseFirst() + await refresh.value + + #expect(store.tokenSnapshotForCurrentProviderConfig(for: .mistral) == nil) + #expect(store.tokenSnapshotPublicationRevision(for: .mistral) == 0) + } + + @Test + func `cached token account activation does not prove a forced refresh`() async throws { + let (settings, store) = Self.makeStore(provider: .mistral) + settings.addTokenAccount(provider: .mistral, label: "Fixture", token: "fixture") + let account = try #require(settings.effectiveSelectedTokenAccount(for: .mistral)) + let usage = Self.mistralUsage(cost: 3) + store.accountSnapshots[.mistral] = [TokenAccountUsageSnapshot( + account: account, + snapshot: usage, + error: nil, + sourceLabel: "fixture-cache", + cacheKey: store.tokenAccountSnapshotCacheKey(provider: .mistral, account: account))] + store.tokenErrors[.mistral] = "stale account cost error" + _ = store.tokenFailureGates[.mistral]?.shouldSurfaceError(onFailureWithPriorData: false) + store.activateCachedTokenAccountSnapshot(provider: .mistral, accountID: account.id) + let baselineRevision = store.tokenSnapshotPublicationRevision(for: .mistral) + #expect(store.tokenSnapshot(for: .mistral)?.last30DaysCostUSD == 3) + #expect(store.tokenSnapshotPublicationForCurrentProviderConfig(for: .mistral)?.snapshot?.last30DaysCostUSD == 3) + #expect(store.tokenError(for: .mistral) == nil) + #expect(store.tokenFailureGates[.mistral]?.streak == 0) + + store.activateCachedTokenAccountSnapshot(provider: .mistral, accountID: account.id) + #expect(store.tokenSnapshotPublicationRevision(for: .mistral) == baselineRevision) + store._test_providerRefreshOverride = { _ in } + let controller = SpendDashboardController(requestBuilder: { mode in + await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) + }) + controller.update(configuration: SpendDashboardSource.configuration(settings: settings, store: store)) + await Self.waitUntil { !controller.isRefreshing } + #expect(controller.model.groups.first?.totalCost == 3) + + controller.refresh() + await Self.waitUntil { !controller.isRefreshing } + + #expect(controller.model.groups.first?.totalCost == 3) + #expect(controller.failedSourceCount == 1) + #expect(store.tokenSnapshotPublicationRevision(for: .mistral) == baselineRevision) + } + + @Test + func `forced successful empty publication removes prior spend without warning`() async { + let (settings, store) = Self.makeStore(provider: .bedrock) + var loadCount = 0 + store._test_tokenUsageSnapshotLoaderOverride = { _, _, _, _, _ in + loadCount += 1 + return loadCount == 1 ? Self.tokenSnapshot(cost: 4) : Self.emptyTokenSnapshot() + } + await store.refreshTokenUsageNow(for: .bedrock, force: true) + let controller = SpendDashboardController(requestBuilder: { mode in + await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) + }) + controller.update(configuration: SpendDashboardSource.configuration(settings: settings, store: store)) + await Self.waitUntil { !controller.isRefreshing } + #expect(controller.model.groups.first?.totalCost == 4) + + controller.refresh() + await Self.waitUntil { !controller.isRefreshing } + + #expect(loadCount == 2) + #expect(controller.model.groups.isEmpty) + #expect(controller.failedSourceCount == 0) + #expect(store.tokenSnapshot(for: .bedrock) == nil) + let publication = store.tokenSnapshotPublicationForCurrentProviderConfig(for: .bedrock) + #expect(publication?.snapshot == nil) + #expect(publication?.publicationRevision == 2) + } + + @Test + func `first open accepts current empty publication without redundant refresh`() async { + let (settings, store) = Self.makeStore(provider: .bedrock) + var loadCount = 0 + store._test_tokenUsageSnapshotLoaderOverride = { _, _, _, _, _ in + loadCount += 1 + return Self.emptyTokenSnapshot() + } + await store.refreshTokenUsageNow(for: .bedrock, force: true) + let publicationRevision = store.tokenSnapshotPublicationRevision(for: .bedrock) + let controller = SpendDashboardController(requestBuilder: { mode in + await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) + }) + + controller.update(configuration: SpendDashboardSource.configuration(settings: settings, store: store)) + await Self.waitUntil { !controller.isRefreshing } + + #expect(loadCount == 1) + #expect(controller.model.groups.isEmpty) + #expect(controller.failedSourceCount == 0) + #expect(store.tokenSnapshotPublicationRevision(for: .bedrock) == publicationRevision) + } + + @Test + func `provider success without cost projection confirms empty publication`() async { + let (_, store) = Self.makeStore(provider: .mistral) + let outcome = Self.mistralOutcomeWithoutCostProjection() + + await store.applySelectedOutcome( + outcome, + provider: .mistral, + account: nil, + fallbackSnapshot: nil) + + let publication = store.tokenSnapshotPublicationForCurrentProviderConfig(for: .mistral) + #expect(publication?.snapshot == nil) + #expect(publication?.publicationRevision == 1) + #expect(store.tokenSnapshot(for: .mistral) == nil) + } + + @Test + func `legacy token refresh preserves current confirmed empty provider publication`() async { + let (_, store) = Self.makeStore(provider: .mistral) + await store.applySelectedOutcome( + Self.mistralOutcomeWithoutCostProjection(), + provider: .mistral, + account: nil, + fallbackSnapshot: nil) + let publicationRevision = store.tokenSnapshotPublicationRevision(for: .mistral) + + await store.refreshTokenUsage(.mistral, force: true) + + let publication = store.tokenSnapshotPublicationForCurrentProviderConfig(for: .mistral) + #expect(publication?.snapshot == nil) + #expect(publication?.publicationRevision == publicationRevision) + #expect(store.tokenError(for: .mistral) == nil) + } + + @Test + func `multi account provider success publishes current token provenance`() async throws { + let (settings, store) = Self.makeStore(provider: .mistral) + settings.addTokenAccount(provider: .mistral, label: "Fixture", token: "fixture") + let account = try #require(settings.effectiveSelectedTokenAccount(for: .mistral)) + store.tokenErrors[.mistral] = "stale account cost error" + _ = store.tokenFailureGates[.mistral]?.shouldSurfaceError(onFailureWithPriorData: false) + + await store.applySelectedOutcome( + Self.mistralOutcome(cost: 7), + provider: .mistral, + account: account, + fallbackSnapshot: nil) + + #expect(store.tokenSnapshotForCurrentProviderConfig(for: .mistral)?.snapshot.last30DaysCostUSD == 7) + #expect(store.tokenSnapshotPublicationRevision(for: .mistral) == 1) + #expect(store.tokenError(for: .mistral) == nil) + #expect(store.tokenFailureGates[.mistral]?.streak == 0) + } + + @Test + func `legacy token refresh cannot stamp raw provider snapshot without provenance`() async { + let (_, store) = Self.makeStore(provider: .mistral) + store._setSnapshotForTesting(Self.mistralUsage(cost: 8), provider: .mistral) + + await store.refreshTokenUsage(.mistral, force: true) + + #expect(store.snapshot(for: .mistral) != nil) + #expect(store.tokenSnapshotForCurrentProviderConfig(for: .mistral) == nil) + #expect(store.tokenSnapshotPublicationRevision(for: .mistral) == 0) + } + + @Test + func `widget does not project raw provider cost without current provenance`() async throws { + let (_, store) = Self.makeStore(provider: .mistral) + store._setSnapshotForTesting(Self.mistralUsage(cost: 8), provider: .mistral) + var savedSnapshots: [WidgetSnapshot] = [] + store._test_widgetSnapshotSaveOverride = { savedSnapshots.append($0) } + + store.persistWidgetSnapshot(reason: "provenance-test") + await store.widgetSnapshotPersistTask?.value + + let entry = try #require(savedSnapshots.last?.entries.first { $0.provider == .mistral }) + #expect(entry.tokenUsage == nil) + #expect(entry.dailyUsage.isEmpty) + } + + @Test + func `token publication counter remains monotonic across clear and identical republish`() { + let (_, store) = Self.makeStore(provider: .claude) + let snapshot = Self.tokenSnapshot(cost: 9) + store._setTokenSnapshotForTesting(snapshot, provider: .claude) + let firstRevision = store.tokenSnapshotPublicationRevision(for: .claude) + + store._setTokenSnapshotForTesting(nil, provider: .claude) + store._setTokenSnapshotForTesting(snapshot, provider: .claude) + + #expect(store.tokenSnapshotPublicationRevision(for: .claude) > firstRevision) + #expect(store.tokenSnapshotForCurrentProviderConfig(for: .claude)?.snapshot == snapshot) + } + + private static func makeStore(provider: UsageProvider) -> (SettingsStore, UsageStore) { + let settings = testSettingsStore(suiteName: "SpendDashboardTokenProvenanceTests-\(provider.rawValue)") + settings.costUsageEnabled = true + for candidate in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[candidate] else { continue } + settings.setProviderEnabled(provider: candidate, metadata: metadata, enabled: candidate == provider) + } + if provider == .bedrock { + settings.updateProviderConfig(provider: .bedrock) { config in + config.awsAuthMode = BedrockAuthMode.profile.rawValue + config.awsProfile = "fixture" + } + } + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + return (settings, store) + } + + private static func tokenSnapshot(cost: Double) -> CostUsageTokenSnapshot { + CostUsageTokenSnapshot( + sessionTokens: 10, + sessionCostUSD: cost, + last30DaysTokens: 10, + last30DaysCostUSD: cost, + daily: [CostUsageDailyReport.Entry( + date: "2026-07-16", + inputTokens: 4, + outputTokens: 6, + totalTokens: 10, + costUSD: cost, + modelsUsed: nil, + modelBreakdowns: nil)], + updatedAt: Date(timeIntervalSince1970: 1_784_203_200)) + } + + private static func emptyTokenSnapshot() -> CostUsageTokenSnapshot { + CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: 0, + last30DaysCostUSD: 0, + daily: [], + updatedAt: Date(timeIntervalSince1970: 1_784_179_200)) + } + + private static func mistralUsage(cost: Double) -> UsageSnapshot { + MistralUsageSnapshot( + totalCost: cost, + currency: "USD", + currencySymbol: "$", + totalInputTokens: 4, + totalOutputTokens: 6, + totalCachedTokens: 0, + modelCount: 1, + daily: [MistralDailyUsageBucket( + day: "2026-07-16", + cost: cost, + inputTokens: 4, + cachedTokens: 0, + outputTokens: 6, + models: [])], + startDate: nil, + endDate: nil, + updatedAt: Date(timeIntervalSince1970: 1_784_179_200)) + .toUsageSnapshot() + } + + private static func mistralOutcome(cost: Double) -> ProviderFetchOutcome { + ProviderFetchOutcome( + result: .success(ProviderFetchResult( + usage: self.mistralUsage(cost: cost), + credits: nil, + dashboard: nil, + sourceLabel: "fixture", + strategyID: "fixture", + strategyKind: .apiToken)), + attempts: []) + } + + private static func mistralOutcomeWithoutCostProjection() -> ProviderFetchOutcome { + ProviderFetchOutcome( + result: .success(ProviderFetchResult( + usage: UsageSnapshot(primary: nil, secondary: nil, updatedAt: Date()), + credits: nil, + dashboard: nil, + sourceLabel: "fixture", + strategyID: "fixture", + strategyKind: .apiToken)), + attempts: []) + } + + private static func waitUntil(_ condition: @MainActor () -> Bool) async { + for _ in 0..<1000 { + if condition() { + return + } + await Task.yield() + } + Issue.record("Timed out waiting for provenance state") + } +} + +private actor SpendDashboardProvenanceGate { + private var callCount = 0 + private var firstReleased = false + private var releaseContinuations: [CheckedContinuation] = [] + private var callWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + + func enter() async -> Int { + self.callCount += 1 + let call = self.callCount + let ready = self.callWaiters.filter { self.callCount >= $0.count } + self.callWaiters.removeAll { self.callCount >= $0.count } + ready.forEach { $0.continuation.resume() } + if call == 1, !self.firstReleased { + await withCheckedContinuation { continuation in + self.releaseContinuations.append(continuation) + } + } + return call + } + + func waitForCalls(_ count: Int) async { + if self.callCount >= count { + return + } + await withCheckedContinuation { continuation in + self.callWaiters.append((count, continuation)) + } + } + + func releaseFirst() { + self.firstReleased = true + let continuations = self.releaseContinuations + self.releaseContinuations.removeAll() + continuations.forEach { $0.resume() } + } +} diff --git a/Tests/CodexBarTests/UsageStoreCachedTokenHydrationTests.swift b/Tests/CodexBarTests/UsageStoreCachedTokenHydrationTests.swift index 4a460d2a37..9f8ff0c6c7 100644 --- a/Tests/CodexBarTests/UsageStoreCachedTokenHydrationTests.swift +++ b/Tests/CodexBarTests/UsageStoreCachedTokenHydrationTests.swift @@ -191,6 +191,36 @@ struct UsageStoreCachedTokenHydrationTests { #expect(tokenRefreshCount == 1) } + @Test + func `confirmed empty publication wins over in flight cached codex hydration`() async { + let settings = Self.makeCodexOnlySettings(historyDays: 1) + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + let gate = CachedTokenHydrationGate() + store._test_cachedCodexTokenSnapshotLoaderOverride = { _, _, _ in + await gate.enter() + return (Self.cachedTokenSnapshot(), Date()) + } + + let hydration = store.hydrateCachedTokenSnapshots() + await gate.waitForStart() + store.publishConfirmedEmptyTokenSnapshot(for: .codex) + let confirmedEmptyRevision = store.tokenSnapshotPublicationRevision(for: .codex) + await gate.release() + await hydration?.value + + let publication = store.tokenSnapshotPublicationForCurrentProviderConfig(for: .codex) + #expect(hydration != nil) + #expect(publication?.snapshot == nil) + #expect(publication?.publicationRevision == confirmedEmptyRevision) + #expect(store.tokenSnapshot(for: .codex) == nil) + #expect(store.tokenLastAttemptAt(for: .codex) == nil) + } + private static func makeCodexOnlySettings(historyDays: Int) -> SettingsStore { let suite = "UsageStoreCachedTokenHydrationTests-\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! @@ -216,6 +246,16 @@ struct UsageStoreCachedTokenHydrationTests { return settings } + private static func cachedTokenSnapshot() -> CostUsageTokenSnapshot { + CostUsageTokenSnapshot( + sessionTokens: 42, + sessionCostUSD: 1, + last30DaysTokens: 42, + last30DaysCostUSD: 1, + daily: [], + updatedAt: Date()) + } + private static func writeCodexSessionFile( homeRoot: URL, env: CostUsageTestEnvironment, @@ -257,3 +297,35 @@ struct UsageStoreCachedTokenHydrationTests { ]).write(to: url, atomically: true, encoding: .utf8) } } + +private actor CachedTokenHydrationGate { + private var started = false + private var released = false + private var startWaiters: [CheckedContinuation] = [] + private var releaseWaiters: [CheckedContinuation] = [] + + func enter() async { + self.started = true + let waiters = self.startWaiters + self.startWaiters.removeAll() + waiters.forEach { $0.resume() } + guard !self.released else { return } + await withCheckedContinuation { continuation in + self.releaseWaiters.append(continuation) + } + } + + func waitForStart() async { + guard !self.started else { return } + await withCheckedContinuation { continuation in + self.startWaiters.append(continuation) + } + } + + func release() { + self.released = true + let waiters = self.releaseWaiters + self.releaseWaiters.removeAll() + waiters.forEach { $0.resume() } + } +} diff --git a/Tests/CodexBarTests/UsageStoreCoverageTests.swift b/Tests/CodexBarTests/UsageStoreCoverageTests.swift index b5879f42a5..089ff5045e 100644 --- a/Tests/CodexBarTests/UsageStoreCoverageTests.swift +++ b/Tests/CodexBarTests/UsageStoreCoverageTests.swift @@ -503,15 +503,20 @@ extension UsageStoreCoverageTests { @Test func `widget snapshot projects provider derived token usage`() async throws { let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-widget-provider-cost") + settings.costUsageEnabled = true let store = Self.makeUsageStore(settings: settings) + let formatter = ISO8601DateFormatter() + let updatedAt = try #require(formatter.date(from: "2026-05-26T12:00:00Z")) + let startDate = try #require(formatter.date(from: "2026-05-01T00:00:00Z")) + let endDate = try #require(formatter.date(from: "2026-05-31T23:59:59Z")) let day = MistralDailyUsageBucket( day: "2026-05-26", - cost: 1.2, + cost: 9, inputTokens: 10, cachedTokens: 0, outputTokens: 5, models: []) - store._setSnapshotForTesting(MistralUsageSnapshot( + let providerSnapshot = MistralUsageSnapshot( totalCost: 9, currency: "eur", currencySymbol: "€", @@ -520,9 +525,14 @@ extension UsageStoreCoverageTests { totalCachedTokens: 0, modelCount: 1, daily: [day], - startDate: nil, - endDate: nil, - updatedAt: Date()).toUsageSnapshot(), provider: .mistral) + startDate: startDate, + endDate: endDate, + updatedAt: updatedAt).toUsageSnapshot() + store._setSnapshotForTesting(providerSnapshot, provider: .mistral) + let tokenSnapshot = try #require(store.tokenSnapshot( + fromProviderSnapshot: providerSnapshot, + provider: .mistral)) + store._setTokenSnapshotForTesting(tokenSnapshot, provider: .mistral) var widgetSnapshots: [WidgetSnapshot] = [] store._test_widgetSnapshotSaveOverride = { widgetSnapshots.append($0) } diff --git a/Tests/CodexBarTests/UserFacingLocalizationCoverageTests.swift b/Tests/CodexBarTests/UserFacingLocalizationCoverageTests.swift index c220bf650e..e4c9e3e37f 100644 --- a/Tests/CodexBarTests/UserFacingLocalizationCoverageTests.swift +++ b/Tests/CodexBarTests/UserFacingLocalizationCoverageTests.swift @@ -1,5 +1,6 @@ import Foundation import Testing +@testable import CodexBar struct UserFacingLocalizationCoverageTests { @Test @@ -60,6 +61,9 @@ struct UserFacingLocalizationCoverageTests { "Sources/CodexBar/PreferencesProviderErrorView.swift": [ ".help(\"Copy error\")", ], + "Sources/CodexBar/PreferencesSpendDashboardPane.swift": [ + "Text(\"Model breakdown unavailable\")", + ], "Sources/CodexBar/PreferencesProviderSettingsRows.swift": [ "Text(self.title)", "Text(self.toggle.title)", @@ -110,4 +114,60 @@ struct UserFacingLocalizationCoverageTests { violations.isEmpty, "Raw user-facing localization markers remain:\n\(violations.joined(separator: "\n"))") } + + @Test + func `spend dashboard model breakdown state stays precise and localized`() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let source = try String( + contentsOf: root.appendingPathComponent("Sources/CodexBar/PreferencesSpendDashboardPane.swift"), + encoding: .utf8) + + #expect(source.contains(#"Text(L("Model breakdown unavailable"))"#)) + #expect(source.contains(#"Text(L("No model-level history"))"#)) + } + + @Test + func `spend dashboard chart keeps validated points when aggregate total is unavailable`() { + let start = Date(timeIntervalSince1970: 1_783_036_800) + let points = [ + SpendDashboardModel.DailyPoint( + sourceID: "healthy-claude", + provider: .claude, + providerName: "Claude", + day: start, + cost: 2, + stackStart: 0, + stackEnd: 2), + SpendDashboardModel.DailyPoint( + sourceID: "healthy-openai-1", + provider: .openai, + providerName: "OpenAI", + day: start, + cost: 3, + stackStart: 2, + stackEnd: 5), + SpendDashboardModel.DailyPoint( + sourceID: "healthy-openai-2", + provider: .openai, + providerName: "OpenAI", + day: start.addingTimeInterval(86400), + cost: 4, + stackStart: 0, + stackEnd: 4), + ] + + let partial = SpendDailyChartPresentation(dailyPoints: points, aggregateTotal: nil) + #expect(partial.content == .chart) + #expect(partial.series.map(\.name) == ["Claude", "OpenAI"]) + #expect(partial.dayCount == 2) + CodexBarLocalizationOverride.$appLanguage.withValue("en") { + #expect(partial.accessibilityValue == "2 days of usage data across 2 services") + } + + #expect(SpendDailyChartPresentation(dailyPoints: [], aggregateTotal: nil).content == .unavailable) + #expect(SpendDailyChartPresentation(dailyPoints: [], aggregateTotal: 0).content == .chart) + } } diff --git a/docs/codex.md b/docs/codex.md index a151976729..2c4ef537e1 100644 --- a/docs/codex.md +++ b/docs/codex.md @@ -161,6 +161,16 @@ Example: - pi session cache: `~/Library/Caches/CodexBar/cost-usage/pi-sessions-v1.json` - Window: configurable 1-365 day rolling history, with a 60s minimum refresh interval. +### Usage & Spend account rows + +Settings → Usage & Spend performs a separate fixed 30-day scan for every visible Codex account. Each request freezes +the account source, exact Codex home, authentication fingerprint, and cache identity before scanning. A missing or +invalid home is omitted; it never falls back to ambient `~/.codex` or to the global Codex token snapshot. + +These account rows intentionally exclude pi sessions because pi history is machine-local rather than owned by one +Codex account. The normal Codex cost menu and CLI scan continue to include supported pi history. The dashboard labels +its values as local estimates and keeps currencies separate. + ## Key files - Web: `Sources/CodexBarCore/OpenAIWeb/*` - CLI RPC + diagnostic PTY parser: `Sources/CodexBarCore/UsageFetcher.swift`, diff --git a/docs/providers.md b/docs/providers.md index d1279748e2..91c4aae44b 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -19,6 +19,19 @@ Cookie-based providers expose a Cookie source picker (Automatic or Manual) in Se Some browser cookie imports are cached in Keychain and reused until the session is invalid. API keys, manual cookie headers, source selection, provider ordering, and token accounts are stored in `~/.codexbar/config.json`. +## Usage & Spend settings + +Settings → Usage & Spend combines local 7- or 30-day estimated history only for enabled descriptors that advertise +token-cost support: Codex, Claude, Vertex AI, OpenAI, Mistral, and AWS Bedrock. Providers without a cost-history +contract are omitted instead of appearing as empty subscriptions. + +Each native currency has its own total, subscription/model ranking, and daily chart. CodexBar never adds or ranks +amounts across currencies. Coverage text reports how many days of the selected local calendar window are covered by +the scan window; a 30-day selection is not labeled as complete when the available scan window covers fewer days. + +The view stays local and does not upload usage history. Refreshes retain the last successful model if a replacement +scan fails, while provider/account configuration changes replace obsolete results. + | Provider | Strategies (ordered for auto) | | --- | --- | | Codex | App Auto: OAuth API (`oauth`) → CLI RPC/PTy (`codex-cli`). CLI Auto: Web dashboard (`openai-web`) → CLI RPC/PTy (`codex-cli`). | diff --git a/docs/screenshots/usage-spend-dashboard-maintainer-dark.png b/docs/screenshots/usage-spend-dashboard-maintainer-dark.png new file mode 100644 index 0000000000..940f6a271f Binary files /dev/null and b/docs/screenshots/usage-spend-dashboard-maintainer-dark.png differ diff --git a/docs/screenshots/usage-spend-dashboard-maintainer-light.png b/docs/screenshots/usage-spend-dashboard-maintainer-light.png new file mode 100644 index 0000000000..10aa2fb178 Binary files /dev/null and b/docs/screenshots/usage-spend-dashboard-maintainer-light.png differ