diff --git a/README.md b/README.md index 5e86f43181..a0f34f8842 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,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. Codex history uses a WAL-enabled SQLite store capped at 25,000 retained session entries and 256 MiB. -- A persistent Settings → Usage & Spend view for local 7/30-day estimates, grouped by native currency and limited to providers that expose cost history. +- A persistent Settings → Usage & Spend view for local 7/30/365-day estimates, grouped by native currency, with every tracked subscription/key visible and unsupported cost sources excluded from totals. - 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/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift index 8cd6d85ba8..72f1722d34 100644 --- a/Sources/CodexBar/PreferencesSpendDashboardPane.swift +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -8,6 +8,7 @@ func spendDashboardDayRangeText(_ days: Int) -> String { switch days { case 7: template = L("7d") case 30: template = L("30d") + case 365: template = L("365d") default: return codexBarLocalizedInteger(days) } return template.replacingOccurrences( @@ -15,6 +16,10 @@ func spendDashboardDayRangeText(_ days: Int) -> String { with: codexBarLocalizedInteger(days)) } +func spendDashboardRequiredHistoryDays(selectedDays: Int, configuredDays: Int) -> Int { + max(1, min(365, max(selectedDays, configuredDays))) +} + func spendDashboardRankText(_ rank: Int) -> String { "#\(codexBarLocalizedInteger(rank))" } @@ -44,6 +49,76 @@ func codexCostCatchUpProgressText(_ activity: CodexCostCatchUpActivity) -> Strin return L("Loading…") } +func spendDashboardTrackedSourceStatusText(_ source: SpendDashboardTrackedSource) -> String { + switch source.state { + case .needsAttention: + return L("Unavailable") + case .awaitingUsage: + return L("No usage yet") + case .connected, .configured: + break + } + if source.contributesCostHistory { + return source.costHistoryAvailable + ? L("Cost history connected") + : L("Cost history pending") + } + return source.state == .connected + ? L("Usage connected · not in cost total") + : L("Configured · not in cost total") +} + +func spendDashboardTrackedSourcesForPresentation( + _ sources: [SpendDashboardTrackedSource], + model: SpendDashboardModel) -> [SpendDashboardTrackedSource] +{ + let costedSourceIDs = Set(model.groups.flatMap(\.providers).compactMap { row in + row.totalCost == nil ? nil : row.id + }) + return sources.map { source in + source.withCostHistoryAvailable( + source.costHistoryAvailable || costedSourceIDs.contains(source.id)) + } +} + +func spendDashboardAggregateCostText(_ group: SpendDashboardModel.CurrencyGroup) -> String { + guard let cost = group.totalCost ?? group.knownCost else { return L("Spend unavailable") } + let formatted = UsageFormatter.currencyString(cost, currencyCode: group.currencyCode) + return group.totalCost == nil ? "~\(formatted)" : formatted +} + +func spendDashboardAggregateTokenText(_ group: SpendDashboardModel.CurrencyGroup) -> String { + if let totalTokens = group.totalTokens { + return UsageFormatter.tokenCountString(totalTokens) + } + + var knownTokens = 0 + var hasKnownTokens = false + for tokens in group.providers.compactMap(\.totalTokens) { + let addition = knownTokens.addingReportingOverflow(tokens) + guard !addition.overflow else { return "—" } + knownTokens = addition.partialValue + hasKnownTokens = true + } + guard hasKnownTokens else { return "—" } + return "~\(UsageFormatter.tokenCountString(knownTokens))" +} + +func spendDashboardCostCoverageText(_ group: SpendDashboardModel.CurrencyGroup) -> String { + "\(codexBarLocalizedInteger(group.knownCostProviderCount)) / " + + "\(codexBarLocalizedInteger(group.providers.count)) \(L("Accounts"))" +} + +func spendDashboardProviderCostText( + _ row: SpendDashboardModel.ProviderRow, + currencyCode: String, + requestedDays: Int) -> String +{ + guard let cost = row.totalCost else { return L("Spend unavailable") } + let formatted = UsageFormatter.currencyString(cost, currencyCode: currencyCode) + return row.coveredDayCount < requestedDays ? "~\(formatted)" : formatted +} + enum SpendDashboardModelHistoryPresentation: Equatable { case unavailable case empty @@ -83,6 +158,7 @@ struct SpendDashboardPane: View { self.header self.codexCostCatchUpPanel self.content + self.trackedAccess self.provenance self.shareAction } @@ -91,6 +167,7 @@ struct SpendDashboardPane: View { .background(FocusResigningBackground()) .onAppear { self.isVisible = true + self.applySelectedHistoryCoverage() self.controller.refreshDateWindow() self.controller.update(configuration: self.configuration) if !self.controller.isRefreshing { @@ -110,6 +187,7 @@ struct SpendDashboardPane: View { } .onDisappear { self.isVisible = false + self.settings.setSpendDashboardHistoryDaysOverride(nil) self.controller.stop() } .onReceive(NotificationCenter.default.publisher(for: .NSCalendarDayChanged)) { _ in @@ -128,34 +206,12 @@ struct SpendDashboardPane: View { } 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) - } + SpendDashboardHeader( + selectedDays: self.controller.selectedDays, + isRefreshing: self.controller.isRefreshing, + isCostTrackingEnabled: self.settings.costUsageEnabled, + selectDays: { self.daysBinding.wrappedValue = $0 }, + refresh: { self.controller.refresh() }) } @ViewBuilder @@ -339,6 +395,22 @@ struct SpendDashboardPane: View { } } + @ViewBuilder + private var trackedAccess: some View { + let sources = spendDashboardTrackedSourcesForPresentation( + self.configuration.trackedSources, + model: self.controller.model) + if !sources.isEmpty { + SpendTrackedAccessPanel( + sources: sources, + description: self.trackedAccessDescription) + } + } + + private var trackedAccessDescription: String { + L("Every configured subscription or key stays visible. Only compatible sources enter cost totals.") + } + private var shareAction: some View { HStack { Spacer() @@ -353,9 +425,51 @@ struct SpendDashboardPane: View { } private var sharePayload: ShareStatsPayload? { - ShareStatsBuilder.make( + Self.makeSharePayload( model: self.controller.model, - subscriptionNames: self.subscriptionNames) + subscriptionNames: self.subscriptionNames, + trackedSources: self.configuration.trackedSources) + } + + static func makeSharePayload( + model: SpendDashboardModel, + subscriptionNames: [String: ShareStatsSubscriptionName], + trackedSources: [SpendDashboardTrackedSource]) -> ShareStatsPayload? + { + let fallbackCurrencyCode = model.groups.first?.currencyCode ?? "USD" + let sourcesByProvider = Dictionary(grouping: trackedSources, by: \.provider) + var seenProviders: Set = [] + let roster = trackedSources.compactMap { source -> ShareStatsProviderRosterEntry? in + guard seenProviders.insert(source.provider).inserted, + let sources = sourcesByProvider[source.provider] + else { return nil } + return ShareStatsProviderRosterEntry( + provider: source.provider, + providerName: source.providerName, + currencyCode: fallbackCurrencyCode, + expectedSourceIDs: self.shareExpectedSourceIDs( + provider: source.provider, + sources: sources)) + } + return ShareStatsBuilder.make( + model: model, + subscriptionNames: subscriptionNames, + providerRoster: roster) + } + + private static func shareExpectedSourceIDs( + provider: UsageProvider, + sources: [SpendDashboardTrackedSource]) -> Set + { + let providerScopedSources = sources.filter { source in + source.contributesCostHistory && + (source.id == "\(provider.rawValue):current" || + source.id.hasPrefix("\(provider.rawValue):account:")) + } + if providerScopedSources.count == 1 { + return [provider.rawValue] + } + return Set(sources.map(\.id)) } private var subscriptionNames: [String: ShareStatsSubscriptionName] { @@ -388,7 +502,186 @@ struct SpendDashboardPane: View { private var daysBinding: Binding { Binding( get: { self.controller.selectedDays }, - set: { self.controller.selectDays($0) }) + set: { + self.controller.selectDays($0) + self.applySelectedHistoryCoverage() + self.controller.refreshDateWindow() + }) + } + + private func applySelectedHistoryCoverage() { + let requiredDays = spendDashboardRequiredHistoryDays( + selectedDays: self.controller.selectedDays, + configuredDays: self.settings.costUsageHistoryDays) + self.settings.setSpendDashboardHistoryDaysOverride( + requiredDays == self.settings.costUsageHistoryDays ? nil : requiredDays) + } +} + +struct SpendDashboardHeader: View { + let selectedDays: Int + let isRefreshing: Bool + let isCostTrackingEnabled: Bool + let selectDays: (Int) -> Void + let refresh: () -> Void + + var body: some View { + ViewThatFits(in: .horizontal) { + HStack(alignment: .top, spacing: 16) { + self.title + Spacer(minLength: 16) + self.controls + } + VStack(alignment: .leading, spacing: 12) { + self.title + self.controls + } + } + } + + private var title: some View { + 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) + .fixedSize(horizontal: false, vertical: true) + } + } + + private var controls: some View { + HStack(spacing: 12) { + Picker(L("Time range"), selection: self.daysBinding) { + Text(spendDashboardDayRangeText(7)).tag(7) + Text(spendDashboardDayRangeText(30)).tag(30) + Text(spendDashboardDayRangeText(365)).tag(365) + } + .labelsHidden() + .pickerStyle(.segmented) + .frame(width: 174) + + Button(action: self.refresh) { + if self.isRefreshing { + ProgressView().controlSize(.small) + } else { + Label(L("Refresh"), systemImage: "arrow.clockwise") + } + } + .disabled(self.isRefreshing || !self.isCostTrackingEnabled) + } + } + + private var daysBinding: Binding { + Binding(get: { self.selectedDays }, set: { self.selectDays($0) }) + } +} + +struct SpendTrackedAccessPanel: View { + let sources: [SpendDashboardTrackedSource] + let description: String + + private let columns = [ + GridItem(.adaptive(minimum: 245, maximum: 420), spacing: 12), + ] + + var body: some View { + SpendDashboardPanel { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 12) { + Text(L("Tracked access")) + .font(.headline) + Spacer(minLength: 12) + Text( + "\(codexBarLocalizedInteger(self.sources.count)) " + + L("tracked sources")) + .font(.caption.monospacedDigit().weight(.semibold)) + .foregroundStyle(.secondary) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(.quaternary.opacity(0.7), in: Capsule()) + .accessibilityLabel( + "\(codexBarLocalizedInteger(self.sources.count)) \(L("tracked sources"))") + } + Text(self.description) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + LazyVGrid(columns: self.columns, alignment: .leading, spacing: 12) { + ForEach(self.sources) { source in + SpendTrackedSourceRow(source: source) + } + } + } + } + } +} + +private struct SpendTrackedSourceRow: View { + let source: SpendDashboardTrackedSource + + var body: some View { + VStack(alignment: .leading, spacing: 9) { + HStack(spacing: 10) { + SpendProviderIcon(provider: self.source.provider) + + VStack(alignment: .leading, spacing: 2) { + Text(self.source.providerName) + .font(.subheadline.weight(.medium)) + .lineLimit(1) + if let accountName = self.source.accountName { + Text(accountName) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + Spacer(minLength: 4) + } + + Label( + spendDashboardTrackedSourceStatusText(self.source), + systemImage: self.statusSymbol) + .font(.caption.weight(.medium)) + .foregroundStyle(self.statusColor) + .fixedSize(horizontal: false, vertical: true) + } + .padding(12) + .frame(maxWidth: .infinity, minHeight: 72, alignment: .leading) + .background(.background.opacity(0.72), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(Color(nsColor: .separatorColor).opacity(0.25)) + } + .accessibilityElement(children: .combine) + } + + private var statusSymbol: String { + switch self.source.state { + case .needsAttention: + return "exclamationmark.triangle.fill" + case .awaitingUsage: + return "clock" + case .connected, .configured: + break + } + if self.source.contributesCostHistory { + return self.source.costHistoryAvailable ? "checkmark.circle.fill" : "clock.fill" + } + return self.source.state == .connected ? "minus.circle.fill" : "minus.circle" + } + + private var statusColor: Color { + if self.source.state == .needsAttention { + return .orange + } + if self.source.contributesCostHistory { + return self.source.costHistoryAvailable ? .green : .orange + } + return .secondary } } @@ -412,15 +705,28 @@ private struct SpendCurrencySection: View { let group: SpendDashboardModel.CurrencyGroup let requestedDays: Int + var body: some View { + VStack(alignment: .leading, spacing: 12) { + SpendCurrencySummaryView(group: self.group, requestedDays: self.requestedDays) + + SpendProviderPanel(group: self.group, requestedDays: self.requestedDays) + SpendModelPanel(group: self.group) + SpendDailyChart(group: self.group) + } + } +} + +struct SpendCurrencySummaryView: 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")) + Text(spendDashboardAggregateCostText(self.group)) .font(.title3.weight(.semibold)) .monospacedDigit() } @@ -437,22 +743,18 @@ private struct SpendCurrencySection: View { HStack(spacing: 24) { SpendSummaryValue( title: L("Estimated spend"), - value: self.group.totalCost.map { - UsageFormatter.currencyString($0, currencyCode: self.group.currencyCode) - } ?? "—") + value: spendDashboardAggregateCostText(self.group)) SpendSummaryValue( title: L("Tracked tokens"), - value: self.group.totalTokens.map(UsageFormatter.tokenCountString) ?? "—") + value: spendDashboardAggregateTokenText(self.group)) + SpendSummaryValue( + title: L("Coverage"), + value: spendDashboardCostCoverageText(self.group)) SpendSummaryValue( title: L("Subscriptions"), value: codexBarLocalizedInteger(self.group.providers.count)) - Spacer() } } - - SpendProviderPanel(group: self.group) - SpendModelPanel(group: self.group) - SpendDailyChart(group: self.group) } } } @@ -470,11 +772,13 @@ private struct SpendSummaryValue: View { .font(.system(.title2, design: .rounded, weight: .semibold)) .monospacedDigit() } + .frame(maxWidth: .infinity, alignment: .leading) } } private struct SpendProviderPanel: View { let group: SpendDashboardModel.CurrencyGroup + let requestedDays: Int var body: some View { SpendDashboardPanel { @@ -492,9 +796,10 @@ private struct SpendProviderPanel: View { SpendProviderIcon(provider: row.provider) Text(row.displayName).lineLimit(1) Spacer() - Text(row.totalCost.map { - UsageFormatter.currencyString($0, currencyCode: self.group.currencyCode) - } ?? L("Spend unavailable")) + Text(spendDashboardProviderCostText( + row, + currencyCode: self.group.currencyCode, + requestedDays: self.requestedDays)) .foregroundStyle(row.totalCost == nil ? .secondary : .primary) .monospacedDigit() } diff --git a/Sources/CodexBar/ProviderRegistry.swift b/Sources/CodexBar/ProviderRegistry.swift index 769cb752ea..ca9add3ad3 100644 --- a/Sources/CodexBar/ProviderRegistry.swift +++ b/Sources/CodexBar/ProviderRegistry.swift @@ -85,7 +85,7 @@ struct ProviderRegistry { } } }, - costUsageHistoryDays: settings.costUsageHistoryDays, + costUsageHistoryDays: settings.effectiveCostUsageHistoryDays, persistsCLISessions: true, persistentCLISessionIdleWindow: Self.persistentCLISessionIdleWindow( refreshInterval: Self.nominalRefreshInterval(for: settings.refreshFrequency))) diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index 91cf51a0ae..7cba0fc2f0 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -30,6 +30,7 @@ " providers" = " providers"; "(System)" = "(النظام)"; "30d" = "30 يومًا"; +"365d" = "365 يومًا"; "7d" = "7 أيام"; "A managed Codex login is already running. Wait for it to finish before adding " = "تسجيل دخول Codex المدار يعمل بالفعل. انتظر حتى ينتهي قبل إضافة "; "API key" = "مفتاح API"; @@ -1296,6 +1297,13 @@ "Model breakdown unavailable" = "تفصيل الإنفاق حسب النموذج غير متاح"; "Local estimated history" = "السجل التقديري المحلي"; "Coverage" = "التغطية"; +"Tracked access" = "الوصول المُتتبَّع"; +"Every configured subscription or key stays visible. Only compatible sources enter cost totals." = "يبقى كل اشتراك أو مفتاح مُهيأ مرئيًا. المصادر المتوافقة فقط تدخل في إجماليات التكلفة."; +"tracked sources" = "المصادر المُتتبَّعة"; +"Cost history connected" = "سجل التكلفة متصل"; +"Cost history pending" = "سجل التكلفة قيد الانتظار"; +"Usage connected · not in cost total" = "الاستخدام متصل · غير مشمول في إجمالي التكلفة"; +"Configured · not in cost total" = "مُهيأ · غير مشمول في إجمالي التكلفة"; "Estimated spend" = "الإنفاق التقديري"; "Tracked tokens" = "الرموز المتتبعة"; "Subscriptions" = "الاشتراكات"; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index a4517ef04f..eb0699f48e 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -30,6 +30,7 @@ " providers" = " proveïdors"; "(System)" = "(Sistema)"; "30d" = "30 d"; +"365d" = "365 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"; @@ -1295,6 +1296,13 @@ "Model breakdown unavailable" = "Desglossament per model no disponible"; "Local estimated history" = "Historial local estimat"; "Coverage" = "Cobertura"; +"Tracked access" = "Accés fet un seguiment"; +"Every configured subscription or key stays visible. Only compatible sources enter cost totals." = "Totes les subscripcions o claus configurades resten visibles. Només les fonts compatibles entren als totals de cost."; +"tracked sources" = "fonts amb seguiment"; +"Cost history connected" = "Historial de costos connectat"; +"Cost history pending" = "Historial de costos pendent"; +"Usage connected · not in cost total" = "Ús connectat · no inclòs al total de cost"; +"Configured · not in cost total" = "Configurat · no inclòs al total de cost"; "Estimated spend" = "Despesa estimada"; "Tracked tokens" = "Tokens registrats"; "Subscriptions" = "Subscripcions"; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index d319691269..68293c7ba5 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -50,6 +50,7 @@ " providers" = "Anbieter"; "(System)" = "(System)"; "30d" = "30d"; +"365d" = "365d"; "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"; @@ -1293,6 +1294,13 @@ "Model breakdown unavailable" = "Modellaufschlüsselung nicht verfügbar"; "Local estimated history" = "Lokaler Schätzverlauf"; "Coverage" = "Abdeckung"; +"Tracked access" = "Verfolgter Zugriff"; +"Every configured subscription or key stays visible. Only compatible sources enter cost totals." = "Jedes konfigurierte Abonnement oder jeder Schlüssel bleibt sichtbar. Nur kompatible Quellen fließen in die Kostensummen ein."; +"tracked sources" = "verfolgte Quellen"; +"Cost history connected" = "Kostenverlauf verbunden"; +"Cost history pending" = "Kostenverlauf ausstehend"; +"Usage connected · not in cost total" = "Nutzung verbunden · nicht in der Kostensumme"; +"Configured · not in cost total" = "Konfiguriert · nicht in der Kostensumme"; "Estimated spend" = "Geschätzte Ausgaben"; "Tracked tokens" = "Erfasste Token"; "Subscriptions" = "Abonnements"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index aa1580cb8f..0ba58a695c 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"; +"365d" = "365d"; "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"; @@ -1274,6 +1275,13 @@ "Model breakdown unavailable" = "Model breakdown unavailable"; "Local estimated history" = "Local estimated history"; "Coverage" = "Coverage"; +"Tracked access" = "Tracked access"; +"Every configured subscription or key stays visible. Only compatible sources enter cost totals." = "Every configured subscription or key stays visible. Only compatible sources enter cost totals."; +"tracked sources" = "tracked sources"; +"Cost history connected" = "Cost history connected"; +"Cost history pending" = "Cost history pending"; +"Usage connected · not in cost total" = "Usage connected · not in cost total"; +"Configured · not in cost total" = "Configured · not in cost total"; "Estimated spend" = "Estimated spend"; "Tracked tokens" = "Tracked tokens"; "Subscriptions" = "Subscriptions"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 6fc06eb6af..8719f75413 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -50,6 +50,7 @@ " providers" = " proveedores"; "(System)" = "(Sistema)"; "30d" = "30 d"; +"365d" = "365 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"; @@ -1291,6 +1292,13 @@ "Model breakdown unavailable" = "Desglose por modelo no disponible"; "Local estimated history" = "Historial local estimado"; "Coverage" = "Cobertura"; +"Tracked access" = "Acceso rastreado"; +"Every configured subscription or key stays visible. Only compatible sources enter cost totals." = "Cada suscripción o clave configurada permanece visible. Solo las fuentes compatibles entran en los totales de costo."; +"tracked sources" = "fuentes rastreadas"; +"Cost history connected" = "Historial de costos conectado"; +"Cost history pending" = "Historial de costos pendiente"; +"Usage connected · not in cost total" = "Uso conectado · no incluido en el total de costo"; +"Configured · not in cost total" = "Configurado · no incluido en el total de costo"; "Estimated spend" = "Gasto estimado"; "Tracked tokens" = "Tokens registrados"; "Subscriptions" = "Suscripciones"; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index 4c16ef57d8..30b2789515 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -30,6 +30,7 @@ " providers" = " providers"; "(System)" = "(سیستم)"; "30d" = "30 روز"; +"365d" = "365 روز"; "7d" = "7 روز"; "A managed Codex login is already running. Wait for it to finish before adding " = "یک ورود Codex مدیریت شده هم اکنون در حال اجرا است. صبر کنید تا تمام شود و بعد را اضافه کنید"; "API key" = "کلید API"; @@ -1296,6 +1297,13 @@ "Model breakdown unavailable" = "تفکیک مدل در دسترس نیست"; "Local estimated history" = "تاریخچه برآورد محلی"; "Coverage" = "پوشش"; +"Tracked access" = "دسترسی ردیابی‌شده"; +"Every configured subscription or key stays visible. Only compatible sources enter cost totals." = "هر اشتراک یا کلید پیکربندی‌شده قابل مشاهده می‌ماند. فقط منابع سازگار در مجموع هزینه‌ها لحاظ می‌شوند."; +"tracked sources" = "منابع ردیابی‌شده"; +"Cost history connected" = "تاریخچه هزینه متصل است"; +"Cost history pending" = "تاریخچه هزینه در انتظار"; +"Usage connected · not in cost total" = "مصرف متصل · در مجموع هزینه لحاظ نشده"; +"Configured · not in cost total" = "پیکربندی‌شده · در مجموع هزینه لحاظ نشده"; "Estimated spend" = "برآورد هزینه"; "Tracked tokens" = "توکن‌های پیگیری‌شده"; "Subscriptions" = "اشتراک‌ها"; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index 5aa43d5d01..60d7b26889 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -50,6 +50,7 @@ " providers" = " fournisseurs"; "(System)" = "(System)"; "30d" = "30d"; +"365d" = "365d"; "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"; @@ -1292,6 +1293,13 @@ "Model breakdown unavailable" = "Répartition par modèle indisponible"; "Local estimated history" = "Historique local estimé"; "Coverage" = "Couverture"; +"Tracked access" = "Accès suivi"; +"Every configured subscription or key stays visible. Only compatible sources enter cost totals." = "Chaque abonnement ou clé configuré reste visible. Seules les sources compatibles entrent dans les totaux de coût."; +"tracked sources" = "sources suivies"; +"Cost history connected" = "Historique des coûts connecté"; +"Cost history pending" = "Historique des coûts en attente"; +"Usage connected · not in cost total" = "Utilisation connectée · non incluse dans le total des coûts"; +"Configured · not in cost total" = "Configuré · non inclus dans le total des coûts"; "Estimated spend" = "Dépenses estimées"; "Tracked tokens" = "Jetons suivis"; "Subscriptions" = "Abonnements"; diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings index 31845a12f8..34414bbf6a 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -30,6 +30,7 @@ " providers" = " provedores"; "(System)" = "(Sistema)"; "30d" = "30d"; +"365d" = "365d"; "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"; @@ -1292,6 +1293,13 @@ "Model breakdown unavailable" = "Desglose por modelo non dispoñible"; "Local estimated history" = "Historial local estimado"; "Coverage" = "Cobertura"; +"Tracked access" = "Acceso rastrexado"; +"Every configured subscription or key stays visible. Only compatible sources enter cost totals." = "Cada subscrición ou clave configurada permanece visible. Só as fontes compatibles entran nos totais de custo."; +"tracked sources" = "fontes rastrexadas"; +"Cost history connected" = "Historial de custos conectado"; +"Cost history pending" = "Historial de custos pendente"; +"Usage connected · not in cost total" = "Uso conectado · non incluído no total de custo"; +"Configured · not in cost total" = "Configurado · non incluído no total de custo"; "Estimated spend" = "Gasto estimado"; "Tracked tokens" = "Tokens rexistrados"; "Subscriptions" = "Subscricións"; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index 5832025fc3..81bba93707 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -30,6 +30,7 @@ " providers" = " penyedia"; "(System)" = "(Sistem)"; "30d" = "30 hari"; +"365d" = "365 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"; @@ -1296,6 +1297,13 @@ "Model breakdown unavailable" = "Rincian per model tidak tersedia"; "Local estimated history" = "Riwayat perkiraan lokal"; "Coverage" = "Cakupan"; +"Tracked access" = "Akses terlacak"; +"Every configured subscription or key stays visible. Only compatible sources enter cost totals." = "Setiap langganan atau kunci yang dikonfigurasi tetap terlihat. Hanya sumber yang kompatibel yang masuk ke total biaya."; +"tracked sources" = "sumber terlacak"; +"Cost history connected" = "Riwayat biaya terhubung"; +"Cost history pending" = "Riwayat biaya tertunda"; +"Usage connected · not in cost total" = "Penggunaan terhubung · tidak termasuk dalam total biaya"; +"Configured · not in cost total" = "Dikonfigurasi · tidak termasuk dalam total biaya"; "Estimated spend" = "Perkiraan pengeluaran"; "Tracked tokens" = "Token yang dilacak"; "Subscriptions" = "Langganan"; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index ec76fed89f..8c23a0a8c9 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -30,6 +30,7 @@ " providers" = " provider"; "(System)" = "(Sistema)"; "30d" = "30 g"; +"365d" = "365 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"; @@ -1296,6 +1297,13 @@ "Model breakdown unavailable" = "Ripartizione per modello non disponibile"; "Local estimated history" = "Cronologia locale stimata"; "Coverage" = "Copertura"; +"Tracked access" = "Accesso tracciato"; +"Every configured subscription or key stays visible. Only compatible sources enter cost totals." = "Ogni abbonamento o chiave configurata rimane visibile. Solo le fonti compatibili entrano nei totali dei costi."; +"tracked sources" = "fonti tracciate"; +"Cost history connected" = "Cronologia costi connessa"; +"Cost history pending" = "Cronologia costi in sospeso"; +"Usage connected · not in cost total" = "Utilizzo connesso · non incluso nel totale dei costi"; +"Configured · not in cost total" = "Configurato · non incluso nel totale dei costi"; "Estimated spend" = "Spesa stimata"; "Tracked tokens" = "Token tracciati"; "Subscriptions" = "Abbonamenti"; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index 21d1180d60..cac4fce285 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -50,6 +50,7 @@ " providers" = " 件のプロバイダ"; "(System)" = "(システム)"; "30d" = "30日"; +"365d" = "365日"; "7d" = "7日"; "A managed Codex login is already running. Wait for it to finish before adding " = "管理対象の Codex ログインがすでに実行中です。完了を待ってから追加してください "; "API key" = "API キー"; @@ -1293,6 +1294,13 @@ "Model breakdown unavailable" = "モデル別の内訳を取得できません"; "Local estimated history" = "ローカル推定履歴"; "Coverage" = "対象範囲"; +"Tracked access" = "追跡中のアクセス"; +"Every configured subscription or key stays visible. Only compatible sources enter cost totals." = "設定済みのサブスクリプションやキーはすべて表示されたままになります。互換性のあるソースのみがコスト合計に含まれます。"; +"tracked sources" = "追跡中のソース"; +"Cost history connected" = "コスト履歴が接続済み"; +"Cost history pending" = "コスト履歴が保留中"; +"Usage connected · not in cost total" = "使用状況接続済み · コスト合計に含まれません"; +"Configured · not in cost total" = "設定済み · コスト合計に含まれません"; "Estimated spend" = "推定支出"; "Tracked tokens" = "追跡対象トークン"; "Subscriptions" = "サブスクリプション"; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index 5a5c19785b..17811dde33 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -50,6 +50,7 @@ " providers" = " 공급자"; "(System)" = "(시스템)"; "30d" = "30일"; +"365d" = "365일"; "7d" = "7일"; "A managed Codex login is already running. Wait for it to finish before adding " = "관리되는 Codex 로그인이 이미 실행 중입니다. 추가하기 전에 완료될 때까지 기다리세요. "; "API key" = "API 키"; @@ -1260,6 +1261,13 @@ "Model breakdown unavailable" = "모델별 내역을 사용할 수 없습니다"; "Local estimated history" = "로컬 예상 내역"; "Coverage" = "포함 범위"; +"Tracked access" = "추적된 접근"; +"Every configured subscription or key stays visible. Only compatible sources enter cost totals." = "구성된 모든 구독 또는 키가 계속 표시됩니다. 호환되는 소스만 비용 합계에 포함됩니다."; +"tracked sources" = "추적된 소스"; +"Cost history connected" = "비용 기록 연결됨"; +"Cost history pending" = "비용 기록 대기 중"; +"Usage connected · not in cost total" = "사용량 연결됨 · 비용 합계에 포함되지 않음"; +"Configured · not in cost total" = "구성됨 · 비용 합계에 포함되지 않음"; "Estimated spend" = "예상 지출"; "Tracked tokens" = "추적된 토큰"; "Subscriptions" = "구독"; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index 3f386b640c..17bee88f77 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -50,6 +50,7 @@ " providers" = " providers"; "(System)" = "(Systeem)"; "30d" = "30d"; +"365d" = "365d"; "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"; @@ -1292,6 +1293,13 @@ "Model breakdown unavailable" = "Uitsplitsing per model niet beschikbaar"; "Local estimated history" = "Lokaal geschatte geschiedenis"; "Coverage" = "Dekking"; +"Tracked access" = "Bijgehouden toegang"; +"Every configured subscription or key stays visible. Only compatible sources enter cost totals." = "Elk geconfigureerd abonnement of elke sleutel blijft zichtbaar. Alleen compatibele bronnen tellen mee in de kostentotalen."; +"tracked sources" = "bijgehouden bronnen"; +"Cost history connected" = "Kostengeschiedenis verbonden"; +"Cost history pending" = "Kostengeschiedenis in behandeling"; +"Usage connected · not in cost total" = "Gebruik verbonden · niet in het kostentotaal"; +"Configured · not in cost total" = "Geconfigureerd · niet in het kostentotaal"; "Estimated spend" = "Geschatte uitgaven"; "Tracked tokens" = "Bijgehouden tokens"; "Subscriptions" = "Abonnementen"; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index 2eb1478c74..30286f458a 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -30,6 +30,7 @@ " providers" = " providers"; "(System)" = "(System)"; "30d" = "30d"; +"365d" = "365d"; "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"; @@ -1296,6 +1297,13 @@ "Model breakdown unavailable" = "Podział według modeli jest niedostępny"; "Local estimated history" = "Lokalna historia szacunkowa"; "Coverage" = "Pokrycie"; +"Tracked access" = "Śledzony dostęp"; +"Every configured subscription or key stays visible. Only compatible sources enter cost totals." = "Każda skonfigurowana subskrypcja lub klucz pozostaje widoczna. Tylko kompatybilne źródła wchodzą do sum kosztów."; +"tracked sources" = "śledzone źródła"; +"Cost history connected" = "Historia kosztów połączona"; +"Cost history pending" = "Historia kosztów oczekująca"; +"Usage connected · not in cost total" = "Użycie połączone · nie wliczane do sumy kosztów"; +"Configured · not in cost total" = "Skonfigurowane · nie wliczane do sumy kosztów"; "Estimated spend" = "Szacowane wydatki"; "Tracked tokens" = "Śledzone tokeny"; "Subscriptions" = "Subskrypcje"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index 09cdd79c12..1136e164b1 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -50,6 +50,7 @@ " providers" = " provedores"; "(System)" = "(Sistema)"; "30d" = "30d"; +"365d" = "365d"; "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"; @@ -1293,6 +1294,13 @@ "Model breakdown unavailable" = "Detalhamento por modelo indisponível"; "Local estimated history" = "Histórico local estimado"; "Coverage" = "Cobertura"; +"Tracked access" = "Acesso rastreado"; +"Every configured subscription or key stays visible. Only compatible sources enter cost totals." = "Cada assinatura ou chave configurada permanece visível. Apenas fontes compatíveis entram nos totais de custo."; +"tracked sources" = "fontes rastreadas"; +"Cost history connected" = "Histórico de custos conectado"; +"Cost history pending" = "Histórico de custos pendente"; +"Usage connected · not in cost total" = "Uso conectado · não incluído no total de custo"; +"Configured · not in cost total" = "Configurado · não incluído no total de custo"; "Estimated spend" = "Gastos estimados"; "Tracked tokens" = "Tokens acompanhados"; "Subscriptions" = "Assinaturas"; diff --git a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings index 3d9c80387e..a0043ef145 100644 --- a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings @@ -50,6 +50,7 @@ " providers" = " провайдеров"; "(System)" = "(Система)"; "30d" = "30 дн."; +"365d" = "365 дн."; "7d" = "7 дн."; "A managed Codex login is already running. Wait for it to finish before adding " = "Управляемый вход Codex уже активен. Подождите, пока он завершится, прежде чем добавлять "; "API key" = "API-ключ"; @@ -1294,6 +1295,13 @@ "Model breakdown unavailable" = "Разбивка по моделям недоступна"; "Local estimated history" = "Локальная история оценок"; "Coverage" = "Охват"; +"Tracked access" = "Отслеживаемый доступ"; +"Every configured subscription or key stays visible. Only compatible sources enter cost totals." = "Каждая настроенная подписка или ключ остаются видимыми. Только совместимые источники входят в итоговые суммы затрат."; +"tracked sources" = "отслеживаемые источники"; +"Cost history connected" = "История затрат подключена"; +"Cost history pending" = "История затрат ожидается"; +"Usage connected · not in cost total" = "Использование подключено · не входит в сумму затрат"; +"Configured · not in cost total" = "Настроено · не входит в сумму затрат"; "Estimated spend" = "Предполагаемые расходы"; "Tracked tokens" = "Отслеживаемые токены"; "Subscriptions" = "Подписки"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index ec237d5528..02f6e535b3 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -50,6 +50,7 @@ " providers" = " leverantörer"; "(System)" = "(System)"; "30d" = "30 d"; +"365d" = "365 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"; @@ -1291,6 +1292,13 @@ "Model breakdown unavailable" = "Modellfördelning ej tillgänglig"; "Local estimated history" = "Lokal uppskattad historik"; "Coverage" = "Täckning"; +"Tracked access" = "Spårad åtkomst"; +"Every configured subscription or key stays visible. Only compatible sources enter cost totals." = "Varje konfigurerad prenumeration eller nyckel förblir synlig. Endast kompatibla källor ingår i kostnadssummorna."; +"tracked sources" = "spårade källor"; +"Cost history connected" = "Kostnadshistorik ansluten"; +"Cost history pending" = "Kostnadshistorik väntar"; +"Usage connected · not in cost total" = "Användning ansluten · ingår inte i kostnadssumman"; +"Configured · not in cost total" = "Konfigurerad · ingår inte i kostnadssumman"; "Estimated spend" = "Uppskattade utgifter"; "Tracked tokens" = "Spårade token"; "Subscriptions" = "Abonnemang"; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index cb4b5a04d1..b2162ab0d0 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -30,6 +30,7 @@ " providers" = "ผู้ให้บริการ "; "(System)" = "(ระบบ)"; "30d" = "30 วัน"; +"365d" = "365 วัน"; "7d" = "7 วัน"; "A managed Codex login is already running. Wait for it to finish before adding " = "การเข้าสู่ระบบ Codex ที่มีการจัดการกําลังทํางานอยู่แล้ว รอให้เสร็จก่อนที่จะเพิ่ม "; "API key" = "ปุ่ม API"; @@ -1296,6 +1297,13 @@ "Model breakdown unavailable" = "ไม่มีรายละเอียดแยกตามโมเดล"; "Local estimated history" = "ประวัติโดยประมาณในเครื่อง"; "Coverage" = "ความครอบคลุม"; +"Tracked access" = "การเข้าถึงที่ติดตาม"; +"Every configured subscription or key stays visible. Only compatible sources enter cost totals." = "การสมัครสมาชิกหรือคีย์ที่กำหนดค่าไว้ทุกรายการยังคงมองเห็นได้ เฉพาะแหล่งที่เข้ากันได้เท่านั้นที่นับรวมในยอดรวมต้นทุน"; +"tracked sources" = "แหล่งที่ติดตาม"; +"Cost history connected" = "เชื่อมต่อประวัติต้นทุนแล้ว"; +"Cost history pending" = "ประวัติต้นทุนรอดำเนินการ"; +"Usage connected · not in cost total" = "เชื่อมต่อการใช้งานแล้ว · ไม่รวมในยอดรวมต้นทุน"; +"Configured · not in cost total" = "กำหนดค่าแล้ว · ไม่รวมในยอดรวมต้นทุน"; "Estimated spend" = "ค่าใช้จ่ายโดยประมาณ"; "Tracked tokens" = "โทเค็นที่ติดตาม"; "Subscriptions" = "การสมัครสมาชิก"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index 4f2faec2da..e5fa63c1e5 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -30,6 +30,7 @@ " providers" = " sağlayıcı"; "(System)" = "(Sistem)"; "30d" = "30 gün"; +"365d" = "365 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ı"; @@ -1294,6 +1295,13 @@ "Model breakdown unavailable" = "Model dökümü kullanılamıyor"; "Local estimated history" = "Yerel tahmini geçmiş"; "Coverage" = "Kapsam"; +"Tracked access" = "İzlenen erişim"; +"Every configured subscription or key stays visible. Only compatible sources enter cost totals." = "Yapılandırılan her abonelik veya anahtar görünür kalır. Yalnızca uyumlu kaynaklar maliyet toplamlarına girer."; +"tracked sources" = "izlenen kaynaklar"; +"Cost history connected" = "Maliyet geçmişi bağlandı"; +"Cost history pending" = "Maliyet geçmişi beklemede"; +"Usage connected · not in cost total" = "Kullanım bağlandı · maliyet toplamına dahil değil"; +"Configured · not in cost total" = "Yapılandırıldı · maliyet toplamına dahil değil"; "Estimated spend" = "Tahmini harcama"; "Tracked tokens" = "İzlenen tokenlar"; "Subscriptions" = "Abonelikler"; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index 3b471f9a92..de31e53ee7 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -50,6 +50,7 @@ " providers" = "провайдерів"; "(System)" = "(Система)"; "30d" = "30д"; +"365d" = "365д"; "7d" = "7д"; "A managed Codex login is already running. Wait for it to finish before adding " = "Керований вхід до Codex вже запущено. Перш ніж додавати, зачекайте, поки він закінчиться"; "API key" = "Ключ API"; @@ -1292,6 +1293,13 @@ "Model breakdown unavailable" = "Розподіл за моделями недоступний"; "Local estimated history" = "Локальна історія оцінок"; "Coverage" = "Охоплення"; +"Tracked access" = "Відстежуваний доступ"; +"Every configured subscription or key stays visible. Only compatible sources enter cost totals." = "Кожна налаштована підписка або ключ залишаються видимими. Лише сумісні джерела входять до підсумкових сум витрат."; +"tracked sources" = "відстежувані джерела"; +"Cost history connected" = "Історію витрат підключено"; +"Cost history pending" = "Історія витрат очікується"; +"Usage connected · not in cost total" = "Використання підключено · не входить до суми витрат"; +"Configured · not in cost total" = "Налаштовано · не входить до суми витрат"; "Estimated spend" = "Орієнтовні витрати"; "Tracked tokens" = "Відстежувані токени"; "Subscriptions" = "Підписки"; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index 0d43da6353..46d1d021e8 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -50,6 +50,7 @@ " providers" = "nhà cung cấp"; "(System)" = "(Hệ thống)"; "30d" = "30d"; +"365d" = "365d"; "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"; @@ -1293,6 +1294,13 @@ "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"; +"Tracked access" = "Truy cập được theo dõi"; +"Every configured subscription or key stays visible. Only compatible sources enter cost totals." = "Mọi gói đăng ký hoặc khóa đã cấu hình đều vẫn hiển thị. Chỉ các nguồn tương thích mới được tính vào tổng chi phí."; +"tracked sources" = "nguồn được theo dõi"; +"Cost history connected" = "Đã kết nối lịch sử chi phí"; +"Cost history pending" = "Lịch sử chi phí đang chờ"; +"Usage connected · not in cost total" = "Đã kết nối mức sử dụng · không tính vào tổng chi phí"; +"Configured · not in cost total" = "Đã cấu hình · không tính vào tổng chi phí"; "Estimated spend" = "Chi tiêu ước tính"; "Tracked tokens" = "Token được theo dõi"; "Subscriptions" = "Gói đăng ký"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 3ed80d8bcf..75f06b6de2 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -50,6 +50,7 @@ " providers" = " 提供商"; "(System)" = "(System)"; "30d" = "30 天"; +"365d" = "365 天"; "7d" = "7 天"; "A managed Codex login is already running. Wait for it to finish before adding " = "托管 Codex 登录已在运行。请等待其完成后再添加 "; "API key" = "API 密钥"; @@ -1271,6 +1272,13 @@ "Model breakdown unavailable" = "模型明细不可用"; "Local estimated history" = "本地估算历史"; "Coverage" = "覆盖范围"; +"Tracked access" = "已跟踪的访问"; +"Every configured subscription or key stays visible. Only compatible sources enter cost totals." = "每个已配置的订阅或密钥都保持可见。只有兼容的来源才会计入成本总额。"; +"tracked sources" = "已跟踪的来源"; +"Cost history connected" = "成本历史已连接"; +"Cost history pending" = "成本历史待处理"; +"Usage connected · not in cost total" = "用量已连接 · 不计入成本总额"; +"Configured · not in cost total" = "已配置 · 不计入成本总额"; "Estimated spend" = "估算支出"; "Tracked tokens" = "已跟踪 token"; "Subscriptions" = "订阅"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index e8b803e593..dc7a0dba9c 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -50,6 +50,7 @@ " providers" = " 提供者"; "(System)" = "(系統)"; "30d" = "30 天"; +"365d" = "365 天"; "7d" = "7 天"; "A managed Codex login is already running. Wait for it to finish before adding " = "託管 Codex 登入已在執行。請等待其完成後再新增 "; "API key" = "API 金鑰"; @@ -1323,6 +1324,13 @@ "Model breakdown unavailable" = "無法取得模型明細"; "Local estimated history" = "本機預估歷史"; "Coverage" = "涵蓋範圍"; +"Tracked access" = "已追蹤的存取"; +"Every configured subscription or key stays visible. Only compatible sources enter cost totals." = "每個已設定的訂閱或密鑰都保持可見。只有相容的來源才會計入成本總額。"; +"tracked sources" = "已追蹤的來源"; +"Cost history connected" = "成本歷史已連接"; +"Cost history pending" = "成本歷史待處理"; +"Usage connected · not in cost total" = "用量已連接 · 不計入成本總額"; +"Configured · not in cost total" = "已設定 · 不計入成本總額"; "Estimated spend" = "預估支出"; "Tracked tokens" = "已追蹤 token"; "Subscriptions" = "訂閱"; diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index 4c7f74c01f..3750086d19 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -536,6 +536,14 @@ extension SettingsStore { } } + var effectiveCostUsageHistoryDays: Int { + max(self.costUsageHistoryDays, self.spendDashboardHistoryDaysOverride ?? 0) + } + + func setSpendDashboardHistoryDaysOverride(_ days: Int?) { + self.spendDashboardHistoryDaysOverride = days.map { max(1, min(365, $0)) } + } + var costComparisonPeriodsEnabled: Bool { get { self.defaultsState.costComparisonPeriodsEnabled } set { @@ -882,10 +890,10 @@ extension SettingsStore { guard self.hasMergedOverviewSelectionPreference else { return Array(normalizedActive.prefix(maxVisibleProviders)) } - if normalizedActive.count <= maxVisibleProviders, + if normalizedActive.count <= Self.mergedOverviewLegacyWheelNavigationLimit, !self.mergedOverviewSelectionApplies(to: normalizedActive) { - return normalizedActive + return Array(normalizedActive.prefix(maxVisibleProviders)) } let selectedSet = Set(self.mergedOverviewSelectedProviders) @@ -908,7 +916,8 @@ extension SettingsStore { return [] } - let shouldPersistResolvedSelection = normalizedActive.count > maxVisibleProviders || + let shouldPersistResolvedSelection = + normalizedActive.count > Self.mergedOverviewLegacyWheelNavigationLimit || self.mergedOverviewSelectionApplies(to: normalizedActive) if self.hasMergedOverviewSelectionPreference, shouldPersistResolvedSelection { diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index ef5eaa3d7b..fbb2743214 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -188,7 +188,8 @@ enum CodexAccountMenuProjectionRevalidationResult: Equatable { @Observable final class SettingsStore { static let sharedDefaults = AppGroupSupport.sharedDefaults() - static let mergedOverviewProviderLimit = 6 + static let mergedOverviewProviderLimit = UsageProvider.allCases.count + static let mergedOverviewLegacyWheelNavigationLimit = 6 static let productionCodexAccountReconciliationSnapshotCacheInterval: TimeInterval = 2 static let isRunningTests: Bool = { let env = ProcessInfo.processInfo.environment @@ -231,6 +232,7 @@ final class SettingsStore { var providerDetailSettingsRevision: Int = 0 var backgroundWorkSettingsRevision: Int = 0 var costUsageSettingsRevision: UInt64 = 0 + @ObservationIgnored var spendDashboardHistoryDaysOverride: Int? var providerOrder: [ProviderInstanceID] = [] var providerEnablement: [ProviderInstanceID: Bool] = [:] @ObservationIgnored var providerEnablementRevisions: [ProviderInstanceID: UInt64] = [:] diff --git a/Sources/CodexBar/ShareStatsCardView.swift b/Sources/CodexBar/ShareStatsCardView.swift index 1cd70ccfe2..84642fc9aa 100644 --- a/Sources/CodexBar/ShareStatsCardView.swift +++ b/Sources/CodexBar/ShareStatsCardView.swift @@ -5,11 +5,15 @@ struct ShareStatsCardView: View { let payload: ShareStatsPayload - static func providerDisplayLimit(for providerCount: Int) -> Int { + nonisolated static func providerDisplayLimit(for providerCount: Int) -> Int { providerCount > 5 ? 4 : min(providerCount, 5) } - static func providerPaletteIndex( + nonisolated static func modelSectionDetail(for modelCount: Int) -> String { + modelCount > 3 ? "3 OF \(modelCount) · BY TOKENS" : "BY TOKENS" + } + + nonisolated static func providerPaletteIndex( for model: ShareStatsModelPayload, providers: [ShareStatsProviderPayload]) -> Int? { @@ -72,7 +76,9 @@ struct ShareStatsCardView: View { .font(.system(size: 20, weight: .semibold, design: .rounded)) .tracking(1.8) .foregroundStyle(self.secondary) - Text(self.payload.totalTokens.map(ShareStatsFormatting.compactCount) ?? "—") + Text(self.payload.totalTokens.map { + "\(self.payload.totalTokensIsPartial ? "~" : "")\(ShareStatsFormatting.compactCount($0))" + } ?? "—") .font(.system(size: 104, weight: .semibold, design: .rounded)) .monospacedDigit() .lineLimit(1) @@ -92,7 +98,8 @@ struct ShareStatsCardView: View { .foregroundStyle(self.secondary) Spacer() Text(currency.estimatedCost.map { - ShareStatsFormatting.currency($0, code: currency.currencyCode) + let value = ShareStatsFormatting.currency($0, code: currency.currencyCode) + return "\(currency.isPartial ? "~" : "")\(value)" } ?? "Unavailable") .font(.system(size: 32, weight: .semibold, design: .rounded)) .monospacedDigit() @@ -112,14 +119,18 @@ struct ShareStatsCardView: View { private var currencySummary: String { let hiddenCount = self.payload.currencies.count - min(self.payload.currencies.count, 2) return hiddenCount > 0 - ? "+\(hiddenCount) more currencies · see subscription rows" - : "\(self.payload.providers.count) subscriptions · native currencies kept separate" + ? "\(self.spendCoverage) · +\(hiddenCount) more currencies" + : "\(self.spendCoverage) · native currencies kept separate" + } + + private var spendCoverage: String { + "\(self.payload.spendReportingProviderCount)/\(self.payload.providers.count) report spend" } private var rankings: some View { HStack(alignment: .top, spacing: 46) { VStack(alignment: .leading, spacing: 6) { - self.sectionHeader("SUBSCRIPTIONS", detail: "\(self.payload.providers.count) CONNECTED") + self.sectionHeader("TRACKED SERVICES", detail: "\(self.payload.providers.count) TRACKED") ForEach( Array(self.payload.providers.prefix(self.providerDisplayLimit).enumerated()), id: \.offset) @@ -141,9 +152,11 @@ struct ShareStatsCardView: View { VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 6) { - self.sectionHeader("TOP MODELS", detail: "BY USAGE") + self.sectionHeader( + "TOP MODELS", + detail: Self.modelSectionDetail(for: self.payload.topModels.count)) if self.payload.topModels.isEmpty { - Text("No model-level history in this local snapshot") + Text("No share-safe model ranking in this snapshot") .font(.system(size: 18, weight: .medium, design: .rounded)) .foregroundStyle(self.secondary) .padding(.top, 4) @@ -311,7 +324,7 @@ private struct ShareStatsProviderRow: View { } else { metrics.append("Spend unavailable") } - return metrics.isEmpty ? "connected" : metrics.joined(separator: " · ") + return metrics.isEmpty ? "tracked" : metrics.joined(separator: " · ") } } diff --git a/Sources/CodexBar/ShareStatsPayload.swift b/Sources/CodexBar/ShareStatsPayload.swift index 8a8e2945e9..afba001d71 100644 --- a/Sources/CodexBar/ShareStatsPayload.swift +++ b/Sources/CodexBar/ShareStatsPayload.swift @@ -2,6 +2,7 @@ import CodexBarCore import Foundation struct ShareStatsProviderPayload: Sendable, Equatable { + let sourceID: String? let provider: UsageProvider let providerName: String let subscriptionName: String? @@ -9,11 +10,51 @@ struct ShareStatsProviderPayload: Sendable, Equatable { let totalTokens: Int? let estimatedCost: Double? let coveredDayCount: Int + + init( + sourceID: String? = nil, + provider: UsageProvider, + providerName: String, + subscriptionName: String?, + currencyCode: String, + totalTokens: Int?, + estimatedCost: Double?, + coveredDayCount: Int) + { + self.sourceID = sourceID + self.provider = provider + self.providerName = providerName + self.subscriptionName = subscriptionName + self.currencyCode = currencyCode + self.totalTokens = totalTokens + self.estimatedCost = estimatedCost + self.coveredDayCount = coveredDayCount + } +} + +struct ShareStatsProviderRosterEntry: Sendable, Equatable { + let provider: UsageProvider + let providerName: String + let currencyCode: String + let expectedSourceIDs: Set + + init( + provider: UsageProvider, + providerName: String, + currencyCode: String, + expectedSourceIDs: Set = []) + { + self.provider = provider + self.providerName = providerName + self.currencyCode = currencyCode + self.expectedSourceIDs = expectedSourceIDs + } } struct ShareStatsModelPayload: Sendable, Equatable { let provider: UsageProvider let providerName: String + let modelIdentity: String let modelName: String let currencyCode: String let totalTokens: Int? @@ -23,6 +64,7 @@ struct ShareStatsModelPayload: Sendable, Equatable { private struct ShareStatsModelFamilyKey: Hashable { let provider: UsageProvider let providerName: String + let modelIdentity: String let modelName: String let currencyCode: String } @@ -74,6 +116,7 @@ private struct ShareStatsModelFamilyAccumulator { return ShareStatsModelPayload( provider: self.key.provider, providerName: self.key.providerName, + modelIdentity: self.key.modelIdentity, modelName: self.key.modelName, currencyCode: self.key.currencyCode, totalTokens: totalTokens, @@ -85,6 +128,7 @@ struct ShareStatsCurrencyPayload: Sendable, Equatable, Identifiable { let currencyCode: String let estimatedCost: Double? let coveredDayCount: Int + var isPartial = false var id: String { self.currencyCode @@ -98,12 +142,17 @@ struct ShareStatsPayload: Sendable, Equatable { let topModels: [ShareStatsModelPayload] let currencies: [ShareStatsCurrencyPayload] let totalTokens: Int? + let totalTokensIsPartial: Bool var hasShareableData: Bool { !self.providers.isEmpty && self.providers.contains { provider in provider.totalTokens != nil || provider.estimatedCost != nil } } + + var spendReportingProviderCount: Int { + self.providers.count { $0.estimatedCost != nil } + } } struct ShareStatsSubscriptionName: Sendable, Equatable { @@ -142,16 +191,23 @@ enum ShareStatsSanitizer { static func modelName(_ rawValue: String) -> String? { guard let value = self.safeLabel( rawValue, - maximumLength: 72, + maximumLength: 96, maximumWords: 3, requireModelShape: true) else { return nil } let normalized = value.lowercased() + guard !normalized.contains("://"), !normalized.contains("\\") else { return nil } + let pathComponents = normalized.split(separator: "/", omittingEmptySubsequences: false) + guard pathComponents.count <= 2, + pathComponents.allSatisfy({ !$0.isEmpty }) + else { return nil } + + let routedModelName = String(pathComponents.last ?? "") let regionalPrefixes = ["us.", "eu.", "apac.", "global."] - let familyName = regionalPrefixes.first { normalized.hasPrefix($0) }.map { - String(normalized.dropFirst($0.count)) - } ?? normalized + let familyName = regionalPrefixes.first { routedModelName.hasPrefix($0) }.map { + String(routedModelName.dropFirst($0.count)) + } ?? routedModelName let publicModelFamilies: [(prefixes: [String], label: String)] = [ (["amazon.nova-", "nova-"], "Amazon Nova"), (["anthropic.claude-", "claude-", "claude "], "Claude"), @@ -178,13 +234,74 @@ enum ShareStatsSanitizer { (["tts-"], "OpenAI TTS"), (["whisper-"], "Whisper"), ] - guard !normalized.contains("://"), - !normalized.contains("/"), - !normalized.contains("\\") - else { return nil } - return publicModelFamilies.first { family in - family.prefixes.contains(where: familyName.hasPrefix) - }?.label + guard let match = publicModelFamilies.lazy.compactMap({ family -> (String, String)? in + guard let prefix = family.prefixes.first(where: familyName.hasPrefix) else { return nil } + return (prefix, family.label) + }).first else { return nil } + + let remainder = String(familyName.dropFirst(match.0.count)) + guard !remainder.isEmpty else { return match.1 } + guard let detail = self.publicModelDetail(remainder) else { return nil } + return "\(match.1) \(detail)" + } + + private static func publicModelDetail(_ rawValue: String) -> String? { + let displayTokens: [String: String] = [ + "air": "Air", "chat": "Chat", "code": "Code", "coder": "Coder", "codex": "Codex", + "fable": "Fable", "fast": "Fast", "flash": "Flash", "free": "Free", "haiku": "Haiku", + "instruct": "Instruct", "large": "Large", "lite": "Lite", "max": "Max", + "latest": "Latest", "luna": "Luna", "medium": "Medium", "mini": "Mini", "nano": "Nano", + "opus": "Opus", + "oss": "OSS", "preview": "Preview", "pro": "Pro", "reasoning": "Reasoning", + "small": "Small", "sol": "Sol", "sonnet": "Sonnet", "terra": "Terra", "thinking": "Thinking", + "turbo": "Turbo", "vision": "Vision", + ] + let tokens = rawValue.split(whereSeparator: { "-_:".contains($0) }).map(String.init) + var details: [String] = [] + var numericVersion: [String] = [] + + func flushNumericVersion() { + guard !numericVersion.isEmpty else { return } + details.append(numericVersion.joined(separator: ".")) + numericVersion.removeAll(keepingCapacity: true) + } + + for token in tokens { + if token.allSatisfy(\.isNumber), token.count <= 3 { + numericVersion.append(token) + continue + } + flushNumericVersion() + if token.range(of: #"^v[0-9]+$"#, options: .regularExpression) != nil || + token.range(of: #"^[0-9]{8}$"#, options: .regularExpression) != nil + { + break + } + if token.range(of: #"^[0-9]+(?:\.[0-9]+)+$"#, options: .regularExpression) != nil { + details.append(token) + continue + } + if let displayToken = displayTokens[token] { + details.append(displayToken) + continue + } + if token.range(of: #"^[a-z][0-9]+(?:\.[0-9]+)*$"#, options: .regularExpression) != nil { + details.append(token.uppercased()) + continue + } + if token.range(of: #"^[0-9]+b$"#, options: .regularExpression) != nil { + details.append(token.uppercased()) + continue + } + if token.range(of: #"^[0-9]+o$"#, options: .regularExpression) != nil { + details.append(token) + continue + } + return nil + } + flushNumericVersion() + guard !details.isEmpty else { return nil } + return details.joined(separator: " ") } private static func safeLabel( @@ -221,11 +338,13 @@ enum ShareStatsSanitizer { enum ShareStatsBuilder { static func make( model: SpendDashboardModel, - subscriptionNames: [String: ShareStatsSubscriptionName] = [:]) -> ShareStatsPayload? + subscriptionNames: [String: ShareStatsSubscriptionName] = [:], + providerRoster: [ShareStatsProviderRosterEntry] = []) -> ShareStatsPayload? { - let providers = model.groups.flatMap { group in + let trackedProviders = model.groups.flatMap { group in group.providers.map { row in ShareStatsProviderPayload( + sourceID: row.id, provider: row.provider, providerName: row.displayName, subscriptionName: subscriptionNames[row.id]?.displayName, @@ -235,6 +354,41 @@ enum ShareStatsBuilder { coveredDayCount: row.coveredDayCount) } } + let providers: [ShareStatsProviderPayload] + if providerRoster.isEmpty { + providers = trackedProviders + } else { + let trackedByProvider = Dictionary(grouping: trackedProviders, by: \.provider) + var emittedProviders: Set = [] + providers = providerRoster.compactMap { entry in + guard emittedProviders.insert(entry.provider).inserted else { return nil } + let allMatches = trackedByProvider[entry.provider] ?? [] + let matches = self.rosterMatches(entry: entry, candidates: allMatches) + guard !matches.isEmpty else { + return ShareStatsProviderPayload( + provider: entry.provider, + providerName: entry.providerName, + subscriptionName: nil, + currencyCode: entry.currencyCode, + totalTokens: nil, + estimatedCost: nil, + coveredDayCount: 0) + } + let knownCosts = matches.compactMap(\.estimatedCost) + let currencyCodes = Set(matches.map(\.currencyCode)) + let coveredDayCount = matches.filter { $0.estimatedCost != nil || $0.totalTokens != nil } + .map(\.coveredDayCount) + .min() ?? 0 + return ShareStatsProviderPayload( + provider: entry.provider, + providerName: entry.providerName, + subscriptionName: matches.count == 1 ? matches[0].subscriptionName : nil, + currencyCode: matches[0].currencyCode, + totalTokens: self.combinedTotalTokens(matches.map(\.totalTokens)), + estimatedCost: currencyCodes.count == 1 ? self.safeCostSum(knownCosts) : nil, + coveredDayCount: coveredDayCount) + } + } let sanitizedModels = model.groups.filter { $0.modelHistoryCompleteness == .complete }.flatMap { group in @@ -246,6 +400,7 @@ enum ShareStatsBuilder { return ShareStatsModelPayload( provider: row.provider, providerName: row.providerName, + modelIdentity: modelName.lowercased(), modelName: modelName, currencyCode: group.currencyCode, totalTokens: row.totalTokens, @@ -257,6 +412,7 @@ enum ShareStatsBuilder { let key = ShareStatsModelFamilyKey( provider: row.provider, providerName: row.providerName, + modelIdentity: row.modelIdentity, modelName: row.modelName, currencyCode: row.currencyCode) if var existing = modelFamilies[key] { @@ -275,24 +431,65 @@ enum ShareStatsBuilder { if lhs.providerName != rhs.providerName { return lhs.providerName < rhs.providerName } - return lhs.modelName < rhs.modelName + if lhs.modelName != rhs.modelName { + return lhs.modelName < rhs.modelName + } + if lhs.currencyCode != rhs.currencyCode { + return lhs.currencyCode < rhs.currencyCode + } + return lhs.modelIdentity < rhs.modelIdentity } } - let currencies = model.groups.map { - ShareStatsCurrencyPayload( - currencyCode: $0.currencyCode, - estimatedCost: self.finiteCost($0.totalCost), - coveredDayCount: $0.coveredDayCount) + let rosterProviderKinds = Set(providerRoster.map(\.provider)) + let rosterHasUnexpectedProviderFamilies = !providerRoster.isEmpty && trackedProviders.contains { + !rosterProviderKinds.contains($0.provider) + } + let rosterHasIncompleteSpendProviders = rosterHasUnexpectedProviderFamilies || + providerRoster.contains { entry in + let allMatches = trackedProviders.filter { $0.provider == entry.provider } + let matches = self.rosterMatches(entry: entry, candidates: allMatches) + let knownCosts = matches.compactMap(\.estimatedCost) + return self.rosterIdentityIsIncomplete(entry: entry, candidates: allMatches) || + matches.contains { $0.estimatedCost == nil } || + Set(matches.map(\.currencyCode)).count > 1 || + self.safeCostSum(knownCosts) == nil + } + let rosterHasIncompleteTokenProviders = rosterHasUnexpectedProviderFamilies || + providerRoster.contains { entry in + let allMatches = trackedProviders.filter { $0.provider == entry.provider } + let matches = self.rosterMatches(entry: entry, candidates: allMatches) + return self.rosterIdentityIsIncomplete(entry: entry, candidates: allMatches) || + matches.contains { $0.totalTokens == nil } + } + let rosterHasIncompleteProviders = rosterHasIncompleteSpendProviders || + rosterHasIncompleteTokenProviders + let currencies = model.groups.map { group in + let rosterCosts = providers.filter { $0.currencyCode == group.currencyCode } + .compactMap(\.estimatedCost) + let estimatedCost = providerRoster.isEmpty + ? self.finiteCost(group.totalCost ?? group.knownCost) + : self.safeCostSum(rosterCosts) + return ShareStatsCurrencyPayload( + currencyCode: group.currencyCode, + estimatedCost: estimatedCost, + coveredDayCount: group.coveredDayCount, + isPartial: rosterHasIncompleteSpendProviders || group.totalCost == nil) } - let totalTokens = self.combinedTotalTokens(model.groups.map(\.totalTokens)) + let tokenProviders = providerRoster.isEmpty ? trackedProviders : providers + let knownTokenValues = tokenProviders.compactMap(\.totalTokens) + let totalTokens = self.safeTokenSum(knownTokenValues) + let totalTokensIsPartial = tokenProviders.contains { $0.totalTokens == nil } || + rosterHasIncompleteTokenProviders || + model.groups.contains { $0.totalTokens == nil } let periodEnd = model.groups.map(\.chartDomain.upperBound).max() ?? Date() let payload = ShareStatsPayload( days: model.requestedDays, periodEnd: periodEnd, providers: providers, - topModels: topModels, + topModels: rosterHasIncompleteProviders ? [] : topModels, currencies: currencies, - totalTokens: totalTokens) + totalTokens: totalTokens, + totalTokensIsPartial: totalTokensIsPartial) return payload.hasShareableData ? payload : nil } @@ -311,6 +508,45 @@ enum ShareStatsBuilder { } return total } + + private static func safeTokenSum(_ values: [Int]) -> Int? { + var total = 0 + for value in values { + let result = total.addingReportingOverflow(value) + guard !result.overflow else { return nil } + total = result.partialValue + } + return values.isEmpty ? nil : total + } + + private static func safeCostSum(_ values: [Double]) -> Double? { + guard !values.isEmpty else { return nil } + var total = 0.0 + for value in values { + total += value + guard total.isFinite else { return nil } + } + return total + } + + private static func rosterMatches( + entry: ShareStatsProviderRosterEntry, + candidates: [ShareStatsProviderPayload]) -> [ShareStatsProviderPayload] + { + guard !entry.expectedSourceIDs.isEmpty else { return candidates } + return candidates.filter { candidate in + candidate.sourceID.map(entry.expectedSourceIDs.contains) == true + } + } + + private static func rosterIdentityIsIncomplete( + entry: ShareStatsProviderRosterEntry, + candidates: [ShareStatsProviderPayload]) -> Bool + { + guard !entry.expectedSourceIDs.isEmpty else { return candidates.isEmpty } + let actualIDs = Set(candidates.compactMap(\.sourceID)) + return actualIDs != entry.expectedSourceIDs || candidates.count != entry.expectedSourceIDs.count + } } enum ShareStatsFormatting { @@ -345,10 +581,16 @@ enum ShareStatsFormatting { static func text(_ payload: ShareStatsPayload) -> String { var lines = ["My AI subscriptions · last \(payload.days) days"] if let tokens = payload.totalTokens { - lines.append("\(self.compactCount(tokens)) tracked tokens") + let value = payload.totalTokensIsPartial ? "~\(self.compactCount(tokens))" : self.compactCount(tokens) + lines.append("\(value) tracked tokens") } + lines.append( + "\(payload.spendReportingProviderCount)/\(payload.providers.count) tracked services report spend") lines.append(contentsOf: payload.currencies.map { currency in - let spend = currency.estimatedCost.map { "\(self.currency($0, code: currency.currencyCode)) estimated" } + let spend = currency.estimatedCost.map { + let value = self.currency($0, code: currency.currencyCode) + return "\(currency.isPartial ? "~" : "")\(value) estimated" + } ?? "Spend unavailable" return "\(currency.currencyCode): \(spend) · " + "coverage \(currency.coveredDayCount)/\(payload.days) days" @@ -381,6 +623,9 @@ enum ShareStatsFormatting { } return "\(model.modelName) (\(model.providerName)): \(metrics.joined(separator: " · "))" }) + if payload.topModels.count > 5 { + lines.append("+\(payload.topModels.count - 5) more models ranked in local stats") + } } lines.append("Generated locally by CodexBar · Data through \(self.dataThrough(payload.periodEnd))") return lines.joined(separator: "\n") diff --git a/Sources/CodexBar/SpendDashboardController.swift b/Sources/CodexBar/SpendDashboardController.swift index 3ff06c651b..14d8abb875 100644 --- a/Sources/CodexBar/SpendDashboardController.swift +++ b/Sources/CodexBar/SpendDashboardController.swift @@ -3,12 +3,63 @@ import CryptoKit import Foundation import Observation +struct SpendDashboardTrackedSource: Identifiable, Equatable, Sendable { + enum State: Equatable, Sendable { + case connected + case configured + case needsAttention + case awaitingUsage + } + + let id: String + let provider: UsageProvider + let providerName: String + let accountName: String? + let state: State + let supportsCostHistory: Bool + let contributesCostHistory: Bool + let costHistoryAvailable: Bool + + init( + id: String, + provider: UsageProvider, + providerName: String, + accountName: String?, + state: State, + supportsCostHistory: Bool, + contributesCostHistory: Bool, + costHistoryAvailable: Bool = false) + { + self.id = id + self.provider = provider + self.providerName = providerName + self.accountName = accountName + self.state = state + self.supportsCostHistory = supportsCostHistory + self.contributesCostHistory = contributesCostHistory + self.costHistoryAvailable = costHistoryAvailable + } + + func withCostHistoryAvailable(_ available: Bool) -> Self { + Self( + id: self.id, + provider: self.provider, + providerName: self.providerName, + accountName: self.accountName, + state: self.state, + supportsCostHistory: self.supportsCostHistory, + contributesCostHistory: self.contributesCostHistory, + costHistoryAvailable: available) + } +} + struct SpendDashboardConfiguration: Equatable, Sendable { let costUsageEnabled: Bool let preferredCurrencyCode: String let providerIDs: [String] let codexAccountIdentities: [String] let codexAccountDisplayNames: [String: String] + let trackedSources: [SpendDashboardTrackedSource] let sourceOwnershipFingerprints: [String] let sourceRevisions: [String] @@ -18,6 +69,7 @@ struct SpendDashboardConfiguration: Equatable, Sendable { providerIDs: [String], codexAccountIdentities: [String], codexAccountDisplayNames: [String: String] = [:], + trackedSources: [SpendDashboardTrackedSource] = [], sourceOwnershipFingerprints: [String] = [], sourceRevisions: [String] = []) { @@ -26,6 +78,7 @@ struct SpendDashboardConfiguration: Equatable, Sendable { self.providerIDs = providerIDs self.codexAccountIdentities = codexAccountIdentities self.codexAccountDisplayNames = codexAccountDisplayNames + self.trackedSources = trackedSources self.sourceOwnershipFingerprints = sourceOwnershipFingerprints self.sourceRevisions = sourceRevisions } @@ -67,6 +120,7 @@ struct SpendDashboardLoadRequest: Sendable { let codexRequests: [CodexSpendScanRequest] let now: Date let force: Bool + let historyDays: Int init( configuration: SpendDashboardConfiguration, @@ -75,7 +129,8 @@ struct SpendDashboardLoadRequest: Sendable { confirmedEmptySourceIDs: Set = [], codexRequests: [CodexSpendScanRequest], now: Date, - force: Bool) + force: Bool, + historyDays: Int = 30) { self.configuration = configuration self.capturedInputs = capturedInputs @@ -84,6 +139,7 @@ struct SpendDashboardLoadRequest: Sendable { self.codexRequests = codexRequests self.now = now self.force = force + self.historyDays = max(1, min(365, historyDays)) } } @@ -126,9 +182,7 @@ enum SpendDashboardSource { -> CostUsageTokenSnapshot? typealias CodexCacheRootResolver = @Sendable (CodexSpendScanRequest) -> URL - static let scanDays = 30 static let activityDays = 365 - @MainActor static func configuration(settings: SettingsStore, store: UsageStore) -> SpendDashboardConfiguration { let providers = self.costCapableProviders(store: store) @@ -155,6 +209,7 @@ enum SpendDashboardSource { providerIDs: providers.map(\.rawValue), codexAccountIdentities: codexRequests.map { "\($0.id)|\($0.cacheIdentity)" }, codexAccountDisplayNames: self.codexDisplayNamesByID(codexRequests), + trackedSources: self.trackedSources(settings: settings, store: store), sourceOwnershipFingerprints: self.sourceOwnershipFingerprints( providers: providers, settings: settings, @@ -170,6 +225,7 @@ enum SpendDashboardSource { now: Date? = nil, nowProvider: @escaping @Sendable () -> Date = { Date() }) async -> SpendDashboardLoadRequest { + let historyDays = settings.effectiveCostUsageHistoryDays guard settings.costUsageEnabled else { return SpendDashboardLoadRequest( configuration: self.configuration(settings: settings, store: store), @@ -177,7 +233,8 @@ enum SpendDashboardSource { unavailableSourceIDs: [], codexRequests: [], now: now ?? nowProvider(), - force: mode.forcesLoader) + force: mode.forcesLoader, + historyDays: historyDays) } let initialProviders = self.costCapableProviders(store: store) @@ -215,7 +272,8 @@ enum SpendDashboardSource { unavailableSourceIDs: [], codexRequests: [], now: captureNow, - force: mode.forcesLoader) + force: mode.forcesLoader, + historyDays: historyDays) } var inputs: [SpendDashboardModel.ProviderInput] = [] @@ -252,7 +310,8 @@ enum SpendDashboardSource { confirmedEmptySourceIDs: confirmedEmptySourceIDs, codexRequests: codexRequests, now: captureNow, - force: mode.forcesLoader) + force: mode.forcesLoader, + historyDays: historyDays) } static func load(_ request: SpendDashboardLoadRequest) async -> SpendDashboardLoadResult { @@ -322,7 +381,7 @@ enum SpendDashboardSource { cacheRoot: cacheRootResolver(account), now: request.now, force: false, - historyDays: Self.scanDays, + historyDays: request.historyDays, refreshPricingInBackground: false, includePiSessions: false)) guard !Task.isCancelled, @@ -385,7 +444,7 @@ enum SpendDashboardSource { cacheRoot: cacheRoot, now: request.now, force: request.force, - historyDays: Self.scanDays, + historyDays: request.historyDays, refreshPricingInBackground: false, includePiSessions: false)) try Task.checkCancellation() @@ -464,6 +523,130 @@ enum SpendDashboardSource { } } + @MainActor + static func trackedSources( + settings: SettingsStore, + store: UsageStore) -> [SpendDashboardTrackedSource] + { + // Tracked access reflects user enablement, not refresh-time availability. Providers with no + // current snapshot still need a row so the dashboard can explain their connection state. + let enabled = Set(UsageProvider.allCases.filter { provider in + settings.isProviderEnabled(provider: provider, metadata: store.metadata(for: provider)) + }) + var sources: [SpendDashboardTrackedSource] = [] + + for provider in UsageProvider.allCases { + let providerName = store.metadata(for: provider).displayName + let supportsCostHistory = ProviderDescriptorRegistry.descriptor(for: provider) + .tokenCost.supportsTokenCost + let currentCostHistoryAvailable = store + .tokenSnapshotPublicationForCurrentProviderConfig(for: provider)? + .snapshot?.historyCoverageIsEstablished == true + if provider == .codex { + let accounts = settings.codexVisibleAccountProjection.visibleAccounts + let snapshotsByID = Dictionary(uniqueKeysWithValues: store.codexAccountSnapshots.map { + ($0.id, $0) + }) + if !accounts.isEmpty { + sources.append(contentsOf: accounts.map { account in + let accountSnapshot = snapshotsByID[account.id] + let connected = accountSnapshot?.snapshot != nil + || (account.isActive && store.snapshot(for: .codex) != nil) + let hasError = accountSnapshot?.error != nil + || (account.isActive && store.error(for: .codex) != nil) + return SpendDashboardTrackedSource( + id: "codex:\(account.id)", + provider: provider, + providerName: providerName, + accountName: self.trackedAccountName(account.email, providerName: providerName), + state: self.trackedSourceState( + hasLiveSnapshot: connected, + hasConfiguredCredential: true, + hasError: hasError), + supportsCostHistory: supportsCostHistory, + contributesCostHistory: supportsCostHistory && enabled.contains(provider), + costHistoryAvailable: account.isActive && currentCostHistoryAvailable) + }) + continue + } + } + + let accounts = settings.tokenAccounts(for: provider) + if !accounts.isEmpty { + let activeAccountID = settings.effectiveSelectedTokenAccount(for: provider)?.id + let cachedByID = Dictionary( + uniqueKeysWithValues: (store.accountSnapshots[provider.instanceID] ?? []).map { + ($0.id, $0) + }) + sources.append(contentsOf: accounts.map { account in + let isActive = account.id == activeAccountID + let accountSnapshot = cachedByID[account.id] + let connected = accountSnapshot?.snapshot != nil + || (isActive && store.snapshot(for: provider.instanceID) != nil) + let hasError = accountSnapshot?.error != nil + || (isActive && (store.error(for: provider) != nil || store.tokenError(for: provider) != nil)) + return SpendDashboardTrackedSource( + id: "\(provider.rawValue):account:\(account.id.uuidString.lowercased())", + provider: provider, + providerName: providerName, + accountName: self.trackedAccountName(account.displayName, providerName: providerName), + state: self.trackedSourceState( + hasLiveSnapshot: connected, + hasConfiguredCredential: true, + hasError: hasError), + supportsCostHistory: supportsCostHistory, + contributesCostHistory: supportsCostHistory && isActive && enabled.contains(provider), + costHistoryAvailable: isActive && currentCostHistoryAvailable) + }) + if activeAccountID != nil { continue } + } + + let config = settings.providerConfig(for: provider) + let hasConfiguredCredential = config?.sanitizedAPIKey != nil + || config?.sanitizedSecretKey != nil + || config?.sanitizedCookieHeader != nil + let hasLiveSnapshot = store.snapshot(for: provider.instanceID) != nil + || store.tokenSnapshotPublicationForCurrentProviderConfig(for: provider)?.snapshot != nil + guard enabled.contains(provider) || hasConfiguredCredential || hasLiveSnapshot else { continue } + let hasError = store.error(for: provider) != nil || store.tokenError(for: provider) != nil + sources.append(SpendDashboardTrackedSource( + id: "\(provider.rawValue):current", + provider: provider, + providerName: providerName, + accountName: nil, + state: self.trackedSourceState( + hasLiveSnapshot: hasLiveSnapshot, + hasConfiguredCredential: hasConfiguredCredential, + hasError: hasError), + supportsCostHistory: supportsCostHistory, + contributesCostHistory: supportsCostHistory && enabled.contains(provider), + costHistoryAvailable: currentCostHistoryAvailable)) + } + + return sources + } + + private static func trackedSourceState( + hasLiveSnapshot: Bool, + hasConfiguredCredential: Bool, + hasError: Bool) -> SpendDashboardTrackedSource.State + { + if hasError { return .needsAttention } + if hasLiveSnapshot { return .connected } + if hasConfiguredCredential { return .configured } + return .awaitingUsage + } + + private static func trackedAccountName(_ rawValue: String?, providerName: String) -> String? { + guard let value = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty, + value.localizedCaseInsensitiveCompare(providerName) != .orderedSame + else { + return nil + } + return value + } + @MainActor static func codexRequests(settings: SettingsStore, store: UsageStore) -> [CodexSpendScanRequest] { let accounts = settings.codexVisibleAccountProjection.visibleAccounts @@ -1229,6 +1412,9 @@ final class SpendDashboardController { } private static func normalizedDays(_ value: Int) -> Int { - value == 7 ? 7 : 30 + switch value { + case 7, 30, 365: value + default: 30 + } } } diff --git a/Sources/CodexBar/SpendDashboardModel+ModelBreakdown.swift b/Sources/CodexBar/SpendDashboardModel+ModelBreakdown.swift index 106c001eca..dc4e3e6bd4 100644 --- a/Sources/CodexBar/SpendDashboardModel+ModelBreakdown.swift +++ b/Sources/CodexBar/SpendDashboardModel+ModelBreakdown.swift @@ -118,6 +118,28 @@ extension SpendDashboardModel { } } + static func canRetainTokenOnlyModelHistory(_ summary: InputSummary) -> Bool { + guard summary.input.provider != .codex else { return false } + var sawTokenOnlyModel = false + for windowEntry in summary.entries { + let entry = windowEntry.entry + guard self.validCost(entry.costUSD) != nil else { return false } + guard entry.modelBreakdowns?.isEmpty == false else { + continue + } + guard self.hasCompleteModelTokenCoverage(entry), + entry.modelBreakdowns?.allSatisfy({ breakdown in + !breakdown.modelName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && breakdown.costUSD == nil + }) == true + else { + return false + } + sawTokenOnlyModel = true + } + return sawTokenOnlyModel + } + private static func hasRetainablePartialCodexModelCostCoverage( _ entry: CostUsageDailyReport.Entry) -> Bool { diff --git a/Sources/CodexBar/SpendDashboardModel.swift b/Sources/CodexBar/SpendDashboardModel.swift index f13af2baf9..fc6ec23be7 100644 --- a/Sources/CodexBar/SpendDashboardModel.swift +++ b/Sources/CodexBar/SpendDashboardModel.swift @@ -104,6 +104,21 @@ struct SpendDashboardModel: Equatable, Sendable { var id: String { self.currencyCode } + + var knownCostProviderCount: Int { + self.providers.count { $0.totalCost != nil } + } + + var knownCost: Double? { + let costs = self.providers.compactMap(\.totalCost) + guard !costs.isEmpty else { return nil } + var total = 0.0 + for cost in costs { + total += cost + guard total.isFinite else { return nil } + } + return total + } } let requestedDays: Int @@ -129,7 +144,7 @@ struct SpendDashboardModel: Equatable, Sendable { calendar: Calendar = .current, preferredCurrencyCode: String = "auto") -> Self { - let days = max(1, min(30, requestedDays)) + let days = max(1, min(365, requestedDays)) let calculationCalendar = Self.gregorianCalendar(timeZone: calendar.timeZone) let classifiedInputs = inputs.compactMap { input -> ClassifiedInput? in guard let sourceCurrencyCode = Self.currencyCode(input.snapshot.currencyCode) else { return nil } @@ -240,16 +255,19 @@ struct SpendDashboardModel: Equatable, Sendable { calendar: calendar) } let providers = Self.providerRows(summaries) + let coversRequestedHorizon = summaries.allSatisfy { $0.coveredDayCount >= days } let modelSummaries = summaries.filter { summary in guard summary.totalCost != nil else { return false } let summaryModelHistory = Self.modelSummary(summaries: [summary]) return summaryModelHistory.completeness == .complete || - Self.canRetainPartialCodexModelHistory(summary) + Self.canRetainPartialCodexModelHistory(summary) || + Self.canRetainTokenOnlyModelHistory(summary) } // A Codex session can have valid priced rows alongside model-less or unpriced rows. // Keep only the directly priced portion, but mark the aggregate partial and remove ranking. let modelSummary = Self.modelSummary(summaries: modelSummaries) - let modelHistoryCompleteness = modelSummaries.count == summaries.count && + let modelHistoryCompleteness = coversRequestedHorizon && + modelSummaries.count == summaries.count && modelSummary.completeness == .complete ? ModelHistoryCompleteness.complete : ModelHistoryCompleteness.incomplete @@ -259,8 +277,8 @@ struct SpendDashboardModel: Equatable, Sendable { providers: providers, models: modelSummary.rows, dailyPoints: dailyPoints, - totalTokens: Self.completeIntSum(providers.map(\.totalTokens)), - totalCost: Self.completeCostSum(providers.map(\.totalCost)), + totalTokens: coversRequestedHorizon ? Self.completeIntSum(providers.map(\.totalTokens)) : nil, + totalCost: coversRequestedHorizon ? Self.completeCostSum(providers.map(\.totalCost)) : nil, coveredDayCount: Self.commonCoverageDayCount(summaries: summaries, calendar: calendar), chartDomain: Self.chartDomain(bounds: bounds, calendar: calendar), modelHistoryCompleteness: modelHistoryCompleteness) @@ -602,7 +620,8 @@ struct SpendDashboardModel: Equatable, Sendable { displayCalendar: Calendar) -> ClosedRange { let bucketCalendar = Self.bucketCalendar(for: input.provider, displayCalendar: displayCalendar) - let bucketEnd = bucketCalendar.startOfDay(for: input.snapshot.updatedAt) + let snapshotDay = bucketCalendar.startOfDay(for: input.snapshot.updatedAt) + let bucketEnd = snapshotDay 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 @@ -658,8 +677,8 @@ struct SpendDashboardModel: Equatable, Sendable { 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. + // Mistral labels 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) } diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index be4f44c07c..3ce41df9de 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -573,12 +573,54 @@ extension StatusItemController { guard !model.isOverviewErrorOnly else { return nil } return (provider: provider, model: model) } - guard !rows.isEmpty else { return false } - let t0 = CACurrentMediaTime() defer { self.logChartRenderDurationIfSlow("addOverviewRows(\(rows.count))", startedAt: t0) } + let spendModel = self.overviewSpendDashboardModel(providers: enabledProviders) + let spendSummary = OverviewSpendSummary( + model: spendModel, + connectedProviderCount: enabledProviders.count) + let fallbackCurrencyCode = spendModel.groups.first?.currencyCode ?? "USD" + let sharePayload = ShareStatsBuilder.make( + model: spendModel, + providerRoster: enabledProviders.map { + ShareStatsProviderRosterEntry( + provider: $0, + providerName: ProviderDefaults.metadata[$0]?.displayName ?? $0.rawValue, + currencyCode: fallbackCurrencyCode) + }) + let summaryItem = self.makeMenuCardItem( + OverviewSpendSummaryCardView( + summary: spendSummary, + days: spendModel.requestedDays, + width: menuWidth, + canShare: sharePayload != nil, + share: { [weak interactionMenu] in + guard let sharePayload else { return } + interactionMenu?.cancelTracking() + DispatchQueue.main.async { + ShareStatsPresenter.shared.present(payload: sharePayload) + } + }), + id: "overviewSpendSummary", + width: menuWidth, + heightCacheScope: "overviewSpendSummary", + heightCacheFingerprint: [ + spendSummary.primarySpendText, + spendSummary.coverageText, + spendSummary.tokenText ?? "", + ].joined(separator: "|"), + containsInteractiveControls: sharePayload != nil) + menu.addItem(summaryItem) + menu.addItem(.separator()) + + guard !rows.isEmpty else { + self.addOverviewEmptyState(to: menu, enabledProviders: enabledProviders) + return true + } + for (index, row) in rows.enumerated() { + let emphasis: OverviewMenuCardRowView.Emphasis = index == 0 ? .prominent : .compact let identifier = "\(Self.overviewRowIdentifierPrefix)\(row.provider.rawValue)" let storageText = self.store.storageFootprintText(for: row.provider) let submenu = self.makeOverviewRowSubmenu( @@ -586,13 +628,20 @@ extension StatusItemController { model: row.model, width: menuWidth) let item = self.makeMenuCardItem( - OverviewMenuCardRowView(model: row.model, storageText: storageText, width: menuWidth), + OverviewMenuCardRowView( + model: row.model, + storageText: storageText, + width: menuWidth, + emphasis: emphasis), id: identifier, width: menuWidth, heightCacheScope: row.provider.rawValue, heightCacheFingerprint: row.model.heightFingerprint( section: "overview", - additional: [UsageMenuCardView.Model.heightFingerprintField("storage", storageText)]), + additional: [ + UsageMenuCardView.Model.heightFingerprintField("storage", storageText), + "emphasis=\(emphasis == .prominent ? "prominent" : "compact")", + ]), submenu: submenu, containsInteractiveControls: row.model.subtitleStyle == .error || row.model.usesLiveSubtitle, usesGPUSelection: true, @@ -606,26 +655,13 @@ extension StatusItemController { item.action = #selector(self.selectOverviewProvider(_:)) } menu.addItem(item) - if index < rows.count - 1 { + if index == 0, rows.count > 1 { menu.addItem(.separator()) } } return true } - private func addOverviewEmptyState(to menu: NSMenu, enabledProviders: [UsageProvider]) { - let resolvedProviders = self.settings.resolvedMergedOverviewProviders( - activeProviders: enabledProviders, - maxVisibleProviders: Self.maxOverviewProviders) - let message = resolvedProviders.isEmpty - ? L("No providers selected for Overview.") - : L("No overview data available.") - let item = NSMenuItem(title: message, action: nil, keyEquivalent: "") - item.isEnabled = false - item.representedObject = "overviewEmptyState" - menu.addItem(item) - } - private func addMenuCards(to menu: NSMenu, context: MenuCardContext, captureMenu: NSMenu? = nil) -> Bool { let fleetProjection = self.fleetAccountProjection(for: context.currentProvider) if self.addFleetFallback(fleetProjection, to: menu, context: context) { diff --git a/Sources/CodexBar/StatusItemController+MenuTypes.swift b/Sources/CodexBar/StatusItemController+MenuTypes.swift index 47ed1a890b..7030a09cb8 100644 --- a/Sources/CodexBar/StatusItemController+MenuTypes.swift +++ b/Sources/CodexBar/StatusItemController+MenuTypes.swift @@ -33,50 +33,319 @@ extension ProviderSwitcherSelection { } } +struct OverviewSpendSummary: Equatable { + let primarySpendText: String + let coverageText: String + let tokenText: String? + let isPartial: Bool + + init(model: SpendDashboardModel, connectedProviderCount: Int) { + let connectedCount = max(0, connectedProviderCount) + let knownCostCount = model.groups.reduce(0) { $0 + $1.knownCostProviderCount } + let knownTokenRows = model.groups.flatMap(\.providers).compactMap(\.totalTokens) + let knownTokens = Self.safeTokenSum(knownTokenRows) + let tokenCoverageIsComplete = knownTokenRows.count == connectedCount && + model.groups.allSatisfy { $0.totalTokens != nil } + self.isPartial = knownCostCount < connectedCount || model.groups.contains { $0.totalCost == nil } + + let spendTexts = model.groups.compactMap { group -> String? in + guard let cost = group.totalCost ?? group.knownCost else { return nil } + let formatted = UsageFormatter.currencyString(cost, currencyCode: group.currencyCode) + let groupIsPartial = group.totalCost == nil || knownCostCount < connectedCount + return groupIsPartial ? "~\(formatted)" : formatted + } + self.primarySpendText = spendTexts.isEmpty ? L("Spend unavailable") : spendTexts.joined(separator: " · ") + self.coverageText = "\(codexBarLocalizedInteger(knownCostCount)) / " + + "\(codexBarLocalizedInteger(connectedCount)) \(L("Providers"))" + self.tokenText = knownTokens.map { + let formatted = ShareStatsFormatting.compactCount($0) + let value = tokenCoverageIsComplete ? formatted : "~\(formatted)" + return L("%@ tokens", value) + } + } + + private static func safeTokenSum(_ values: [Int]) -> Int? { + var total = 0 + for value in values { + let result = total.addingReportingOverflow(value) + guard !result.overflow else { return nil } + total = result.partialValue + } + return values.isEmpty ? nil : total + } +} + +struct OverviewSpendSummaryCardView: View { + static let rowHeight: CGFloat = 94 + + let summary: OverviewSpendSummary + let days: Int + let width: CGFloat + let canShare: Bool + let share: () -> Void + + var body: some View { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 7) { + HStack(spacing: 6) { + Text(L("Usage & Spend")) + .font(.headline.weight(.semibold)) + Text("·") + Text(spendDashboardDayRangeText(self.days)) + } + .foregroundStyle(.secondary) + + Text(self.summary.primarySpendText) + .font(.system(.title2, design: .rounded, weight: .bold)) + .monospacedDigit() + .lineLimit(2) + + HStack(spacing: 8) { + Text(self.summary.coverageText) + if let tokenText = self.summary.tokenText { + Text("·") + Text(tokenText) + } + } + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer(minLength: 6) + + Button(action: self.share) { + Image(systemName: "square.and.arrow.up") + .font(.body.weight(.semibold)) + .frame(width: 30, height: 30) + .background(.primary.opacity(0.07), in: Circle()) + } + .buttonStyle(.plain) + .menuCardInteractiveControl(isEnabled: self.canShare) + .disabled(!self.canShare) + .opacity(self.canShare ? 1 : 0.35) + .accessibilityLabel(L("Share Stats…")) + .help(L("Share Stats…")) + } + .padding(.horizontal, UsageMenuCardLayout.horizontalPadding) + .padding(.vertical, 10) + .frame(width: self.width, alignment: .leading) + .frame(minHeight: Self.rowHeight, alignment: .leading) + .background { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.accentColor.opacity(0.08)) + .padding(.horizontal, 6) + } + } +} + struct OverviewMenuCardRowView: View { + enum Emphasis: Equatable { + case prominent + case compact + } + static let showsSectionDividers = false + static let rowHeight: CGFloat = 88 + static let compactRowHeight: CGFloat = 54 + static let accessibilityRowHeight: CGFloat = 112 let model: UsageMenuCardView.Model let storageText: String? let width: CGFloat + var emphasis: Emphasis = .prominent @Environment(\.menuItemHighlighted) private var isHighlighted + @Environment(\.menuCardRefreshMonitor) private var refreshMonitor + @Environment(\.dynamicTypeSize) private var dynamicTypeSize var body: some View { - VStack(alignment: .leading, spacing: 0) { - UsageMenuCardHeaderSectionView( - model: self.model, - showDivider: Self.showsSectionDividers && self.hasUsageBlock, - width: self.width) - if self.hasUsageBlock { - UsageMenuCardUsageSectionView( - model: self.model, - showBottomDivider: false, - bottomPadding: 6, - width: self.width, - showsSectionDividers: Self.showsSectionDividers) + let liveModel = self.liveModel + let liveSubtitle = Self.liveSubtitle(for: liveModel, refreshMonitor: self.refreshMonitor) + let metric = Self.primaryMetric(for: liveModel) + let prioritizesStatus = Self.prioritizesStatus(for: liveSubtitle.style) + Group { + switch self.emphasis { + case .prominent: + self.prominentContent( + liveModel: liveModel, + liveSubtitle: liveSubtitle, + metric: metric, + prioritizesStatus: prioritizesStatus) + case .compact: + self.compactContent( + liveModel: liveModel, + liveSubtitle: liveSubtitle, + metric: metric, + prioritizesStatus: prioritizesStatus) } - if let storageText { - HStack(alignment: .firstTextBaseline, spacing: 4) { - Text("\(L("Storage")):") - .font(.footnote) - .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) - Text(storageText) - .font(.footnote) - .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + } + } + + private func prominentContent( + liveModel: UsageMenuCardView.Model, + liveSubtitle: MenuCardLiveSubtitle, + metric: UsageMenuCardView.Model.Metric?, + prioritizesStatus: Bool) -> some View + { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline, spacing: 10) { + Text(liveModel.providerName) + .font(.headline.weight(.semibold)) + .lineLimit(1) + Spacer(minLength: 8) + Text(liveModel.email) + .font(.caption) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .lineLimit(1) + .truncationMode(.middle) + } + + if prioritizesStatus { + Text(liveSubtitle.text) + .font(.subheadline.weight(.medium)) + .foregroundStyle(self.subtitleColor(for: liveSubtitle.style)) + .lineLimit(self.dynamicTypeSize.isAccessibilitySize ? 2 : 1) + .fixedSize(horizontal: false, vertical: true) + } else if let metric { + let presentation = metric.linePresentation( + title: UsageMenuCardView.popupMetricTitle(provider: liveModel.provider, metric: metric)) + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(metric.statusText ?? presentation.titleText) + .font(.subheadline.weight(.medium)) + .lineLimit(1) + Spacer(minLength: 8) + if let resetText = presentation.resetText, metric.statusText == nil { + Text(resetText) + .font(.caption2) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .lineLimit(1) + } + } + if metric.statusText == nil { + UsageProgressBar( + percent: metric.percent, + tint: liveModel.progressColor, + accessibilityLabel: metric.percentStyle.accessibilityLabel) + } + } else { + Text(Self.compactSpendText(for: liveModel)) + .font(.subheadline.weight(.medium)) + .lineLimit(1) + } + + HStack(alignment: .firstTextBaseline, spacing: 6) { + if metric != nil || prioritizesStatus { + Text(Self.compactSpendText(for: liveModel)) + .lineLimit(1) + .truncationMode(.tail) + } + Spacer(minLength: 4) + if let storageText { + Text("\(L("Storage")): \(storageText)") .lineLimit(1) - Spacer() } - .padding(.horizontal, UsageMenuCardLayout.horizontalPadding) - .padding(.top, self.hasUsageBlock ? 0 : 8) - .padding(.bottom, 6) - .frame(width: self.width, alignment: .leading) } + .font(.caption) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + } + .padding(.horizontal, UsageMenuCardLayout.horizontalPadding) + .padding(.vertical, 7) + .frame(width: self.width, alignment: .leading) + .frame(minHeight: Self.rowHeight(for: self.dynamicTypeSize), alignment: .leading) + } + + private func compactContent( + liveModel: UsageMenuCardView.Model, + liveSubtitle: MenuCardLiveSubtitle, + metric: UsageMenuCardView.Model.Metric?, + prioritizesStatus: Bool) -> some View + { + VStack(alignment: .leading, spacing: 5) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(liveModel.providerName) + .font(.subheadline.weight(.semibold)) + .lineLimit(self.dynamicTypeSize.isAccessibilitySize ? 2 : 1) + if !liveModel.email.isEmpty { + Text(liveModel.email) + .font(.caption) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .lineLimit(self.dynamicTypeSize.isAccessibilitySize ? 2 : 1) + .truncationMode(.middle) + } + Spacer(minLength: 6) + } + + HStack(alignment: .firstTextBaseline, spacing: 8) { + if prioritizesStatus { + Text(liveSubtitle.text) + .foregroundStyle(self.subtitleColor(for: liveSubtitle.style)) + } else if let metric { + let title = UsageMenuCardView.popupMetricTitle(provider: liveModel.provider, metric: metric) + Text(metric.linePresentation(title: title).titleText) + } else { + Text(liveSubtitle.text) + } + Spacer(minLength: 8) + Text(Self.compactSpendText(for: liveModel)) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + } + .font(.caption) + .lineLimit(self.dynamicTypeSize.isAccessibilitySize ? 2 : 1) } + .padding(.horizontal, UsageMenuCardLayout.horizontalPadding) + .padding(.vertical, 6) .frame(width: self.width, alignment: .leading) + .frame( + minHeight: self.dynamicTypeSize.isAccessibilitySize + ? Self.accessibilityRowHeight + : Self.compactRowHeight, + alignment: .leading) } - private var hasUsageBlock: Bool { - self.model.hasUsageContent + static func primaryMetric(for model: UsageMenuCardView.Model) -> UsageMenuCardView.Model.Metric? { + model.metrics.first + } + + static func compactSpendText(for model: UsageMenuCardView.Model) -> String { + self.spendReference(for: model) ?? L("Spend unavailable") + } + + static func prioritizesStatus(for style: UsageMenuCardView.Model.SubtitleStyle) -> Bool { + style != .info + } + + static func rowHeight(for dynamicTypeSize: DynamicTypeSize) -> CGFloat { + dynamicTypeSize.isAccessibilitySize ? self.accessibilityRowHeight : self.rowHeight + } + + static func spendReference(for model: UsageMenuCardView.Model) -> String? { + model.providerCost?.spendLine + ?? model.tokenUsage?.monthLine + ?? model.creditsText + ?? model.placeholder + } + + static func liveSubtitle( + for model: UsageMenuCardView.Model, + refreshMonitor: MenuCardRefreshMonitor?) -> MenuCardLiveSubtitle + { + let fallback = MenuCardLiveSubtitle(text: model.subtitleText, style: model.subtitleStyle) + guard model.usesLiveSubtitle else { return fallback } + return refreshMonitor?.subtitle(for: model.provider, fallback: fallback) ?? fallback + } + + private var liveModel: UsageMenuCardView.Model { + guard self.model.usesLiveSubtitle else { return self.model } + return self.refreshMonitor?.model(for: self.model.provider, fallback: self.model) ?? self.model + } + + private func subtitleColor(for style: UsageMenuCardView.Model.SubtitleStyle) -> Color { + switch style { + case .error: + MenuHighlightStyle.error(self.isHighlighted) + case .info, .loading: + MenuHighlightStyle.secondary(self.isHighlighted) + } } } diff --git a/Sources/CodexBar/StatusItemController+OverviewScroll.swift b/Sources/CodexBar/StatusItemController+OverviewScroll.swift index 5fb980afdd..288087cc1e 100644 --- a/Sources/CodexBar/StatusItemController+OverviewScroll.swift +++ b/Sources/CodexBar/StatusItemController+OverviewScroll.swift @@ -26,6 +26,12 @@ extension StatusItemController { self.overviewScrollAccumulatedDelta = 0 return false } + // Long overviews need AppKit's native viewport movement so offscreen rows can become visible. + // Keep legacy row-to-row wheel navigation only for short lists that fit without scrolling. + if self.overviewRowCount(in: menu) > SettingsStore.mergedOverviewLegacyWheelNavigationLimit { + self.overviewScrollAccumulatedDelta = 0 + return false + } guard !event.hasPreciseScrollingDeltas else { self.overviewScrollAccumulatedDelta = 0 return false @@ -61,7 +67,11 @@ extension StatusItemController { } func menuHasOverviewRows(_ menu: NSMenu) -> Bool { - menu.items.contains { item in + self.overviewRowCount(in: menu) > 0 + } + + func overviewRowCount(in menu: NSMenu) -> Int { + menu.items.count { item in (item.representedObject as? String)?.hasPrefix(Self.overviewRowIdentifierPrefix) == true } } diff --git a/Sources/CodexBar/StatusItemController+OverviewSpend.swift b/Sources/CodexBar/StatusItemController+OverviewSpend.swift new file mode 100644 index 0000000000..381d4d64ec --- /dev/null +++ b/Sources/CodexBar/StatusItemController+OverviewSpend.swift @@ -0,0 +1,39 @@ +import AppKit +import CodexBarCore +import Foundation + +extension StatusItemController { + func addOverviewEmptyState(to menu: NSMenu, enabledProviders: [UsageProvider]) { + let resolvedProviders = self.settings.resolvedMergedOverviewProviders( + activeProviders: enabledProviders, + maxVisibleProviders: SettingsStore.mergedOverviewProviderLimit) + let message = resolvedProviders.isEmpty + ? L("No providers selected for Overview.") + : L("No overview data available.") + let item = NSMenuItem(title: message, action: nil, keyEquivalent: "") + item.isEnabled = false + item.representedObject = "overviewEmptyState" + menu.addItem(item) + } + + func overviewSpendDashboardModel( + providers: [UsageProvider], + now: Date = Date()) -> SpendDashboardModel + { + let inputs = providers.compactMap { provider -> SpendDashboardModel.ProviderInput? in + guard let snapshot = self.store.tokenSnapshotForCurrentProviderConfig(for: provider)?.snapshot else { + return nil + } + return SpendDashboardModel.ProviderInput( + provider: provider, + displayName: ProviderDefaults.metadata[provider]?.displayName ?? provider.rawValue, + snapshot: snapshot) + } + let requestedDays = self.settings.effectiveCostUsageHistoryDays + return SpendDashboardModel.build( + inputs: inputs, + requestedDays: requestedDays, + now: now, + preferredCurrencyCode: self.settings.preferredCurrencyCode) + } +} diff --git a/Sources/CodexBar/UsageStore+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index 0fb7b3dbc1..203875637b 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -882,6 +882,7 @@ extension UsageStore { usage: UsageSnapshot, expectedGuard: CodexAccountScopedRefreshGuard?) -> UsageSnapshot { + // Provider-specific by design: Codex identity repair preserves the expected account email on Codex snapshots. guard provider == .codex, CodexIdentityResolver.normalizeEmail(usage.accountEmail(for: .codex)) == nil, let accountEmail = CodexIdentityResolver.normalizeEmail(expectedGuard?.accountKey) diff --git a/Sources/CodexBar/UsageStore+SpendDashboardCodexCostCatchUp.swift b/Sources/CodexBar/UsageStore+SpendDashboardCodexCostCatchUp.swift index 86758961c5..b8a12ac0ee 100644 --- a/Sources/CodexBar/UsageStore+SpendDashboardCodexCostCatchUp.swift +++ b/Sources/CodexBar/UsageStore+SpendDashboardCodexCostCatchUp.swift @@ -31,7 +31,7 @@ extension UsageStore { return } - let historyDays = max(SpendDashboardSource.scanDays, self.settings.costUsageHistoryDays) + let historyDays = self.settings.effectiveCostUsageHistoryDays let accountScopeSignature = accounts .map { "\($0.id)|\($0.cacheIdentity)" } .joined(separator: "\u{0}") @@ -259,9 +259,9 @@ extension UsageStore { !Task.isCancelled && self.spendDashboardCodexCostCatchUpToken == context.token && self.spendDashboardCodexCostCatchUpScopeSignature == context.scopeSignature + && self.settings.effectiveCostUsageHistoryDays == context.historyDays && self.settings.providerConfigRevision(for: .codex) == context.providerConfigRevision && self.settings.costUsageSettingsRevision == context.costUsageSettingsRevision - && max(SpendDashboardSource.scanDays, self.settings.costUsageHistoryDays) == context.historyDays && self.settings.isCostUsageEffectivelyEnabled(for: .codex) && self.isEnabled(.codex) && context.accounts.allSatisfy(SpendDashboardSource.codexAuthFingerprintMatches) diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index 8e6e2a4820..fdd16e3f66 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -980,7 +980,7 @@ extension UsageStore { generation: publicationGeneration) } }, - costUsageHistoryDays: self.settings.costUsageHistoryDays, + costUsageHistoryDays: self.settings.effectiveCostUsageHistoryDays, claudeOwnerCLIRecoveryOnly: claudeOwnerCLIRecoveryOnly, persistsCLISessions: true, persistentCLISessionIdleWindow: ProviderRegistry.persistentCLISessionIdleWindow( diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index 24cf19d654..646b2fdbc0 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -15,6 +15,7 @@ struct TokenSnapshotPublication: Sendable, Equatable { let snapshot: CostUsageTokenSnapshot? let publicationRevision: UInt64 let providerConfigRevision: UInt64 + let historyDays: Int let scopeSignature: String } @@ -106,8 +107,13 @@ extension UsageStore { for provider: UsageProvider) -> CurrentProviderConfigTokenPublication? { guard let publication = self.tokenSnapshotPublications[provider.instanceID], - publication.providerConfigRevision == self.settings.providerConfigRevision(for: provider), - publication.scopeSignature == self.tokenSnapshotScopeSignature(for: provider) + publication.providerConfigRevision == self.settings.providerConfigRevision(for: provider) + else { return nil } + let requiredHistoryDays = self.settings.effectiveCostUsageHistoryDays + guard publication.historyDays >= requiredHistoryDays, + publication.scopeSignature == self.tokenSnapshotScopeSignature( + for: provider, + historyDays: publication.historyDays) else { return nil } return CurrentProviderConfigTokenPublication( snapshot: publication.snapshot, @@ -129,21 +135,25 @@ extension UsageStore { } private func publishTokenSnapshotState(_ snapshot: CostUsageTokenSnapshot?, for provider: UsageProvider) { + let historyDays = self.settings.effectiveCostUsageHistoryDays self.tokenSnapshotPublicationRevisions[provider.instanceID, default: 0] &+= 1 self.tokenSnapshotPublications[provider.instanceID] = TokenSnapshotPublication( snapshot: snapshot, publicationRevision: self.tokenSnapshotPublicationRevision(for: provider), providerConfigRevision: self.settings.providerConfigRevision(for: provider), - scopeSignature: self.tokenSnapshotScopeSignature(for: provider)) + historyDays: historyDays, + scopeSignature: self.tokenSnapshotScopeSignature(for: provider, historyDays: historyDays)) } func installCachedTokenSnapshot(_ snapshot: CostUsageTokenSnapshot, for provider: UsageProvider) { + let historyDays = self.settings.effectiveCostUsageHistoryDays self.tokenSnapshots[provider.instanceID] = snapshot self.tokenSnapshotPublications[provider.instanceID] = TokenSnapshotPublication( snapshot: snapshot, publicationRevision: self.tokenSnapshotPublicationRevision(for: provider), providerConfigRevision: self.settings.providerConfigRevision(for: provider), - scopeSignature: self.tokenSnapshotScopeSignature(for: provider)) + historyDays: historyDays, + scopeSignature: self.tokenSnapshotScopeSignature(for: provider, historyDays: historyDays)) } func clearTokenSnapshot(for provider: UsageProvider) { @@ -207,7 +217,7 @@ extension UsageStore { } let scope = self.tokenCostScope(for: .codex) - let historyDays = self.settings.costUsageHistoryDays + let historyDays = self.settings.effectiveCostUsageHistoryDays let publicationRevision = self.providerPublicationRevision(for: .codex) let providerConfigRevision = self.settings.providerConfigRevision(for: .codex) let costUsageSettingsRevision = self.settings.costUsageSettingsRevision @@ -244,7 +254,7 @@ extension UsageStore { self.settings.isCostUsageEffectivelyEnabled(for: .codex), self.isEnabled(.codex), self.tokenCostScope(for: .codex).signature == scope.signature, - self.settings.costUsageHistoryDays == historyDays, + self.settings.effectiveCostUsageHistoryDays == historyDays, self.tokenSnapshotScopeSignature(for: .codex) == tokenSnapshotScopeSignature, self.tokenSnapshotPublicationRevision(for: .codex) == tokenSnapshotPublicationRevision, self.tokenSnapshotPublicationForCurrentProviderConfig(for: .codex) == nil @@ -316,9 +326,12 @@ extension UsageStore { } } - func tokenSnapshotScopeSignature(for provider: UsageProvider) -> String { + func tokenSnapshotScopeSignature( + for provider: UsageProvider, + historyDays requestedHistoryDays: Int? = nil) -> String + { let scope = self.tokenCostScope(for: provider) - let historyDays = self.settings.costUsageHistoryDays + let historyDays = max(1, min(365, requestedHistoryDays ?? self.settings.effectiveCostUsageHistoryDays)) let base = "\(scope.signature)|historyDays=\(historyDays)" + "|settingsRevision=\(self.settings.costUsageSettingsRevision)" guard provider == .cursor else { @@ -378,7 +391,7 @@ extension UsageStore { self.settings.providerConfigRevision(for: provider) == providerConfigRevision, self.settings.costUsageEnabled, self.isEnabled(provider), - self.settings.costUsageHistoryDays == historyDays + self.settings.effectiveCostUsageHistoryDays == historyDays else { return false } @@ -422,14 +435,14 @@ extension UsageStore { case .openai: snapshot?.openAIAPIUsage?.toCostUsageTokenSnapshot() case .mistral: - snapshot?.mistralUsage?.toCostUsageTokenSnapshot(historyDays: self.settings.costUsageHistoryDays) + snapshot?.mistralUsage?.toCostUsageTokenSnapshot(historyDays: self.settings.effectiveCostUsageHistoryDays) case .opencodego: // Web-only source mode and machines with no readable local database leave // `opencodegoUsage.daily` empty; a non-nil-but-dataless projection would still // surface a Cost row whose history submenu has nothing to render. snapshot?.opencodegoUsage.flatMap { usage in usage.daily.isEmpty ? nil : usage - .toCostUsageTokenSnapshot(historyDays: self.settings.costUsageHistoryDays) + .toCostUsageTokenSnapshot(historyDays: self.settings.effectiveCostUsageHistoryDays) } default: nil diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index c12fcfefbc..9c73025483 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1467,7 +1467,7 @@ extension UsageStore { guard !self.tokenRefreshInFlight.contains(provider.instanceID) else { return } let now = Date() - let historyDays = self.settings.costUsageHistoryDays + let historyDays = self.settings.effectiveCostUsageHistoryDays // Cursor cost reuses the status cookie policy: a Manual source forwards the manual header so // cost and status share the same session; other sources fall back to auto resolution. guard case let .proceed(cursorCookieHeaderOverride) = self.prepareCursorCostCookie(for: provider) else { diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index 2276e89953..aea8f34c48 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -831,6 +831,11 @@ enum RPCWireError: Error, LocalizedError { } } +private enum RPCRequestRaceResult: Sendable { + case value(Value) + case timedOut +} + /// RPC helper used on background tasks; safe because we confine it to the owning task. private final class CodexRPCClient: @unchecked Sendable { // Provider-specific by design: Codex RPC owns its dedicated subprocess log category. @@ -1013,24 +1018,29 @@ private final class CodexRPCClient: @unchecked Sendable { method: String, body: @escaping @Sendable () async throws -> T) async throws -> T { - try await withThrowingTaskGroup(of: T.self) { group in + try await withThrowingTaskGroup(of: RPCRequestRaceResult.self) { group in group.addTask { - try await body() + try await .value(body()) } - group.addTask { [weak self] in + group.addTask { try await Task.sleep(for: .seconds(seconds)) - self?.terminateProcessForTimeout(method: method) - throw RPCWireError.timeout(method: method) + return .timedOut } - do { - guard let result = try await group.next() else { - throw RPCWireError.timeout(method: method) - } - group.cancelAll() - return result - } catch { + + guard let result = try await group.next() else { group.cancelAll() - throw error + throw RPCWireError.timeout(method: method) + } + group.cancelAll() + + switch result { + case let .value(value): + return value + case .timedOut: + // Terminating the process closes stdout. Classify that expected EOF as a + // timeout by selecting the timer before requesting process termination. + self.terminateProcessForTimeout(method: method) + throw RPCWireError.timeout(method: method) } } } diff --git a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift index 9114bf1e91..5f96d6336d 100644 --- a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift +++ b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift @@ -578,6 +578,7 @@ struct LocalizationLanguageCatalogTests { #expect(indonesian["tab_general"] == "Umum") #expect(indonesian["quit_app"] == "Keluar CodexBar") #expect(indonesian["30d"] == "30 hari") + #expect(indonesian["365d"] == "365 hari") #expect(indonesian["On"] == "Aktif") #expect(indonesian["Off"] == "Nonaktif") diff --git a/Tests/CodexBarTests/MergedOverviewProviderLimitTests.swift b/Tests/CodexBarTests/MergedOverviewProviderLimitTests.swift new file mode 100644 index 0000000000..04e570d38b --- /dev/null +++ b/Tests/CodexBarTests/MergedOverviewProviderLimitTests.swift @@ -0,0 +1,30 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@Suite(.serialized) +@MainActor +struct MergedOverviewProviderLimitTests { + @Test + func `resolved providers cap stale selection fallback`() throws { + let suite = "MergedOverviewProviderLimitTests-stale-selection-cap" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + store.mergedOverviewSelectedProviders = [.grok] + let activeProviders: [UsageProvider] = [.codex, .claude, .cursor] + let resolved = store.resolvedMergedOverviewProviders( + activeProviders: activeProviders, + maxVisibleProviders: 2) + + #expect(resolved == [.codex, .claude]) + #expect(resolved.count <= 2) + } +} diff --git a/Tests/CodexBarTests/PreferencesSpendDashboardShareTests.swift b/Tests/CodexBarTests/PreferencesSpendDashboardShareTests.swift new file mode 100644 index 0000000000..8e129d3201 --- /dev/null +++ b/Tests/CodexBarTests/PreferencesSpendDashboardShareTests.swift @@ -0,0 +1,354 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct PreferencesSpendDashboardShareTests { + @Test + func `settings share includes every tracked provider once even without cost history`() throws { + let date = Date(timeIntervalSince1970: 1_785_974_400) + let model = SpendDashboardModel(requestedDays: 30, groups: [ + SpendDashboardModel.CurrencyGroup( + currencyCode: "USD", + providers: [ + SpendDashboardModel.ProviderRow( + id: "codex:personal", + rank: 1, + provider: .codex, + displayName: "Codex · Personal", + totalTokens: 200, + totalCost: 4, + coveredDayCount: 30), + SpendDashboardModel.ProviderRow( + id: "codex:work", + rank: 2, + provider: .codex, + displayName: "Codex · Work", + totalTokens: 100, + totalCost: 3, + coveredDayCount: 30), + ], + models: [], + dailyPoints: [], + totalTokens: 300, + totalCost: 7, + coveredDayCount: 30, + chartDomain: date...date, + modelHistoryCompleteness: .complete), + ]) + let trackedSources = [ + Self.source( + id: "codex:personal", + provider: .codex, + providerName: "Codex", + state: .connected, + contributesCostHistory: true), + Self.source( + id: "codex:work", + provider: .codex, + providerName: "Codex", + state: .configured, + contributesCostHistory: true), + Self.source( + id: "openrouter:current", + provider: .openrouter, + providerName: "OpenRouter", + state: .awaitingUsage, + contributesCostHistory: true), + Self.source( + id: "gemini:current", + provider: .gemini, + providerName: "Gemini", + state: .needsAttention, + contributesCostHistory: false), + ] + + let payload = try #require(SpendDashboardPane.makeSharePayload( + model: model, + subscriptionNames: [:], + trackedSources: trackedSources)) + + #expect(payload.providers.map(\.provider) == [.codex, .openrouter, .gemini]) + #expect(payload.providers.map(\.estimatedCost) == [7, nil, nil]) + #expect(payload.spendReportingProviderCount == 1) + let allCurrenciesArePartial = payload.currencies.allSatisfy(\.isPartial) + #expect(allCurrenciesArePartial) + #expect(payload.totalTokensIsPartial) + let sharedText = ShareStatsFormatting.text(payload) + #expect(sharedText.contains("1/3 tracked services report spend")) + #expect(!sharedText.contains("connected services")) + } + + @Test + func `missing tracked account keeps settings share partial`() throws { + let model = SpendDashboardModel(requestedDays: 30, groups: [ + Self.group(currencyCode: "USD", providers: [Self.row(id: "codex:personal", tokens: 200, cost: 4)]), + ]) + let payload = try #require(SpendDashboardPane.makeSharePayload( + model: model, + subscriptionNames: [:], + trackedSources: [ + Self.source( + id: "codex:personal", + provider: .codex, + providerName: "Codex", + state: .connected, + contributesCostHistory: true), + Self.source( + id: "codex:work", + provider: .codex, + providerName: "Codex", + state: .configured, + contributesCostHistory: true), + ])) + + #expect(payload.providers.first?.estimatedCost == 4) + let allCurrenciesArePartial = payload.currencies.allSatisfy(\.isPartial) + #expect(allCurrenciesArePartial) + #expect(payload.totalTokensIsPartial) + } + + @Test + func `provider accounts in unlike currencies never merge spend`() throws { + let model = SpendDashboardModel(requestedDays: 30, groups: [ + Self.group(currencyCode: "USD", providers: [Self.row(id: "codex:personal", tokens: 200, cost: 4)]), + Self.group(currencyCode: "EUR", providers: [Self.row(id: "codex:work", tokens: 100, cost: 3)]), + ]) + let trackedSources = ["codex:personal", "codex:work"].map { + Self.source( + id: $0, + provider: .codex, + providerName: "Codex", + state: .connected, + contributesCostHistory: true) + } + let payload = try #require(SpendDashboardPane.makeSharePayload( + model: model, + subscriptionNames: [:], + trackedSources: trackedSources)) + + #expect(payload.providers.first?.estimatedCost == nil) + let allCurrenciesArePartial = payload.currencies.allSatisfy(\.isPartial) + #expect(allCurrenciesArePartial) + } + + @Test + func `provider family overflow fails closed`() { + let model = SpendDashboardModel(requestedDays: 30, groups: [ + Self.group(currencyCode: "USD", providers: [ + Self.row(id: "codex:personal", tokens: Int.max, cost: Double.greatestFiniteMagnitude), + Self.row(id: "codex:work", tokens: 1, cost: Double.greatestFiniteMagnitude), + ]), + ]) + let trackedSources = ["codex:personal", "codex:work"].map { + Self.source( + id: $0, + provider: .codex, + providerName: "Codex", + state: .connected, + contributesCostHistory: true) + } + let payload = SpendDashboardPane.makeSharePayload( + model: model, + subscriptionNames: [:], + trackedSources: trackedSources) + + #expect(payload == nil) + } + + @Test + func `mismatched account identity is excluded and marks share partial`() throws { + let model = SpendDashboardModel(requestedDays: 30, groups: [ + Self.group(currencyCode: "USD", providers: [ + Self.row(id: "codex:a", tokens: 200, cost: 4), + Self.row(id: "codex:c", tokens: 900, cost: 9), + ]), + ]) + let payload = try #require(SpendDashboardPane.makeSharePayload( + model: model, + subscriptionNames: [:], + trackedSources: Self.codexSources(ids: ["codex:a", "codex:b"]))) + + #expect(payload.providers.first?.estimatedCost == 4) + #expect(payload.providers.first?.totalTokens == 200) + #expect(payload.currencies.first?.estimatedCost == 4) + let allCurrenciesArePartial = payload.currencies.allSatisfy(\.isPartial) + #expect(allCurrenciesArePartial) + #expect(payload.totalTokensIsPartial) + #expect(payload.topModels.isEmpty) + } + + @Test + func `extra stale account row is excluded and marks share partial`() throws { + let model = SpendDashboardModel(requestedDays: 30, groups: [ + Self.group(currencyCode: "USD", providers: [ + Self.row(id: "codex:a", tokens: 200, cost: 4), + Self.row(id: "codex:b", tokens: 100, cost: 3), + Self.row(id: "codex:c", tokens: 900, cost: 9), + ]), + ]) + let payload = try #require(SpendDashboardPane.makeSharePayload( + model: model, + subscriptionNames: [:], + trackedSources: Self.codexSources(ids: ["codex:a", "codex:b"]))) + + #expect(payload.providers.first?.estimatedCost == 7) + #expect(payload.providers.first?.totalTokens == 300) + #expect(payload.currencies.first?.estimatedCost == 7) + let allCurrenciesArePartial = payload.currencies.allSatisfy(\.isPartial) + #expect(allCurrenciesArePartial) + #expect(payload.totalTokensIsPartial) + } + + @Test + func `stale provider family is excluded from top models`() throws { + let date = Date(timeIntervalSince1970: 1_785_974_400) + let group = SpendDashboardModel.CurrencyGroup( + currencyCode: "USD", + providers: [ + Self.row(id: "codex:a", tokens: 200, cost: 4), + SpendDashboardModel.ProviderRow( + id: "openrouter", + rank: 1, + provider: .openrouter, + displayName: "OpenRouter", + totalTokens: 900, + totalCost: 9, + coveredDayCount: 30), + ], + models: [ + SpendDashboardModel.ModelRow( + rank: 1, + provider: .openrouter, + providerName: "OpenRouter", + modelName: "anthropic/claude-sonnet-4", + totalTokens: 900, + totalCost: 9), + ], + dailyPoints: [], + totalTokens: 1100, + totalCost: 13, + coveredDayCount: 30, + chartDomain: date...date, + modelHistoryCompleteness: .complete) + let model = SpendDashboardModel(requestedDays: 30, groups: [group]) + let payload = try #require(SpendDashboardPane.makeSharePayload( + model: model, + subscriptionNames: [:], + trackedSources: Self.codexSources(ids: ["codex:a"]))) + + #expect(payload.providers.map(\.provider) == [.codex]) + #expect(payload.currencies.compactMap(\.estimatedCost) == [4]) + #expect(payload.totalTokens == 200) + #expect(payload.totalTokensIsPartial) + #expect(payload.topModels.isEmpty) + } + + @Test + func `selected saved account matches provider scoped spend`() throws { + let date = Date(timeIntervalSince1970: 1_785_974_400) + let model = SpendDashboardModel(requestedDays: 30, groups: [ + SpendDashboardModel.CurrencyGroup( + currencyCode: "USD", + providers: [ + SpendDashboardModel.ProviderRow( + id: "openrouter", + rank: 1, + provider: .openrouter, + displayName: "OpenRouter", + totalTokens: 900, + totalCost: 9, + coveredDayCount: 30), + ], + models: [], + dailyPoints: [], + totalTokens: 900, + totalCost: 9, + coveredDayCount: 30, + chartDomain: date...date, + modelHistoryCompleteness: .complete), + ]) + let payload = try #require(SpendDashboardPane.makeSharePayload( + model: model, + subscriptionNames: [:], + trackedSources: [ + Self.source( + id: "openrouter:account:00000000-0000-0000-0000-000000000001", + provider: .openrouter, + providerName: "OpenRouter", + state: .connected, + contributesCostHistory: true), + Self.source( + id: "openrouter:account:00000000-0000-0000-0000-000000000002", + provider: .openrouter, + providerName: "OpenRouter", + state: .configured, + contributesCostHistory: false), + ])) + + #expect(payload.providers.first?.estimatedCost == 9) + #expect(payload.providers.first?.totalTokens == 900) + #expect(payload.spendReportingProviderCount == 1) + #expect(payload.currencies.first?.estimatedCost == 9) + #expect(payload.currencies.first?.isPartial == false) + #expect(payload.totalTokens == 900) + #expect(payload.totalTokensIsPartial == false) + } + + private static func source( + id: String, + provider: UsageProvider, + providerName: String, + state: SpendDashboardTrackedSource.State, + contributesCostHistory: Bool) -> SpendDashboardTrackedSource + { + SpendDashboardTrackedSource( + id: id, + provider: provider, + providerName: providerName, + accountName: nil, + state: state, + supportsCostHistory: contributesCostHistory, + contributesCostHistory: contributesCostHistory) + } + + private static func row(id: String, tokens: Int, cost: Double) -> SpendDashboardModel.ProviderRow { + SpendDashboardModel.ProviderRow( + id: id, + rank: 1, + provider: .codex, + displayName: "Codex", + totalTokens: tokens, + totalCost: cost, + coveredDayCount: 30) + } + + private static func codexSources(ids: [String]) -> [SpendDashboardTrackedSource] { + ids.map { + Self.source( + id: $0, + provider: .codex, + providerName: "Codex", + state: .connected, + contributesCostHistory: true) + } + } + + private static func group( + currencyCode: String, + providers: [SpendDashboardModel.ProviderRow]) -> SpendDashboardModel.CurrencyGroup + { + let date = Date(timeIntervalSince1970: 1_785_974_400) + return SpendDashboardModel.CurrencyGroup( + currencyCode: currencyCode, + providers: providers, + models: [], + dailyPoints: [], + totalTokens: nil, + totalCost: nil, + coveredDayCount: 30, + chartDomain: date...date, + modelHistoryCompleteness: .incomplete) + } +} diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 81f7047a84..7996303cbd 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -896,37 +896,37 @@ struct ProviderArchitectureGatekeeperTests { reason: "This observation touchpoint reads a fixed provider field so UI invalidation tracks that setting."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 334, + line: 393, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 336, + line: 395, anchor: "modelProviderName: ProviderDescriptorRegistry.descriptor(for: .codex).metadata.displayName,", expectedProviderIDs: ["codex"], reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 408, + line: 467, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 410, + line: 469, anchor: "modelProviderName: ProviderDescriptorRegistry.descriptor(for: .codex).metadata.displayName,", expectedProviderIDs: ["codex"], reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 441, + line: 500, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 470, + line: 653, anchor: "let providerName = store.metadata(for: .codex).displayName", expectedProviderIDs: ["codex"], reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), @@ -962,7 +962,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "The memory-pressure debug fixture installs its synthetic entry in the Codex cache slot."), SuppressedProviderReference( path: "Sources/CodexBar/StatusItemController+Menu.swift", - line: 1087, + line: 1123, anchor: "controller.refreshOpenMenuIfStillVisible(menu, provider: .codex)", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), @@ -1070,25 +1070,25 @@ struct ProviderArchitectureGatekeeperTests { reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 925, + line: 926, anchor: "let snapshotEmail = CodexIdentityResolver.normalizeEmail(snapshot.accountEmail(for: .codex)),", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 933, + line: 934, anchor: "let identity = snapshot.identity(for: .codex)", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 935, + line: 936, anchor: "providerID: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 1466, + line: 1467, anchor: "let currentAccount = self.uniqueTokenAccount(provider: .claude, accountID: fetchedAccount.id),", expectedProviderIDs: ["claude"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), @@ -1196,68 +1196,68 @@ struct ProviderArchitectureGatekeeperTests { reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 73, + line: 74, anchor: "allowVertexClaudeFallback: !self.isEnabled(.claude),", expectedProviderIDs: ["claude"], reason: "The local transcript scan permits Vertex fallback only when Claude is disabled to avoid " + "double-counting the same logs."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 209, + line: 219, anchor: "let scope = self.tokenCostScope(for: .codex)", expectedProviderIDs: ["codex"], reason: "The Codex-only cache hydration path passes its fixed provider identity to shared state helpers."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 211, + line: 221, anchor: "let publicationRevision = self.providerPublicationRevision(for: .codex)", expectedProviderIDs: ["codex"], reason: "The Codex-only cache hydration path passes its fixed provider identity to shared state helpers."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 212, + line: 222, anchor: "let providerConfigRevision = self.settings.providerConfigRevision(for: .codex)", expectedProviderIDs: ["codex"], reason: "The Codex-only cache hydration path passes its fixed provider identity to shared state helpers."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 214, + line: 224, anchor: "let tokenSnapshotScopeSignature = self.tokenSnapshotScopeSignature(for: .codex)", expectedProviderIDs: ["codex"], reason: "The Codex-only cache hydration path passes its fixed provider identity to shared state helpers."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 215, + line: 225, anchor: "let tokenSnapshotPublicationRevision = self.tokenSnapshotPublicationRevision(for: .codex)", expectedProviderIDs: ["codex"], reason: "The Codex-only cache hydration path passes its fixed provider identity to shared state helpers."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 244, + line: 254, anchor: "self.settings.isCostUsageEffectivelyEnabled(for: .codex),", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 245, + line: 255, anchor: "self.isEnabled(.codex),", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 254, + line: 264, anchor: "self.installCachedTokenSnapshot(result.snapshot, for: .codex)", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 335, + line: 348, anchor: "let credentialFingerprint = CookieHeaderCache.loadForDisplay(provider: .cursor)", expectedProviderIDs: ["cursor"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 348, + line: 361, anchor: "let scope = self.tokenCostScope(for: .cursor)", expectedProviderIDs: ["cursor"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), @@ -1503,7 +1503,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This tagged diagnostic payload encodes MiniMax details under the matching wire key."), SuppressedProviderReference( path: "Sources/CodexBarCore/UsageFetcher.swift", - line: 1486, + line: 1496, anchor: "providerID: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), @@ -2193,7 +2193,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/PreferencesSpendDashboardPane.swift", - line: 254, + line: 310, anchor: "self.configuration.providerIDs.contains(UsageProvider.codex.rawValue)", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2201,7 +2201,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/PreferencesSpendDashboardPane.swift", - line: 365, + line: 479, anchor: ".count { $0.provider == .codex }", expectedProviderIDs: ["codex"], expectedReferenceCount: 3, @@ -2217,12 +2217,20 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared provider integration dispatches a capability owned by the provider descriptor or adapter."), AllowedProviderConstruct( path: "Sources/CodexBar/ShareStatsPayload.swift", - line: 163, + line: 219, anchor: "([\"codestral-\", \"devstral-\", \"magistral-\", \"mistral-\", \"mistral \", \"mistral.\", \"mixtral-\"], \"Mistral\"),", expectedProviderIDs: ["mistral"], expectedReferenceCount: 1, expectedReferenceFingerprint: ["mistral@0"], reason: "This public model-family sanitizer is independent of the provider registry; Mistral is also a provider ID."), + AllowedProviderConstruct( + path: "Sources/CodexBar/ShareStatsPayload.swift", + line: 250, + anchor: "\"air\": \"Air\", \"chat\": \"Chat\", \"code\": \"Code\", \"coder\": \"Coder\", \"codex\": \"Codex\",", + expectedProviderIDs: ["codex"], + expectedReferenceCount: 1, + expectedReferenceFingerprint: ["codex@0"], + reason: "This privacy allowlist recognizes Codex as a public model-name token, not as a provider dispatch."), AllowedProviderConstruct( path: "Sources/CodexBar/SessionQuotaNotifications.swift", line: 198, @@ -2281,7 +2289,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/SettingsStore.swift", - line: 1027, + line: 1029, anchor: "if !seen.contains(.factory), let zaiIndex = ordered.firstIndex(of: .zai) {", expectedProviderIDs: ["factory", "minimax", "zai"], expectedReferenceCount: 8, @@ -2298,7 +2306,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 135, + line: 189, anchor: "let codexRequests = providers.contains(.codex)", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2306,7 +2314,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 184, + line: 241, anchor: "let providerBaselines = initialProviders.filter { $0 != .codex }.map { provider in", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2314,7 +2322,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 203, + line: 260, anchor: "let codexRequests = providers.contains(.codex)", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2322,7 +2330,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 224, + line: 282, anchor: "for provider in providers where provider != .codex {", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2330,7 +2338,15 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 496, + line: 545, + anchor: "if provider == .codex {", + expectedProviderIDs: ["codex"], + expectedReferenceCount: 3, + expectedReferenceFingerprint: ["codex@0", "codex@9", "codex@11"], + reason: "The tracked-source roster projects Codex's first-party multi-account state before generic token accounts."), + AllowedProviderConstruct( + path: "Sources/CodexBar/SpendDashboardController.swift", + line: 679, anchor: "if providers.contains(.codex) {", expectedProviderIDs: ["codex"], expectedReferenceCount: 2, @@ -2338,15 +2354,15 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 551, + line: 734, anchor: "guard provider != .codex else { return nil }", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, expectedReferenceFingerprint: ["codex@0"], - reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), + reason: "Codex ownership is represented by its dedicated account projection rather than generic provider config."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 1172, + line: 1355, anchor: "guard input.provider == .codex,", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2357,12 +2373,12 @@ struct ProviderArchitectureGatekeeperTests { line: 113, anchor: "guard summary.input.provider == .codex else { return false }", expectedProviderIDs: ["codex"], - expectedReferenceCount: 1, - expectedReferenceFingerprint: ["codex@0"], + expectedReferenceCount: 2, + expectedReferenceFingerprint: ["codex@0", "codex@9"], reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardModel.swift", - line: 660, + line: 679, anchor: "guard provider == .mistral else { return displayCalendar }", expectedProviderIDs: ["mistral"], expectedReferenceCount: 1, @@ -2482,7 +2498,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/StatusItemController+Menu.swift", - line: 1120, + line: 1156, anchor: "return .provider((self.resolvedMenuProvider(enabledProviders: enabledProviders) ?? .codex).instanceID)", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2490,7 +2506,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/StatusItemController+Menu.swift", - line: 1133, + line: 1169, anchor: "return self.store.enabledFirstPartyProvidersForDisplay().first ?? .codex", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2843,15 +2859,15 @@ struct ProviderArchitectureGatekeeperTests { "deepseek@1", "codex@8", "codex@16", - "codex@27", "codex@28", - "codex@33", - "codex@35", + "codex@29", + "codex@34", + "codex@36", ], reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 985, + line: 986, anchor: "guard provider == .claude, !hasSelectedTokenAccount else { return false }", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -2859,7 +2875,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 1325, + line: 1326, anchor: "if provider == .gemini, Self.isGeminiConsumerTierDeprecationError(error) {", expectedProviderIDs: ["claude", "gemini"], expectedReferenceCount: 2, @@ -2867,7 +2883,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 1369, + line: 1370, anchor: "if provider == .claude,", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -2875,7 +2891,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 1385, + line: 1386, anchor: "if provider == .claude,", expectedProviderIDs: ["claude"], expectedReferenceCount: 5, @@ -2883,7 +2899,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", - line: 1467, + line: 1468, anchor: "cached.cacheKey == self.tokenAccountSnapshotCacheKey(provider: .claude, account: currentAccount)", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -2915,7 +2931,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+SpendDashboardCodexCostCatchUp.swift", - line: 262, + line: 263, anchor: "&& self.settings.providerConfigRevision(for: .codex) == context.providerConfigRevision", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3045,7 +3061,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 218, + line: 228, anchor: "guard self.tokenSnapshotPublicationForCurrentProviderConfig(for: .codex) == nil else { return }", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3053,7 +3069,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 241, + line: 251, anchor: "guard self.providerPublicationRevisionIsCurrent(publicationRevision, for: .codex),", expectedProviderIDs: ["codex"], expectedReferenceCount: 9, @@ -3071,7 +3087,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 278, + line: 288, anchor: "return provider == .codex && self.codexCostCatchUpActivity?.phase == .indexing", expectedProviderIDs: ["claude", "codex", "vertexai"], expectedReferenceCount: 4, @@ -3079,7 +3095,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 324, + line: 337, anchor: "guard provider == .cursor else {", expectedProviderIDs: ["cursor"], expectedReferenceCount: 1, @@ -3087,7 +3103,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 386, + line: 399, anchor: "if provider == .cursor,", expectedProviderIDs: ["cursor"], expectedReferenceCount: 1, @@ -3095,7 +3111,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 406, + line: 419, anchor: "guard provider == .cursor,", expectedProviderIDs: ["cursor"], expectedReferenceCount: 1, @@ -3103,7 +3119,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 422, + line: 435, anchor: "case .openai:", expectedProviderIDs: ["mistral", "openai", "opencodego"], expectedReferenceCount: 3, @@ -3111,7 +3127,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 441, + line: 454, anchor: "case .mistral, .openai, .opencodego:", expectedProviderIDs: ["mistral", "openai", "opencodego"], expectedReferenceCount: 3, @@ -3119,7 +3135,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 483, + line: 496, anchor: "self.tokenFailureGates[.codex]?.reset()", expectedProviderIDs: ["claude", "codex"], expectedReferenceCount: 2, diff --git a/Tests/CodexBarTests/ProviderPluginDetailsParityTests.swift b/Tests/CodexBarTests/ProviderPluginDetailsParityTests.swift index 6b6795184a..c72c67918e 100644 --- a/Tests/CodexBarTests/ProviderPluginDetailsParityTests.swift +++ b/Tests/CodexBarTests/ProviderPluginDetailsParityTests.swift @@ -180,11 +180,11 @@ struct ProviderPluginDetailsParityTests { #expect(recorded[1].url?.absoluteString == (overridden ? "https://router.example.test/gateway/v1/key" : "https://openrouter.ai/api/v1/key")) - #expect(recorded[1].timeoutInterval == 15) #expect(recorded[0].value(forHTTPHeaderField: "X-Title") == (overridden ? "CodexBar QA" : "CodexBar")) #expect(recorded[0].value(forHTTPHeaderField: "HTTP-Referer") == (overridden ? "https://codexbar.example" : nil)) #expect(recorded[1].value(forHTTPHeaderField: "X-Title") == nil) + #expect(recorded[1].timeoutInterval == 15) } @Test diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift index 0155c6397c..487e75d79a 100644 --- a/Tests/CodexBarTests/SettingsStoreTests.swift +++ b/Tests/CodexBarTests/SettingsStoreTests.swift @@ -404,7 +404,7 @@ struct SettingsStoreTests { .gemini, .grok, ] - let expectedProviders: [UsageProvider] = [.opencode, .codex, .claude, .cursor, .warp, .gemini] + let expectedProviders: [UsageProvider] = [.opencode, .codex, .claude, .cursor, .warp, .gemini, .grok] #expect(storeA.mergedOverviewSelectedProviders == expectedProviders) let defaultsB = try #require(UserDefaults(suiteName: suite)) @@ -434,8 +434,8 @@ struct SettingsStoreTests { } @Test - func `resolved merged overview providers defaults to first six when selection empty`() throws { - let suite = "SettingsStoreTests-merged-overview-default-first-six" + func `resolved merged overview providers defaults to every active provider when selection empty`() throws { + let suite = "SettingsStoreTests-merged-overview-default-all" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) let configStore = testConfigStore(suiteName: suite) @@ -445,10 +445,11 @@ struct SettingsStoreTests { zaiTokenStore: NoopZaiTokenStore(), syntheticTokenStore: NoopSyntheticTokenStore()) - let activeProviders: [UsageProvider] = [.codex, .claude, .cursor, .opencode, .warp, .gemini, .grok] + let activeProviders = Array(UsageProvider.allCases.prefix(20)) let resolved = store.resolvedMergedOverviewProviders(activeProviders: activeProviders) - #expect(resolved == [.codex, .claude, .cursor, .opencode, .warp, .gemini]) + #expect(activeProviders.count == 20) + #expect(resolved == activeProviders) } @Test @@ -684,13 +685,14 @@ struct SettingsStoreTests { .opencode, .warp, .gemini, + .grok, ]) let resolvedWhenEmpty = store.reconcileMergedOverviewSelectedProviders(activeProviders: []) #expect(resolvedWhenEmpty == []) let resolvedAfterReenable = store.resolvedMergedOverviewProviders(activeProviders: activeProviders) - #expect(resolvedAfterReenable == [.codex, .claude, .cursor, .opencode, .warp, .gemini]) + #expect(resolvedAfterReenable == activeProviders) } @Test diff --git a/Tests/CodexBarTests/ShareStatsTests.swift b/Tests/CodexBarTests/ShareStatsTests.swift index 64a874f785..26354f11d6 100644 --- a/Tests/CodexBarTests/ShareStatsTests.swift +++ b/Tests/CodexBarTests/ShareStatsTests.swift @@ -31,24 +31,153 @@ struct ShareStatsTests { subscriptionNames: subscriptionNames)) #expect(payload.days == 30) - #expect(payload.totalTokens == nil) + #expect(payload.totalTokens == 500) + #expect(payload.totalTokensIsPartial) #expect(payload.currencies == [ ShareStatsCurrencyPayload(currencyCode: "GBP", estimatedCost: 12, coveredDayCount: 10), - ShareStatsCurrencyPayload(currencyCode: "USD", estimatedCost: nil, coveredDayCount: 0), + ShareStatsCurrencyPayload(currencyCode: "USD", estimatedCost: 4, coveredDayCount: 0, isPartial: true), ]) #expect(payload.providers.map(\.providerName) == ["Claude", "Codex · #1", "Cursor"]) #expect(payload.providers.map(\.subscriptionName) == ["Max", "Pro 20x", "Cursor Pro"]) #expect(payload.providers.last?.estimatedCost == nil) - #expect(payload.topModels.map(\.modelName).prefix(2) == ["Claude", "GPT"]) + #expect(payload.spendReportingProviderCount == 2) + #expect(payload.topModels.map(\.modelName).prefix(2) == ["Claude Sonnet 4", "GPT 5.4"]) let text = ShareStatsFormatting.text(payload) #expect(text.contains("GBP: £12.00 estimated · coverage 10/30 days")) #expect(text.contains("Claude · Max: 300 tokens · ~£12.00 est · 10/30 days")) - #expect(text.contains("USD: Spend unavailable · coverage 0/30 days")) + #expect(text.contains("USD: ~$4.00 estimated · coverage 0/30 days")) #expect(text.contains("Cursor · Cursor Pro: Spend unavailable")) + #expect(text.contains("2/3 tracked services report spend")) + #expect(!text.contains("connected services")) #expect(!text.contains("£12.00 +")) } + @Test + func `overview roster keeps every connected service in the flex card`() throws { + let roster = [ + ShareStatsProviderRosterEntry(provider: .codex, providerName: "Codex", currencyCode: "USD"), + ShareStatsProviderRosterEntry(provider: .claude, providerName: "Claude", currencyCode: "USD"), + ShareStatsProviderRosterEntry(provider: .openrouter, providerName: "OpenRouter", currencyCode: "USD"), + ShareStatsProviderRosterEntry(provider: .gemini, providerName: "Gemini", currencyCode: "USD"), + ShareStatsProviderRosterEntry(provider: .grok, providerName: "Grok", currencyCode: "USD"), + ShareStatsProviderRosterEntry(provider: .cursor, providerName: "Cursor", currencyCode: "USD"), + ] + let payload = try #require(ShareStatsBuilder.make(model: Self.dashboard, providerRoster: roster)) + + #expect(payload.providers.map(\.provider) == roster.map(\.provider)) + #expect(payload.providers.count == 6) + #expect(payload.spendReportingProviderCount == 2) + #expect(payload.providers.last?.estimatedCost == nil) + #expect(payload.totalTokens == 500) + #expect(payload.totalTokensIsPartial) + #expect(payload.currencies.allSatisfy { currency in currency.isPartial }) + #expect(ShareStatsFormatting.text(payload).contains("~500 tracked tokens")) + let sharedText = ShareStatsFormatting.text(payload) + #expect(sharedText.contains("2/6 tracked services report spend")) + #expect(!sharedText.contains("connected services")) + } + + @Test + func `duplicate accounts cannot mask a connected provider with unavailable usage`() throws { + let model = SpendDashboardModel(requestedDays: 30, groups: [ + SpendDashboardModel.CurrencyGroup( + currencyCode: "USD", + providers: [ + SpendDashboardModel.ProviderRow( + id: "codex:one", + rank: 1, + provider: .codex, + displayName: "Codex · #1", + totalTokens: 200, + totalCost: 4, + coveredDayCount: 30), + SpendDashboardModel.ProviderRow( + id: "codex:two", + rank: 2, + provider: .codex, + displayName: "Codex · #2", + totalTokens: 300, + totalCost: 6, + coveredDayCount: 30), + ], + models: [], + dailyPoints: [], + totalTokens: 500, + totalCost: 10, + coveredDayCount: 30, + chartDomain: Self.date...Self.date, + modelHistoryCompleteness: .complete), + ]) + let roster = [ + ShareStatsProviderRosterEntry(provider: .codex, providerName: "Codex", currencyCode: "USD"), + ShareStatsProviderRosterEntry( + provider: .openrouter, + providerName: "OpenRouter", + currencyCode: "USD"), + ] + + let payload = try #require(ShareStatsBuilder.make(model: model, providerRoster: roster)) + + #expect(payload.totalTokens == 500) + #expect(payload.totalTokensIsPartial) + #expect(payload.currencies.count == 1) + #expect(payload.currencies.first?.isPartial == true) + #expect(payload.spendReportingProviderCount == 1) + #expect(payload.providers.count == 2) + } + + @Test + func `complete spend remains exact when a provider has no token counts`() throws { + let group = SpendDashboardModel.CurrencyGroup( + currencyCode: "USD", + providers: [ + SpendDashboardModel.ProviderRow( + id: "bedrock", + rank: 1, + provider: .bedrock, + displayName: "Bedrock", + totalTokens: nil, + totalCost: 12, + coveredDayCount: 30), + ], + models: [], + dailyPoints: [], + totalTokens: nil, + totalCost: 12, + coveredDayCount: 30, + chartDomain: Self.date...Self.date, + modelHistoryCompleteness: .complete) + let roster = [ + ShareStatsProviderRosterEntry(provider: .bedrock, providerName: "Bedrock", currencyCode: "USD"), + ] + + let payload = try #require(ShareStatsBuilder.make( + model: SpendDashboardModel(requestedDays: 30, groups: [group]), + providerRoster: roster)) + + #expect(payload.currencies.first?.estimatedCost == 12) + #expect(payload.currencies.first?.isPartial == false) + #expect(payload.totalTokens == nil) + #expect(payload.totalTokensIsPartial) + #expect(ShareStatsFormatting.text(payload).contains("USD: $12.00 estimated")) + } + + @Test + func `twenty connected services remain accounted for without overflowing the flex card`() throws { + let roster = Array(UsageProvider.allCases.prefix(20)).map { provider in + ShareStatsProviderRosterEntry( + provider: provider, + providerName: ProviderDefaults.metadata[provider]?.displayName ?? provider.rawValue, + currencyCode: "USD") + } + let payload = try #require(ShareStatsBuilder.make(model: Self.dashboard, providerRoster: roster)) + + #expect(roster.count == 20) + #expect(payload.providers.count == 20) + #expect(ShareStatsCardView.providerDisplayLimit(for: payload.providers.count) == 4) + } + @Test func `payload sanitizer excludes emails identifiers paths and prompts`() throws { let model = Self.dashboard(models: [ @@ -77,9 +206,9 @@ struct ShareStatsTests { subscriptionNames: subscriptionNames)) let text = ShareStatsFormatting.text(payload) - #expect(payload.topModels.map(\.modelName) == ["Claude", "GPT"]) - #expect(payload.topModels.last?.totalTokens == 400) - #expect(payload.topModels.last?.estimatedCost == 8) + #expect(payload.topModels.map(\.modelName) == ["Claude Sonnet 4", "GPT 5.4"]) + #expect(payload.topModels.last?.totalTokens == 200) + #expect(payload.topModels.last?.estimatedCost == 4) #expect(payload.providers.map(\.subscriptionName) == ["Max", nil, nil]) #expect(!text.contains("person@example.com")) #expect(!text.contains("/Users/")) @@ -124,8 +253,82 @@ struct ShareStatsTests { @Test func `bedrock regional model identifiers map to public families`() { - #expect(ShareStatsSanitizer.modelName("us.amazon.nova-2-lite-v1:0") == "Amazon Nova") - #expect(ShareStatsSanitizer.modelName("global.anthropic.claude-sonnet-4-v1:0") == "Claude") + #expect(ShareStatsSanitizer.modelName("us.amazon.nova-2-lite-v1:0") == "Amazon Nova 2 Lite") + #expect(ShareStatsSanitizer.modelName("global.anthropic.claude-sonnet-4-v1:0") == "Claude Sonnet 4") + } + + @Test + func `share model labels preserve first class variants and routed OpenRouter ids`() { + #expect(ShareStatsSanitizer.modelName("claude-fable-5") == "Claude Fable 5") + #expect(ShareStatsSanitizer.modelName("claude-opus-4-6") == "Claude Opus 4.6") + #expect(ShareStatsSanitizer.modelName("claude-sonnet-4-6") == "Claude Sonnet 4.6") + #expect(ShareStatsSanitizer.modelName("anthropic/claude-sonnet-4-6") == "Claude Sonnet 4.6") + #expect(ShareStatsSanitizer.modelName("openai/gpt-5.4-mini") == "GPT 5.4 Mini") + #expect(ShareStatsSanitizer.modelName("openai/gpt-5.6-sol") == "GPT 5.6 Sol") + #expect(ShareStatsSanitizer.modelName("openai/gpt-5.6-terra") == "GPT 5.6 Terra") + #expect(ShareStatsSanitizer.modelName("openai/gpt-5.6-luna") == "GPT 5.6 Luna") + #expect(ShareStatsSanitizer.modelName("openai/gpt-4o") == "GPT 4o") + #expect(ShareStatsSanitizer.modelName("google/gemini-2.5-pro") == "Gemini 2.5 Pro") + #expect(ShareStatsSanitizer.modelName("moonshotai/kimi-k2.5") == "Kimi K2.5") + #expect(ShareStatsSanitizer.modelName("acme/private-model-v2") == nil) + #expect(ShareStatsSanitizer.modelName("acme/gpt-secret-project") == nil) + } + + @Test @MainActor + func `routed OpenRouter models remain distinct in the share ranking`() throws { + let modelRows = [ + ("anthropic/claude-fable-5", 900), + ("anthropic/claude-opus-4-6", 800), + ("anthropic/claude-sonnet-4-6", 700), + ("openai/gpt-5.4-mini", 600), + ("gpt-5.4-mini", 100), + ("google/gemini-2.5-pro", 500), + ("x-ai/grok-4-fast", 400), + ] + let dashboard = SpendDashboardModel(requestedDays: 30, groups: [ + SpendDashboardModel.CurrencyGroup( + currencyCode: "USD", + providers: [ + SpendDashboardModel.ProviderRow( + id: "openrouter", + rank: 1, + provider: .openrouter, + displayName: "OpenRouter", + totalTokens: 4000, + totalCost: 40, + coveredDayCount: 30), + ], + models: modelRows.enumerated().map { index, row in + SpendDashboardModel.ModelRow( + rank: index + 1, + provider: .openrouter, + providerName: "OpenRouter", + modelName: row.0, + totalTokens: row.1, + totalCost: Double(row.1) / 100) + }, + dailyPoints: [], + totalTokens: 4000, + totalCost: 40, + coveredDayCount: 30, + chartDomain: Self.date...Self.date, + modelHistoryCompleteness: .complete), + ]) + + let payload = try #require(ShareStatsBuilder.make(model: dashboard)) + #expect(payload.topModels.map(\.modelName) == [ + "Claude Fable 5", + "Claude Opus 4.6", + "Claude Sonnet 4.6", + "GPT 5.4 Mini", + "Gemini 2.5 Pro", + "Grok 4 Fast", + ]) + #expect(payload.topModels.allSatisfy { $0.provider == .openrouter }) + #expect(payload.topModels.first { $0.modelName == "GPT 5.4 Mini" }?.totalTokens == 700) + #expect(ShareStatsCardView.modelSectionDetail(for: payload.topModels.count) == "3 OF 6 · BY TOKENS") + #expect(ShareStatsFormatting.text(payload).contains("+1 more models ranked in local stats")) + #expect(try #require(ShareStatsRenderer.pngData(for: payload)).isEmpty == false) } @Test @@ -135,21 +338,21 @@ struct ShareStatsTests { rank: 1, provider: .codex, providerName: "Codex", - modelName: "gpt-5.4", + modelName: "gpt-5.4-mini", totalTokens: Int.max, totalCost: Double.greatestFiniteMagnitude), SpendDashboardModel.ModelRow( rank: 2, provider: .codex, providerName: "Codex", - modelName: "gpt-5.4-mini", + modelName: "openai/gpt-5.4-mini", totalTokens: 1, totalCost: Double.greatestFiniteMagnitude), SpendDashboardModel.ModelRow( rank: 3, provider: .codex, providerName: "Codex", - modelName: "gpt-5.4-nano", + modelName: "chatgpt-5.4-mini", totalTokens: 5, totalCost: 5), ] @@ -309,6 +512,7 @@ struct ShareStatsTests { #expect(ShareStatsCardView.providerDisplayLimit(for: 5) == 5) #expect(ShareStatsCardView.providerDisplayLimit(for: 6) == 4) #expect(ShareStatsCardView.providerDisplayLimit(for: 12) == 4) + #expect(ShareStatsCardView.providerDisplayLimit(for: 20) == 4) } @Test @MainActor diff --git a/Tests/CodexBarTests/SpendDashboardCachedPresentationTests.swift b/Tests/CodexBarTests/SpendDashboardCachedPresentationTests.swift index e801e93ceb..9d4c63d4d5 100644 --- a/Tests/CodexBarTests/SpendDashboardCachedPresentationTests.swift +++ b/Tests/CodexBarTests/SpendDashboardCachedPresentationTests.swift @@ -15,6 +15,7 @@ struct SpendDashboardCachedPresentationTests { func `production loader reads a validated scoped account report`() async throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } + let historyDays = 30 let day = try env.makeLocalNoon(year: 2026, month: 7, day: 15) _ = try env.writeCodexSessionFile( day: day, @@ -45,7 +46,7 @@ struct SpendDashboardCachedPresentationTests { provider: .codex, now: day, codexHomePath: env.codexHomeRoot.path, - historyDays: SpendDashboardSource.scanDays, + historyDays: historyDays, includePiSessions: false) let account = CodexSpendScanRequest( id: "profile", @@ -64,7 +65,8 @@ struct SpendDashboardCachedPresentationTests { unavailableSourceIDs: [], codexRequests: [account], now: day, - force: false) + force: false, + historyDays: historyDays) let cacheRoot = env.cacheRoot let result = await SpendDashboardSource.loadCached(request, cacheRootResolver: { _ in cacheRoot }) @@ -117,12 +119,13 @@ struct SpendDashboardCachedPresentationTests { unavailableSourceIDs: [], codexRequests: [first, second], now: Self.fixtureNow, - force: false) + force: false, + historyDays: 365) let result = await SpendDashboardSource.loadCached(request, cachedCodexSnapshotLoader: { context in switch context.account.id { - case "first": Self.input(cost: 2).snapshot - case "second": Self.input(cost: 5).snapshot + case "first": Self.input(cost: 2, historyDays: context.historyDays).snapshot + case "second": Self.input(cost: 5, historyDays: context.historyDays).snapshot default: nil } }) @@ -131,6 +134,7 @@ struct SpendDashboardCachedPresentationTests { "codex:first": 2, "codex:second": 5, ]) + #expect(result.inputs.allSatisfy { $0.snapshot.historyDays == 365 }) #expect(SpendDashboardSource.codexCacheRoot(for: first).lastPathComponent == "first-cache") #expect(SpendDashboardSource.codexCacheRoot(for: second).lastPathComponent == "second-cache") } @@ -207,7 +211,8 @@ struct SpendDashboardCachedPresentationTests { private nonisolated static func input( id: String? = nil, - cost: Double) -> SpendDashboardModel.ProviderInput + cost: Double, + historyDays: Int = 30) -> SpendDashboardModel.ProviderInput { let entry = CostUsageDailyReport.Entry( date: "2026-07-15", @@ -222,6 +227,7 @@ struct SpendDashboardCachedPresentationTests { sessionCostUSD: nil, last30DaysTokens: 10, last30DaysCostUSD: cost, + historyDays: historyDays, daily: [entry], updatedAt: Self.fixtureNow) return SpendDashboardModel.ProviderInput( diff --git a/Tests/CodexBarTests/SpendDashboardControllerTests.swift b/Tests/CodexBarTests/SpendDashboardControllerTests.swift index 98018e1821..909a4b5b63 100644 --- a/Tests/CodexBarTests/SpendDashboardControllerTests.swift +++ b/Tests/CodexBarTests/SpendDashboardControllerTests.swift @@ -6,6 +6,29 @@ import Testing @MainActor @Suite(.serialized) struct SpendDashboardControllerTests { + @Test + func `dashboard selection derives an ephemeral provider history window`() throws { + #expect(spendDashboardRequiredHistoryDays(selectedDays: 365, configuredDays: 30) == 365) + #expect(spendDashboardRequiredHistoryDays(selectedDays: 7, configuredDays: 30) == 30) + #expect(spendDashboardRequiredHistoryDays(selectedDays: 30, configuredDays: 365) == 365) + + let suite = "SpendDashboardControllerTests-history-override" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + defaults.set(30, forKey: "tokenCostUsageHistoryDays") + let settings = testSettingsStore(suiteName: suite) + + settings.setSpendDashboardHistoryDaysOverride(365) + #expect(settings.costUsageHistoryDays == 30) + #expect(settings.effectiveCostUsageHistoryDays == 365) + #expect(defaults.integer(forKey: "tokenCostUsageHistoryDays") == 30) + + settings.setSpendDashboardHistoryDaysOverride(nil) + #expect(settings.costUsageHistoryDays == 30) + #expect(settings.effectiveCostUsageHistoryDays == 30) + } + @Test func `empty codex history loads as successful inactive source`() async { let now = Date(timeIntervalSince1970: 1_784_179_200) @@ -24,7 +47,8 @@ struct SpendDashboardControllerTests { unavailableSourceIDs: [], codexRequests: [account], now: now, - force: false) + force: false, + historyDays: 7) let result = await SpendDashboardSource.load(request, codexSnapshotLoader: { context in await recorder.record(context) @@ -48,7 +72,7 @@ struct SpendDashboardControllerTests { #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?.historyDays == 7) #expect(contexts.first?.refreshPricingInBackground == false) #expect(contexts.first?.includePiSessions == false) } @@ -731,28 +755,6 @@ struct SpendDashboardControllerTests { #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 nonisolated static let fixtureNow = Date(timeIntervalSince1970: 1_784_179_200) private static func dashboardController( @@ -880,6 +882,34 @@ struct SpendDashboardControllerTests { } } +extension SpendDashboardControllerTests { + @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(30) + #expect(controller.selectedDays == 30) + controller.selectDays(365) + #expect(controller.selectedDays == 365) + controller.selectDays(9) + #expect(controller.selectedDays == 30) + } +} + @MainActor struct SpendDashboardRequestTimeTests { @Test @@ -903,6 +933,19 @@ struct SpendDashboardRequestTimeTests { #expect(request.now == afterMidnight) } + @Test + func `request carries configured history into provider loads`() async { + let (settings, store) = Self.store(suiteName: "SpendDashboardRequestTimeTests-history") + settings.costUsageHistoryDays = 7 + + let request = await SpendDashboardSource.makeRequest( + settings: settings, + store: store, + mode: .captureOnly) + + #expect(request.historyDays == 7) + } + @Test func `explicit request time remains authoritative after refresh`() async throws { let (settings, store) = Self.store(suiteName: "SpendDashboardRequestTimeTests-explicit") diff --git a/Tests/CodexBarTests/SpendDashboardCoverageTruthTests.swift b/Tests/CodexBarTests/SpendDashboardCoverageTruthTests.swift new file mode 100644 index 0000000000..d6f84b3716 --- /dev/null +++ b/Tests/CodexBarTests/SpendDashboardCoverageTruthTests.swift @@ -0,0 +1,124 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct SpendDashboardCoverageTruthTests { + @Test + func `short non OpenRouter history stays a qualified lower bound in a longer window`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let now = try #require(ISO8601DateFormatter().date(from: "2026-08-07T12:00:00Z")) + let snapshot = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: 20, + last30DaysCostUSD: 2, + currencyCode: "USD", + historyDays: 30, + daily: [ + CostUsageDailyReport.Entry( + date: "2026-08-07", + inputTokens: 10, + outputTokens: 10, + totalTokens: 20, + costUSD: 2, + modelsUsed: ["claude-sonnet-4"], + modelBreakdowns: [ + .init(modelName: "claude-sonnet-4", costUSD: 2, totalTokens: 20), + ]), + ], + updatedAt: now) + let group = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: snapshot)], + requestedDays: 365, + now: now, + calendar: calendar).groups.first) + let row = try #require(group.providers.first) + + #expect(row.coveredDayCount == 30) + #expect(row.totalCost == 2) + #expect(group.totalCost == nil) + #expect(group.knownCost == 2) + #expect(group.totalTokens == nil) + #expect(group.modelHistoryCompleteness == .incomplete) + #expect(spendDashboardAggregateTokenText(group) == "~20") + #expect(spendDashboardProviderCostText( + row, + currencyCode: group.currencyCode, + requestedDays: 365) == "~$2.00") + } + + @Test + func `full requested history keeps provider and aggregate amounts exact`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let now = try #require(ISO8601DateFormatter().date(from: "2026-08-07T12:00:00Z")) + let snapshot = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: 20, + last30DaysCostUSD: 2, + currencyCode: "USD", + historyDays: 30, + daily: [ + CostUsageDailyReport.Entry( + date: "2026-08-07", + inputTokens: 10, + outputTokens: 10, + totalTokens: 20, + costUSD: 2, + modelsUsed: ["claude-sonnet-4"], + modelBreakdowns: [ + .init(modelName: "claude-sonnet-4", costUSD: 2, totalTokens: 20), + ]), + ], + updatedAt: now) + let group = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: snapshot)], + requestedDays: 30, + now: now, + calendar: calendar).groups.first) + let row = try #require(group.providers.first) + + #expect(group.totalCost == 2) + #expect(group.totalTokens == 20) + #expect(spendDashboardAggregateTokenText(group) == "20") + #expect(spendDashboardProviderCostText( + row, + currencyCode: group.currencyCode, + requestedDays: 30) == "$2.00") + } + + @Test + func `aggregate token text qualifies safe mixed coverage and rejects unknown or overflow`() { + let date = Date(timeIntervalSince1970: 1_785_974_400) + func group(tokens: [Int?]) -> SpendDashboardModel.CurrencyGroup { + let providers = tokens.enumerated().map { index, tokens in + SpendDashboardModel.ProviderRow( + id: "provider-\(index)", + rank: index + 1, + provider: index == 0 ? .codex : .openrouter, + displayName: index == 0 ? "Codex" : "OpenRouter", + totalTokens: tokens, + totalCost: nil, + coveredDayCount: index == 0 ? 365 : 30) + } + return SpendDashboardModel.CurrencyGroup( + currencyCode: "USD", + providers: providers, + models: [], + dailyPoints: [], + totalTokens: nil, + totalCost: nil, + coveredDayCount: 30, + chartDomain: date...date, + modelHistoryCompleteness: .incomplete) + } + + #expect(spendDashboardAggregateTokenText(group(tokens: [4_000_000, 6_000_000])) == "~10M") + #expect(spendDashboardAggregateTokenText(group(tokens: [4_000_000, nil])) == "~4M") + #expect(spendDashboardAggregateTokenText(group(tokens: [nil, nil])) == "—") + #expect(spendDashboardAggregateTokenText(group(tokens: [.max, .max])) == "—") + } +} diff --git a/Tests/CodexBarTests/SpendDashboardDateTruthTests.swift b/Tests/CodexBarTests/SpendDashboardDateTruthTests.swift index ea29bd0f58..886bc622bd 100644 --- a/Tests/CodexBarTests/SpendDashboardDateTruthTests.swift +++ b/Tests/CodexBarTests/SpendDashboardDateTruthTests.swift @@ -30,7 +30,7 @@ struct SpendDashboardDateTruthTests { updatedAt: now) let group = try #require(SpendDashboardModel.build( inputs: [.init(provider: .mistral, displayName: "Mistral", snapshot: snapshot)], - requestedDays: 7, + requestedDays: 2, now: now, calendar: pacific).groups.first) @@ -146,8 +146,10 @@ struct SpendDashboardDateTruthTests { #expect(snapshot.updatedAt == july15) #expect(group.coveredDayCount == 2) - #expect(group.totalCost == 3) - #expect(group.totalTokens == 30) + #expect(group.totalCost == nil) + #expect(group.totalTokens == nil) + #expect(group.knownCost == 3) + #expect(group.providers.first?.totalTokens == 30) #expect(group.dailyPoints.map(\.day) == [july14, july15]) #expect(group.dailyPoints.map(\.cost) == [1, 2]) } @@ -332,9 +334,11 @@ struct SpendDashboardDateTruthTests { let eur = try #require(groups.first(where: { $0.currencyCode == "EUR" })) let usd = try #require(groups.first(where: { $0.currencyCode == "USD" })) - #expect(usd.totalCost == 7) + let coversRequestedHorizon = omission.historyDays >= 7 + #expect(usd.totalCost == (coversRequestedHorizon ? 7 : nil)) + #expect(usd.knownCost == 7) #expect(usd.totalTokens == nil) - #expect(usd.modelHistoryCompleteness == .complete) + #expect(usd.modelHistoryCompleteness == (coversRequestedHorizon ? .complete : .incomplete)) #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) @@ -344,7 +348,7 @@ struct SpendDashboardDateTruthTests { aggregateTotal: usd.totalCost).content == .chart) #expect(cad.totalCost == nil) - #expect(cad.totalTokens == 30) + #expect(cad.totalTokens == (coversRequestedHorizon ? 30 : nil)) #expect(cad.modelHistoryCompleteness == .incomplete) #expect(cad.models.map(\.provider) == [.mistral]) #expect(cad.models.map(\.totalCost) == [5]) @@ -505,7 +509,7 @@ struct SpendDashboardDateTruthTests { historyDays: 2) let group = try #require(SpendDashboardModel.build( inputs: [.init(provider: .claude, displayName: "Claude", snapshot: snapshot)], - requestedDays: 7, + requestedDays: 2, now: Self.now, calendar: Self.calendar).groups.first) @@ -591,7 +595,7 @@ struct SpendDashboardDateTruthTests { historyDays: 1) let group = try #require(SpendDashboardModel.build( inputs: [.init(provider: .claude, displayName: "Claude", snapshot: snapshot)], - requestedDays: 7, + requestedDays: 1, now: Self.now, calendar: Self.calendar).groups.first) @@ -664,7 +668,7 @@ struct SpendDashboardDateTruthTests { #expect(snapshot.last30DaysTokens == 0) let group = try #require(SpendDashboardModel.build( inputs: [.init(provider: .mistral, displayName: "Mistral", snapshot: snapshot)], - requestedDays: 7, + requestedDays: 1, now: Self.now, calendar: Self.calendar).groups.first) @@ -726,7 +730,7 @@ struct SpendDashboardDateTruthTests { establishesCoverage: true)) let groups = SpendDashboardModel.build( inputs: [costOnly, completeUSD, tokenOnly], - requestedDays: 7, + requestedDays: 1, now: Self.now, calendar: Self.calendar).groups let eur = try #require(groups.first(where: { $0.currencyCode == "EUR" })) diff --git a/Tests/CodexBarTests/SpendDashboardIntegratedModelCoverageTests.swift b/Tests/CodexBarTests/SpendDashboardIntegratedModelCoverageTests.swift new file mode 100644 index 0000000000..5ac3c11abc --- /dev/null +++ b/Tests/CodexBarTests/SpendDashboardIntegratedModelCoverageTests.swift @@ -0,0 +1,139 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct SpendDashboardIntegratedModelCoverageTests { + @Test + func `dashboard aggregation keeps every model from every cost capable integrated provider`() throws { + let providers = ProviderDescriptorRegistry.all + .filter(\.tokenCost.supportsTokenCost) + .map(\.id) + let inputs = providers.enumerated().map { index, provider in + Self.input(provider: provider, costOffset: Double(index)) + } + + let group = try #require(SpendDashboardModel.build( + inputs: inputs, + requestedDays: 365, + now: Self.now, + calendar: Self.calendar).groups.first) + let expectedIDs = Set(providers.flatMap { provider in + [ + "\(provider.rawValue):fixture-shared-model", + "\(provider.rawValue):fixture-\(provider.rawValue)-primary", + ] + }) + + #expect(Set(providers) == [ + .bedrock, + .claude, + .codex, + .cursor, + .mistral, + .openai, + .opencodego, + .vertexai, + ]) + #expect(Set(group.models.map(\.id)) == expectedIDs) + #expect(group.models.count == providers.count * 2) + #expect(group.models.allSatisfy { $0.totalTokens != nil && $0.totalCost != nil }) + #expect(group.modelHistoryCompleteness == .complete) + } + + @Test + func `OpenAI adapter keeps token only model rows without inventing model cost`() throws { + let firstModel = OpenAIAPIUsageSnapshot.ModelBreakdown( + name: "fixture-openai-primary", + requests: 1, + inputTokens: 6, + cachedInputTokens: 0, + outputTokens: 4, + totalTokens: 10) + let secondModel = OpenAIAPIUsageSnapshot.ModelBreakdown( + name: "fixture-openai-secondary", + requests: 1, + inputTokens: 12, + cachedInputTokens: 0, + outputTokens: 8, + totalTokens: 20) + let usage = OpenAIAPIUsageSnapshot( + daily: [ + OpenAIAPIUsageSnapshot.DailyBucket( + day: "2026-08-05", + startTime: Self.now, + endTime: Self.now.addingTimeInterval(86400), + costUSD: 3, + requests: 2, + inputTokens: 18, + cachedInputTokens: 0, + outputTokens: 12, + totalTokens: 30, + lineItems: [], + models: [firstModel, secondModel]), + ], + updatedAt: Self.now, + historyDays: 365) + let input = SpendDashboardModel.ProviderInput( + provider: .openai, + displayName: "OpenAI", + snapshot: usage.toCostUsageTokenSnapshot()) + + let group = try #require(SpendDashboardModel.build( + inputs: [input], + requestedDays: 365, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(Set(group.models.map(\.modelName)) == [firstModel.name, secondModel.name]) + #expect(Set(group.models.compactMap(\.totalTokens)) == [10, 20]) + #expect(group.models.allSatisfy { $0.totalCost == nil }) + #expect(group.modelHistoryCompleteness == .incomplete) + } + + private static func input( + provider: UsageProvider, + costOffset: Double) -> SpendDashboardModel.ProviderInput + { + let primaryCost = costOffset + 1 + let sharedCost = costOffset + 2 + let breakdowns = [ + CostUsageDailyReport.ModelBreakdown( + modelName: "fixture-\(provider.rawValue)-primary", + costUSD: primaryCost, + totalTokens: 10), + CostUsageDailyReport.ModelBreakdown( + modelName: "fixture-shared-model", + costUSD: sharedCost, + totalTokens: 20), + ] + let entry = CostUsageDailyReport.Entry( + date: "2026-08-04", + inputTokens: nil, + outputTokens: nil, + totalTokens: 30, + costUSD: primaryCost + sharedCost, + modelsUsed: breakdowns.map(\.modelName), + modelBreakdowns: breakdowns) + let snapshot = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: 30, + last30DaysCostUSD: primaryCost + sharedCost, + currencyCode: "USD", + historyDays: 365, + daily: [entry], + updatedAt: Self.now) + return SpendDashboardModel.ProviderInput( + provider: provider, + displayName: "Fixture \(provider.rawValue)", + snapshot: snapshot) + } + + private static let now = Date(timeIntervalSince1970: 1_785_888_000) // 2026-08-05 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/SpendDashboardModelTests.swift b/Tests/CodexBarTests/SpendDashboardModelTests.swift index 929239016e..57ba66550b 100644 --- a/Tests/CodexBarTests/SpendDashboardModelTests.swift +++ b/Tests/CodexBarTests/SpendDashboardModelTests.swift @@ -4,27 +4,6 @@ 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 @@ -68,7 +47,16 @@ struct SpendDashboardModelTests { let providers = Set(ProviderDescriptorRegistry.all .filter(\.tokenCost.supportsTokenCost) .map(\.id)) - #expect(providers == [.codex, .claude, .vertexai, .openai, .mistral, .bedrock, .cursor, .opencodego]) + #expect(providers == [ + .codex, + .claude, + .vertexai, + .openai, + .mistral, + .bedrock, + .cursor, + .opencodego, + ]) } @Test @@ -207,7 +195,8 @@ struct SpendDashboardModelTests { #expect(group.coveredDayCount == 0) #expect(group.providers.allSatisfy { $0.coveredDayCount == 7 }) - #expect(group.totalCost == 5) + #expect(group.totalCost == nil) + #expect(group.knownCost == 5) #expect(group.providers.map(\.id) == ["later", "earlier"]) #expect(group.dailyPoints.map(\.sourceID) == ["earlier", "later"]) } @@ -239,7 +228,8 @@ struct SpendDashboardModelTests { #expect(group.coveredDayCount == 3) #expect(group.providers.allSatisfy { $0.coveredDayCount == 7 }) - #expect(group.totalCost == 5) + #expect(group.totalCost == nil) + #expect(group.knownCost == 5) } @Test @@ -261,12 +251,16 @@ struct SpendDashboardModelTests { calendar: Self.calendar).groups.first) #expect(group.totalCost == nil) + #expect(group.knownCost == 4) + #expect(group.knownCostProviderCount == 1) #expect(group.totalTokens == nil) #expect(group.modelHistoryCompleteness == .incomplete) #expect(group.models.map(\.provider) == [.claude]) #expect(group.models.map(\.modelName) == ["test-model"]) #expect(group.models.map(\.totalCost) == [4]) #expect(spendDashboardModelHistoryPresentation(group) == .partial) + #expect(spendDashboardAggregateCostText(group) == "~$4.00") + #expect(spendDashboardCostCoverageText(group) == "1 / 2 Accounts") } @Test @@ -395,6 +389,9 @@ struct SpendDashboardModelTests { #expect(group.providers.first(where: { $0.id == "invalid" })?.totalCost == nil) #expect(group.totalCost == nil) + #expect(group.knownCost == nil) + #expect(group.knownCostProviderCount == 2) + #expect(spendDashboardAggregateCostText(group) == "Spend unavailable") #expect(group.totalTokens == nil) #expect(group.dailyPoints.isEmpty) } @@ -753,7 +750,6 @@ struct SpendDashboardModelTests { #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", @@ -981,6 +977,28 @@ extension SpendDashboardModelTests { #expect(group.models.isEmpty) } + @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(spendDashboardDayRangeText(365) == "۳۶۵ روز") + #expect(spendDashboardRankText(1234) == "#۱٬۲۳۴") + #expect(spendDashboardRefreshFailureText(2) == "\(L("Refresh failures")): ۲") + #expect(spendDashboardCoverageText(covered: 3, requested: 30) == "پوشش: ۳ / ۳۰") + } + } + @Test func `full 30 day coverage keeps unpriced spend unavailable instead of zero`() throws { let snapshot = Self.snapshot( diff --git a/Tests/CodexBarTests/SpendDashboardTokenActivityIntegrationTests.swift b/Tests/CodexBarTests/SpendDashboardTokenActivityIntegrationTests.swift index c86e948408..a4360f113f 100644 --- a/Tests/CodexBarTests/SpendDashboardTokenActivityIntegrationTests.swift +++ b/Tests/CodexBarTests/SpendDashboardTokenActivityIntegrationTests.swift @@ -139,7 +139,7 @@ struct SpendDashboardTokenActivityIntegrationTests { request, cacheRootResolver: { _ in cacheRoot }, codexSnapshotLoader: { context in - #expect(context.historyDays == SpendDashboardSource.scanDays) + #expect(context.historyDays == request.historyDays) #expect(context.cacheRoot == cacheRoot) return snapshot }) diff --git a/Tests/CodexBarTests/SpendDashboardTokenProvenanceTests.swift b/Tests/CodexBarTests/SpendDashboardTokenProvenanceTests.swift index c454d6ab1e..0a73478fbd 100644 --- a/Tests/CodexBarTests/SpendDashboardTokenProvenanceTests.swift +++ b/Tests/CodexBarTests/SpendDashboardTokenProvenanceTests.swift @@ -127,12 +127,14 @@ struct SpendDashboardTokenProvenanceTests { let controller = Self.dashboardController(settings: settings, store: store, now: now) controller.update(configuration: SpendDashboardSource.configuration(settings: settings, store: store)) await Self.waitUntil { !controller.isRefreshing } - #expect(controller.model.groups.first?.totalCost == 3) + #expect(controller.model.groups.first?.totalCost == nil) + #expect(controller.model.groups.first?.knownCost == 3) controller.refresh() await Self.waitUntil { !controller.isRefreshing } - #expect(controller.model.groups.first?.totalCost == 3) + #expect(controller.model.groups.first?.totalCost == nil) + #expect(controller.model.groups.first?.knownCost == 3) #expect(controller.failedSourceCount == 1) #expect(store.tokenSnapshotPublicationRevision(for: .mistral) == baselineRevision) } @@ -149,7 +151,8 @@ struct SpendDashboardTokenProvenanceTests { let controller = Self.dashboardController(settings: settings, store: store, now: now) controller.update(configuration: SpendDashboardSource.configuration(settings: settings, store: store)) await Self.waitUntil { !controller.isRefreshing } - #expect(controller.model.groups.first?.totalCost == 4) + #expect(controller.model.groups.first?.totalCost == nil) + #expect(controller.model.groups.first?.knownCost == 4) controller.refresh() await Self.waitUntil { !controller.isRefreshing } diff --git a/Tests/CodexBarTests/SpendDashboardTrackedSourceTests.swift b/Tests/CodexBarTests/SpendDashboardTrackedSourceTests.swift new file mode 100644 index 0000000000..df64ed0cda --- /dev/null +++ b/Tests/CodexBarTests/SpendDashboardTrackedSourceTests.swift @@ -0,0 +1,427 @@ +import AppKit +import CodexBarCore +import SwiftUI +import Testing +@testable import CodexBar + +struct SpendDashboardTrackedSourceTests { + @Test + @MainActor + func `tracked access keeps enabled providers visible across connection states`() throws { + let settings = testSettingsStore(suiteName: "SpendDashboardTrackedSourceTests-enabled") + let metadata = ProviderRegistry.shared.metadata + for provider in UsageProvider.allCases { + try settings.setProviderEnabled( + provider: provider, + metadata: #require(metadata[provider]), + enabled: [.cursor, .gemini, .grok, .openrouter].contains(provider)) + } + settings.addTokenAccount(provider: .cursor, label: "Work", token: "fixture") + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 10, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date()), + provider: .grok) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 20, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date()), + provider: .gemini) + store._setErrorForTesting("Not logged in", provider: .gemini) + + let sources = SpendDashboardSource.trackedSources(settings: settings, store: store) + let rows = Dictionary(uniqueKeysWithValues: sources.map { ($0.provider, $0) }) + + #expect(Set(rows.keys).isSuperset(of: [.cursor, .gemini, .grok, .openrouter])) + #expect(rows[.grok]?.state == .connected) + #expect(rows[.gemini]?.state == .needsAttention) + #expect(rows[.openrouter]?.state == .awaitingUsage) + #expect(rows[.openrouter]?.supportsCostHistory == false) + #expect(Set(rows.values.filter(\.contributesCostHistory).map(\.provider)) == [.cursor]) + } + + @Test + @MainActor + func `automatic provider fetch with dormant saved account emits one ambient current source`() throws { + let settings = testSettingsStore(suiteName: "SpendDashboardTrackedSourceTests-ambient-account") + let metadata = try #require(ProviderRegistry.shared.metadata[.cursor]) + try settings.setProviderEnabled(provider: .cursor, metadata: metadata, enabled: true) + settings.addTokenAccount(provider: .cursor, label: "Dormant manual account", token: "fixture") + let account = try #require(settings.selectedTokenAccount(for: .cursor)) + settings.cursorCookieSource = .auto + #expect(settings.effectiveSelectedTokenAccount(for: .cursor) == nil) + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + let sources = SpendDashboardSource.trackedSources(settings: settings, store: store) + .filter { $0.provider == .cursor } + let accountID = "cursor:account:\(account.id.uuidString.lowercased())" + + #expect(Set(sources.map(\.id)) == [accountID, "cursor:current"]) + #expect(sources.first { $0.id == accountID }?.contributesCostHistory == false) + #expect(sources.first { $0.id == "cursor:current" }?.contributesCostHistory == true) + + let date = Date(timeIntervalSince1970: 1_785_974_400) + let model = SpendDashboardModel(requestedDays: 30, groups: [ + SpendDashboardModel.CurrencyGroup( + currencyCode: "USD", + providers: [ + SpendDashboardModel.ProviderRow( + id: "cursor", + rank: 1, + provider: .cursor, + displayName: "Cursor", + totalTokens: 900, + totalCost: 9, + coveredDayCount: 30), + ], + models: [], + dailyPoints: [], + totalTokens: 900, + totalCost: 9, + coveredDayCount: 30, + chartDomain: date...date, + modelHistoryCompleteness: .complete), + ]) + let payload = try #require(SpendDashboardPane.makeSharePayload( + model: model, + subscriptionNames: [:], + trackedSources: sources)) + + #expect(payload.providers.first?.provider == .cursor) + #expect(payload.providers.first?.estimatedCost == 9) + #expect(payload.providers.first?.totalTokens == 900) + #expect(payload.currencies.first?.estimatedCost == 9) + #expect(payload.currencies.first?.isPartial == false) + #expect(payload.totalTokens == 900) + #expect(payload.totalTokensIsPartial == false) + } + + @Test + @MainActor + func `active saved account does not duplicate provider with an ambient current source`() throws { + let settings = testSettingsStore(suiteName: "SpendDashboardTrackedSourceTests-active-account") + let metadata = try #require(ProviderRegistry.shared.metadata[.cursor]) + try settings.setProviderEnabled(provider: .cursor, metadata: metadata, enabled: true) + settings.addTokenAccount(provider: .cursor, label: "Active manual account", token: "fixture") + let account = try #require(settings.effectiveSelectedTokenAccount(for: .cursor)) + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + let sources = SpendDashboardSource.trackedSources(settings: settings, store: store) + .filter { $0.provider == .cursor } + + #expect(sources.count == 1) + #expect(sources.first?.id == "cursor:account:\(account.id.uuidString.lowercased())") + #expect(sources.first?.contributesCostHistory == true) + #expect(!sources.contains { $0.id == "cursor:current" }) + } + + @Test + @MainActor + func `quota usage does not claim cost history is connected`() throws { + let settings = testSettingsStore(suiteName: "SpendDashboardTrackedSourceTests-quota-only") + let metadata = try #require(ProviderRegistry.shared.metadata[.openrouter]) + try settings.setProviderEnabled(provider: .openrouter, metadata: metadata, enabled: true) + settings[providerConfig: .openrouter, field: .apiKey] = "fixture-key" + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 25, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date()), + provider: .openrouter) + + let source = try #require(SpendDashboardSource.trackedSources(settings: settings, store: store).first { + $0.provider == .openrouter + }) + + #expect(spendDashboardTrackedSourceStatusText(source) == "Usage connected · not in cost total") + + store._setTokenSnapshotForTesting(CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: nil, + last30DaysCostUSD: nil, + historyCoverageIsEstablished: false, + daily: [], + updatedAt: Date()), provider: .openrouter) + let sourceWithUnestablishedCost = try #require(SpendDashboardSource.trackedSources( + settings: settings, + store: store).first { $0.provider == .openrouter }) + #expect(spendDashboardTrackedSourceStatusText(sourceWithUnestablishedCost) == + "Usage connected · not in cost total") + + store._setTokenSnapshotForTesting(CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: 100, + last30DaysCostUSD: 1, + daily: [], + updatedAt: Date()), provider: .openrouter) + let sourceWithCost = try #require(SpendDashboardSource.trackedSources( + settings: settings, + store: store).first { $0.provider == .openrouter }) + #expect(spendDashboardTrackedSourceStatusText(sourceWithCost) == "Usage connected · not in cost total") + } + + @Test + func `inactive Codex account with loaded spend presents cost history as connected`() throws { + let source = SpendDashboardTrackedSource( + id: "codex:work", + provider: .codex, + providerName: "Codex", + accountName: "Work", + state: .configured, + supportsCostHistory: true, + contributesCostHistory: true) + let date = Date(timeIntervalSince1970: 1_785_974_400) + let model = SpendDashboardModel(requestedDays: 30, groups: [ + SpendDashboardModel.CurrencyGroup( + currencyCode: "USD", + providers: [ + SpendDashboardModel.ProviderRow( + id: "codex:work", + rank: 1, + provider: .codex, + displayName: "Codex · Work", + totalTokens: 100, + totalCost: 2, + coveredDayCount: 30), + ], + models: [], + dailyPoints: [], + totalTokens: 100, + totalCost: 2, + coveredDayCount: 30, + chartDomain: date...date, + modelHistoryCompleteness: .complete), + ]) + + let presented = try #require(spendDashboardTrackedSourcesForPresentation( + [source], + model: model).first) + #expect(presented.costHistoryAvailable) + #expect(spendDashboardTrackedSourceStatusText(presented) == "Cost history connected") + } + + @Test + @MainActor + func `tracked access includes every saved provider credential without inventing cost coverage`() { + let settings = testSettingsStore(suiteName: "SpendDashboardTrackedSourceTests-credentials") + let supportedProviders = UsageProvider.allCases.filter { + TokenAccountSupportCatalog.support(for: $0) != nil + } + for provider in supportedProviders { + settings.addTokenAccount(provider: provider, label: "\(provider.rawValue) account", token: "fixture") + } + settings.addTokenAccount(provider: .openrouter, label: "second account", token: "fixture-2") + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + let sources = SpendDashboardSource.trackedSources(settings: settings, store: store) + let credentialSources = sources.filter { $0.id.contains(":account:") } + + #expect(Set(credentialSources.map(\.provider)) == Set(supportedProviders)) + #expect(credentialSources.count == supportedProviders.count + 1) + #expect(Set(credentialSources.map(\.id)).count == credentialSources.count) + + let openRouterSources = credentialSources.filter { $0.provider == .openrouter } + #expect(openRouterSources.count == 2) + #expect(openRouterSources.allSatisfy { $0.state == .configured }) + #expect(openRouterSources.allSatisfy { !$0.supportsCostHistory }) + #expect(openRouterSources.allSatisfy { !$0.contributesCostHistory }) + } + + @Test + func `tracked access copy distinguishes missing cost history from zero spend`() { + let source = SpendDashboardTrackedSource( + id: "openrouter:account:test", + provider: .openrouter, + providerName: "OpenRouter", + accountName: "Work", + state: .connected, + supportsCostHistory: false, + contributesCostHistory: false) + + #expect(spendDashboardTrackedSourceStatusText(source) == "Usage connected · not in cost total") + + let attention = SpendDashboardTrackedSource( + id: "gemini:current", + provider: .gemini, + providerName: "Gemini", + accountName: nil, + state: .needsAttention, + supportsCostHistory: false, + contributesCostHistory: false) + #expect(spendDashboardTrackedSourceStatusText(attention) == "Unavailable") + + let setup = SpendDashboardTrackedSource( + id: "openrouter:current", + provider: .openrouter, + providerName: "OpenRouter", + accountName: nil, + state: .awaitingUsage, + supportsCostHistory: false, + contributesCostHistory: false) + #expect(spendDashboardTrackedSourceStatusText(setup) == "No usage yet") + } + + @Test + @MainActor + func `tracked access renders without clipping at wide and narrow settings widths`() throws { + let proofDirectory = ProcessInfo.processInfo.environment["CODEXBAR_SPEND_DASHBOARD_PROOF_DIR"].map { + URL(fileURLWithPath: $0, isDirectory: true) + } + if let proofDirectory { + try FileManager.default.createDirectory( + at: proofDirectory, + withIntermediateDirectories: true) + } + + for (size, filename) in [ + (CGSize(width: 760, height: 440), "spend-dashboard-tracked-access-wide.png"), + (CGSize(width: 430, height: 720), "spend-dashboard-tracked-access-narrow.png"), + ] { + let view = VStack(alignment: .leading, spacing: 18) { + SpendDashboardHeader( + selectedDays: 365, + isRefreshing: false, + isCostTrackingEnabled: true, + selectDays: { _ in }, + refresh: {}) + SpendTrackedAccessPanel( + sources: Self.proofSources, + description: "Every configured subscription or key stays visible. " + + "Only compatible sources enter cost totals.") + } + .padding(24) + .frame(width: size.width, height: size.height, alignment: .topLeading) + .background(Color(nsColor: .windowBackgroundColor)) + + let data = try #require(Self.pngData(for: view, size: size)) + let bitmap = try #require(NSBitmapImageRep(data: data)) + #expect(bitmap.pixelsWide == Int(size.width)) + #expect(bitmap.pixelsHigh == Int(size.height)) + if let proofDirectory { + try data.write(to: proofDirectory.appendingPathComponent(filename), options: .atomic) + } + } + } + + private static let proofSources = [ + SpendDashboardTrackedSource( + id: "codex:account:personal", + provider: .codex, + providerName: "Codex", + accountName: "Personal", + state: .connected, + supportsCostHistory: true, + contributesCostHistory: true, + costHistoryAvailable: true), + SpendDashboardTrackedSource( + id: "claude:account:team", + provider: .claude, + providerName: "Claude", + accountName: "Team", + state: .connected, + supportsCostHistory: true, + contributesCostHistory: true, + costHistoryAvailable: true), + SpendDashboardTrackedSource( + id: "openrouter:account:research", + provider: .openrouter, + providerName: "OpenRouter", + accountName: "Research", + state: .awaitingUsage, + supportsCostHistory: false, + contributesCostHistory: false), + SpendDashboardTrackedSource( + id: "cursor:account:work", + provider: .cursor, + providerName: "Cursor", + accountName: "Work", + state: .configured, + supportsCostHistory: true, + contributesCostHistory: false), + SpendDashboardTrackedSource( + id: "gemini:account:studio", + provider: .gemini, + providerName: "Gemini", + accountName: "Studio", + state: .needsAttention, + supportsCostHistory: false, + contributesCostHistory: false), + SpendDashboardTrackedSource( + id: "mistral:account:api", + provider: .mistral, + providerName: "Mistral", + accountName: "API", + state: .configured, + supportsCostHistory: true, + contributesCostHistory: false), + ] + + @MainActor + private static func pngData(for rootView: some View, size: CGSize) -> Data? { + let view = NSHostingView(rootView: rootView) + view.frame = CGRect(origin: .zero, size: size) + view.layoutSubtreeIfNeeded() + + guard let representation = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: Int(size.width), + pixelsHigh: Int(size.height), + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0) + else { return nil } + representation.size = size + guard let context = NSGraphicsContext(bitmapImageRep: representation) else { return nil } + view.displayIgnoringOpacity(view.bounds, in: context) + return representation.representation(using: .png, properties: [:]) + } +} diff --git a/Tests/CodexBarTests/StatusMenuOverviewScrollTests.swift b/Tests/CodexBarTests/StatusMenuOverviewScrollTests.swift index 2a4b7b2fde..24f1130780 100644 --- a/Tests/CodexBarTests/StatusMenuOverviewScrollTests.swift +++ b/Tests/CodexBarTests/StatusMenuOverviewScrollTests.swift @@ -4,6 +4,13 @@ import Foundation import Testing @testable import CodexBar +extension StatusMenuTests { + func expectCondensedOverview(menu: NSMenu, ids: [String], rows: [String]) { + #expect(ids.contains("overviewSpendSummary")) + #expect(menu.items.count(where: \.isSeparatorItem) < rows.count + 1) + } +} + @MainActor struct StatusMenuOverviewScrollTests { private func makeController(suiteName: String) -> StatusItemController { @@ -28,17 +35,34 @@ struct StatusMenuOverviewScrollTests { statusBar: .system) } - private func makeOverviewMenu() -> NSMenu { + private func makeOverviewMenu(count: Int = 2) -> NSMenu { let menu = NSMenu() - for provider in ["claude", "codex"] { + for index in 0.. NSEvent? { guard let cgEvent = CGEvent( scrollWheelEvent2Source: nil, diff --git a/Tests/CodexBarTests/StatusMenuOverviewSubmenuTests.swift b/Tests/CodexBarTests/StatusMenuOverviewSubmenuTests.swift index 5c1b7af07d..e282c54bee 100644 --- a/Tests/CodexBarTests/StatusMenuOverviewSubmenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuOverviewSubmenuTests.swift @@ -4,6 +4,63 @@ import Testing @testable import CodexBar extension StatusMenuTests { + @Test + func `overview spend window uses current time instead of cached snapshot time`() throws { + let settings = self.makeSettings() + settings.costUsageHistoryDays = 7 + + let fetcher = UsageFetcher() + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let cachedAt = try #require(Calendar.current.date(from: DateComponents( + year: 2025, + month: 1, + day: 7, + hour: 12))) + store._setTokenSnapshotForTesting(CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: 1000, + last30DaysCostUSD: 9, + historyDays: 7, + daily: [ + CostUsageDailyReport.Entry( + date: "2025-01-07", + inputTokens: 600, + outputTokens: 400, + totalTokens: 1000, + costUSD: 9, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: cachedAt), provider: .claude) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let currentTime = try #require(Calendar.current.date(from: DateComponents( + year: 2025, + month: 2, + day: 7, + hour: 12))) + let model = controller.overviewSpendDashboardModel(providers: [.claude], now: currentTime) + let group = try #require(model.groups.first) + + #expect(group.providers.first?.totalCost == nil) + #expect(group.dailyPoints.isEmpty) + #expect(Calendar.current.isDate( + group.chartDomain.upperBound, + inSameDayAs: currentTime.addingTimeInterval(86400))) + } + @Test func `overview rows expose provider detail submenus`() throws { self.disableMenuCardsForTesting() diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index a324d5414c..11340ea193 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -1573,7 +1573,7 @@ extension StatusMenuTests { let ids = self.representedIDs(in: menu) let overviewRows = ids.filter { $0.hasPrefix("overviewRow-") } #expect(Set(overviewRows) == Set(enabledProviders.map { "overviewRow-\($0.rawValue)" })) - #expect(menu.items.count(where: \.isSeparatorItem) == overviewRows.count + 1) + self.expectCondensedOverview(menu: menu, ids: ids, rows: overviewRows) } @Test diff --git a/Tests/CodexBarTests/UsageMenuCardLayoutTests.swift b/Tests/CodexBarTests/UsageMenuCardLayoutTests.swift index 2f93b5f225..d1071a657b 100644 --- a/Tests/CodexBarTests/UsageMenuCardLayoutTests.swift +++ b/Tests/CodexBarTests/UsageMenuCardLayoutTests.swift @@ -31,7 +31,7 @@ struct UsageMenuCardLayoutTests { } @Test - func `full provider card matches overview height`() { + func `overview uses a fixed compact card instead of the full provider detail height`() { let model = Self.model(metrics: [ UsageMenuCardView.Model.Metric( id: "session", @@ -44,24 +44,241 @@ struct UsageMenuCardLayoutTests { detailRightText: "Lasts until reset", pacePercent: nil, paceOnTop: true), + UsageMenuCardView.Model.Metric( + id: "weekly", + title: "Weekly", + percent: 52, + percentStyle: .left, + resetText: "Resets Friday", + detailText: nil, + detailLeftText: nil, + detailRightText: nil, + pacePercent: nil, + paceOnTop: true), ]) let width: CGFloat = 296 let fullCardSize = NSHostingController(rootView: UsageMenuCardView(model: model, width: width)) .sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) - let overviewStyleSize = NSHostingController(rootView: UsageMenuCardHeaderAndUsageSectionView( + let overviewSize = NSHostingController(rootView: OverviewMenuCardRowView( model: model, - layoutModel: model, - bottomPadding: UsageMenuCardLayout.sectionBottomPadding, + storageText: nil, + width: width)) + .sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) + let emptyOverviewSize = NSHostingController(rootView: OverviewMenuCardRowView( + model: Self.model(), + storageText: nil, width: width)) .sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) - #expect(UsageMenuCardLayout.postHeaderDividerContentSpacing == 16) - #expect(UsageMenuCardLayout.headerOnlyVerticalPadding == 6) - #expect(UsageMenuCardLayout.sectionTopPadding == 6) - #expect(UsageMenuCardLayout.sectionBottomPadding == 6) + #expect(overviewSize.height == OverviewMenuCardRowView.rowHeight) + #expect(emptyOverviewSize.height == OverviewMenuCardRowView.rowHeight) + #expect(overviewSize.height < fullCardSize.height) + #expect(OverviewMenuCardRowView.primaryMetric(for: model)?.id == "session") + } + + @Test + func `overview keeps one prominent provider and compresses the remaining rows`() { + let model = Self.model(metrics: [ + UsageMenuCardView.Model.Metric( + id: "session", + title: "Session", + percent: 37, + percentStyle: .left, + resetText: "Resets in 41m", + detailText: nil, + detailLeftText: nil, + detailRightText: nil, + pacePercent: nil, + paceOnTop: true), + ]) + let width: CGFloat = 296 + + let prominent = NSHostingController(rootView: OverviewMenuCardRowView( + model: model, + storageText: nil, + width: width, + emphasis: .prominent)) + .sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) + let compact = NSHostingController(rootView: OverviewMenuCardRowView( + model: model, + storageText: nil, + width: width, + emphasis: .compact)) + .sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) + + #expect(prominent.height == OverviewMenuCardRowView.rowHeight) + #expect(compact.height == OverviewMenuCardRowView.compactRowHeight) + #expect(compact.height < prominent.height) + #expect(OverviewMenuCardRowView.compactSpendText(for: model) == "Spend unavailable") + + let accessibilityCompact = NSHostingController(rootView: OverviewMenuCardRowView( + model: model, + storageText: nil, + width: width, + emphasis: .compact) + .environment(\.dynamicTypeSize, .accessibility2)) + .sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) + #expect(accessibilityCompact.height >= OverviewMenuCardRowView.accessibilityRowHeight) + } + + @Test + func `overview spend summary keeps partial totals honest across connected services`() { + let group = SpendDashboardModel.CurrencyGroup( + currencyCode: "USD", + providers: [ + .init( + id: "codex", + rank: 1, + provider: .codex, + displayName: "Codex", + totalTokens: 4_820_000, + totalCost: 412.64, + coveredDayCount: 30), + .init( + id: "claude", + rank: 2, + provider: .claude, + displayName: "Claude", + totalTokens: nil, + totalCost: nil, + coveredDayCount: 8), + .init( + id: "openrouter", + rank: 3, + provider: .openrouter, + displayName: "OpenRouter", + totalTokens: 9_640_000, + totalCost: 282.74, + coveredDayCount: 30), + .init( + id: "gemini", + rank: 4, + provider: .gemini, + displayName: "Gemini", + totalTokens: nil, + totalCost: nil, + coveredDayCount: 0), + .init( + id: "grok", + rank: 5, + provider: .grok, + displayName: "Grok", + totalTokens: nil, + totalCost: nil, + coveredDayCount: 0), + .init( + id: "cursor", + rank: 6, + provider: .cursor, + displayName: "Cursor", + totalTokens: 1_250_000, + totalCost: 64.18, + coveredDayCount: 30), + ], + models: [], + dailyPoints: [], + totalTokens: nil, + totalCost: nil, + coveredDayCount: 30, + chartDomain: Date(timeIntervalSince1970: 1_783_036_800)...Date(timeIntervalSince1970: 1_785_628_800), + modelHistoryCompleteness: .incomplete) + let summary = OverviewSpendSummary( + model: SpendDashboardModel(requestedDays: 30, groups: [group]), + connectedProviderCount: 6) + + #expect(summary.primarySpendText == "~$759.56") + #expect(summary.coverageText == "3 / 6 Providers") + #expect(summary.tokenText == "~15.7M tokens") + #expect(summary.isPartial) + } + + @Test + func `overview token total reflects requested range coverage`() { + func model(coveredDayCount: Int) -> SpendDashboardModel { + let providers = [ + SpendDashboardModel.ProviderRow( + id: "codex", + rank: 1, + provider: .codex, + displayName: "Codex", + totalTokens: 4_000_000, + totalCost: 40, + coveredDayCount: coveredDayCount), + SpendDashboardModel.ProviderRow( + id: "openrouter", + rank: 2, + provider: .openrouter, + displayName: "OpenRouter", + totalTokens: 6_000_000, + totalCost: 60, + coveredDayCount: coveredDayCount), + ] + let group = SpendDashboardModel.CurrencyGroup( + currencyCode: "USD", + providers: providers, + models: [], + dailyPoints: [], + totalTokens: coveredDayCount >= 365 ? 10_000_000 : nil, + totalCost: coveredDayCount >= 365 ? 100 : nil, + coveredDayCount: coveredDayCount, + chartDomain: Date(timeIntervalSince1970: 1_783_036_800)...Date(timeIntervalSince1970: 1_814_572_800), + modelHistoryCompleteness: coveredDayCount >= 365 ? .complete : .incomplete) + return SpendDashboardModel(requestedDays: 365, groups: [group]) + } + + let shortCoverage = OverviewSpendSummary( + model: model(coveredDayCount: 30), + connectedProviderCount: 2) + let fullCoverage = OverviewSpendSummary( + model: model(coveredDayCount: 365), + connectedProviderCount: 2) + + #expect(shortCoverage.tokenText == "~10M tokens") + #expect(fullCoverage.tokenText == "10M tokens") + } + + @Test + func `overview prioritizes refresh status and expands for accessibility text`() { + let metric = UsageMenuCardView.Model.Metric( + id: "weekly", + title: "Weekly", + percent: 52, + percentStyle: .left, + resetText: "Resets Friday", + detailText: nil, + detailLeftText: nil, + detailRightText: nil, + pacePercent: nil, + paceOnTop: true) + var configuredErrorModel = Self.model( + metrics: [metric], + subtitleText: "Could not refresh usage", + subtitleStyle: .error) + configuredErrorModel.usesLiveSubtitle = true + let errorModel = configuredErrorModel + let width: CGFloat = 296 + let monitor = MenuCardRefreshMonitor( + resolveModel: { _ in errorModel }, + isProviderRefreshActive: { _ in true }) + monitor.beginManualRefresh(frozenModels: [.codex: errorModel]) + let refreshSubtitle = OverviewMenuCardRowView.liveSubtitle( + for: errorModel, + refreshMonitor: monitor) + + let accessibilitySize = NSHostingController(rootView: OverviewMenuCardRowView( + model: errorModel, + storageText: nil, + width: width) + .environment(\.dynamicTypeSize, .accessibility5)) + .sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) - #expect(abs(fullCardSize.height - overviewStyleSize.height) < Self.heightTolerance) + #expect(refreshSubtitle.text == "Refreshing…") + #expect(refreshSubtitle.style == .loading) + #expect(OverviewMenuCardRowView.prioritizesStatus(for: refreshSubtitle.style)) + #expect(OverviewMenuCardRowView.primaryMetric(for: errorModel)?.id == "weekly") + #expect(accessibilitySize.height >= OverviewMenuCardRowView.accessibilityRowHeight) + #expect(accessibilitySize.height > OverviewMenuCardRowView.rowHeight) } @Test @@ -247,14 +464,16 @@ struct UsageMenuCardLayoutTests { metrics: [UsageMenuCardView.Model.Metric] = [], usageNotes: [String] = [], creditsText: String? = nil, - placeholder: String? = nil) -> UsageMenuCardView.Model + placeholder: String? = nil, + subtitleText: String = "Not fetched yet", + subtitleStyle: UsageMenuCardView.Model.SubtitleStyle = .info) -> UsageMenuCardView.Model { UsageMenuCardView.Model( provider: .codex, providerName: "Codex", email: "steipete@gmail.com", - subtitleText: "Not fetched yet", - subtitleStyle: .info, + subtitleText: subtitleText, + subtitleStyle: subtitleStyle, planText: "Pro 20x", metrics: metrics, usageNotes: usageNotes, diff --git a/Tests/CodexBarTests/UsageStoreCoverageTests.swift b/Tests/CodexBarTests/UsageStoreCoverageTests.swift index d70d01bc40..13a09fdd06 100644 --- a/Tests/CodexBarTests/UsageStoreCoverageTests.swift +++ b/Tests/CodexBarTests/UsageStoreCoverageTests.swift @@ -176,6 +176,32 @@ struct UsageStoreCoverageTests { fetchedCredentialScopeFingerprint: fingerprint)) } + @Test + func `dashboard history override keeps sparse provider coverage current when it closes`() throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-dashboard-history") + settings.costUsageEnabled = true + settings.costUsageHistoryDays = 30 + settings.setSpendDashboardHistoryDaysOverride(365) + let metadata = try #require(ProviderRegistry.shared.metadata[.claude]) + settings.setProviderEnabled(provider: .claude, metadata: metadata, enabled: true) + let store = Self.makeUsageStore(settings: settings) + let snapshot = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: 10, + last30DaysCostUSD: 1, + historyDays: 1, + daily: [], + updatedAt: Date()) + + store.publishTokenSnapshot(snapshot, for: .claude) + settings.setSpendDashboardHistoryDaysOverride(nil) + + #expect(settings.costUsageHistoryDays == 30) + #expect(store.tokenSnapshotForCurrentProviderConfig(for: .claude)?.snapshot == snapshot) + #expect(store.tokenSnapshotForCurrentProviderConfig(for: .claude)?.snapshot.historyDays == 1) + } + @Test func `source label adds open AI web`() { let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-source") diff --git a/Tests/CodexBarTests/UsageStoreSpendDashboardCodexCostCatchUpTests.swift b/Tests/CodexBarTests/UsageStoreSpendDashboardCodexCostCatchUpTests.swift index ca63d14f21..7efb5f0f21 100644 --- a/Tests/CodexBarTests/UsageStoreSpendDashboardCodexCostCatchUpTests.swift +++ b/Tests/CodexBarTests/UsageStoreSpendDashboardCodexCostCatchUpTests.swift @@ -13,6 +13,8 @@ struct UsageStoreSpendDashboardCodexCostCatchUpTests { Self.account(id: "first", cacheIdentity: "cache-first"), Self.account(id: "second", cacheIdentity: "cache-second"), ] + store.settings.setSpendDashboardHistoryDaysOverride(365) + let expectedHistoryDays = store.settings.effectiveCostUsageHistoryDays let baselineConfiguration = SpendDashboardSource.configuration(settings: store.settings, store: store) var completedCacheIdentities: Set = [] var statusAccounts: [String] = [] @@ -50,7 +52,7 @@ struct UsageStoreSpendDashboardCodexCostCatchUpTests { let replacementConfiguration = SpendDashboardSource.configuration(settings: store.settings, store: store) #expect(statusAccounts == ["first", "second"]) #expect(advancedAccounts == ["first", "second"]) - #expect(receivedHistoryDays == [SpendDashboardSource.scanDays, SpendDashboardSource.scanDays]) + #expect(receivedHistoryDays == [expectedHistoryDays, expectedHistoryDays]) #expect(store.spendDashboardCodexCostCatchUpRevision == 1) #expect(baselineConfiguration.sourceRevisions != replacementConfiguration.sourceRevisions) #expect(store.spendDashboardCodexCostCatchUpActivity?.phase == .complete) @@ -68,12 +70,12 @@ struct UsageStoreSpendDashboardCodexCostCatchUpTests { } @Test(arguments: [1, 7, 29]) - func `dashboard catch-up retains its thirty day floor`(historyDays: Int) async throws { + func `dashboard catch-up honors short configured history windows`(historyDays: Int) async throws { let receivedHistoryDays = try await Self.receivedHistoryDays( configuredHistoryDays: historyDays, suite: "floor-\(historyDays)") - #expect(receivedHistoryDays == SpendDashboardSource.scanDays) + #expect(receivedHistoryDays == historyDays) } @Test @@ -254,6 +256,22 @@ struct UsageStoreSpendDashboardCodexCostCatchUpTests { store.cancelSpendDashboardCodexCostCatchUp() } + @Test + func `changing the dashboard history window restarts catch-up with a new identity`() throws { + let store = try Self.makeStore(suite: "history-window-identity") + let accounts = [Self.account(id: "account", cacheIdentity: "cache-account")] + store.settings.setSpendDashboardHistoryDaysOverride(30) + + store.startSpendDashboardCodexCostCatchUpIfNeeded(accounts: accounts) + let thirtyDayToken = try #require(store.spendDashboardCodexCostCatchUpToken) + store.settings.setSpendDashboardHistoryDaysOverride(365) + store.startSpendDashboardCodexCostCatchUpIfNeeded(accounts: accounts) + + #expect(store.spendDashboardCodexCostCatchUpToken != nil) + #expect(store.spendDashboardCodexCostCatchUpToken != thirtyDayToken) + store.cancelSpendDashboardCodexCostCatchUp() + } + @Test func `stopping an active pass clears a queued restart`() throws { let store = try Self.makeStore(suite: "stop-clears-restart") diff --git a/docs/codex.md b/docs/codex.md index 9f7fe1f286..e800258d20 100644 --- a/docs/codex.md +++ b/docs/codex.md @@ -173,9 +173,10 @@ Example: ### 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. +Settings → Usage & Spend performs a separate scan at the selected 7-, 30-, or 365-day window 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 and OMP sessions because their history is machine-local rather than owned by one Codex account. The normal Codex cost menu and CLI scan continue to include supported pi-compatible history. The diff --git a/docs/plugin-conversion-matrix.md b/docs/plugin-conversion-matrix.md index b3f336e6b1..84e5d00003 100644 --- a/docs/plugin-conversion-matrix.md +++ b/docs/plugin-conversion-matrix.md @@ -73,7 +73,7 @@ weakening the plugin network policy. | ollama | `needs-cookie-import` | No | Skipped: hosted parity requires HTML bootstrap/state extraction plus API-key fallback arbitration. | | synthetic | `cut-over` | Yes | Cut over on both engines: fixed-origin bearer GET with generic windows, cost, dates, and identity; the native fetch twin is deleted. | | warp | `needs-pty/webview/native` | No | Warp sends a POST GraphQL operation, which the GET-only HTTP broker cannot express. | -| openrouter | `cut-over` | Yes | Cut over on JavaScriptCore: endpoint and client-header overrides plus one-second best-effort key enrichment match native behavior; the native fetch core is Linux-only. | +| openrouter | `cut-over` | Yes | Cut over on both engines: endpoint and client-header overrides plus best-effort key enrichment are plugin-owned; the native fetch core is deleted. | | elevenlabs | `convertible-now` | No | Verified `xi-api-key` GET; heterogeneous character/minute quotas map to named generic windows. | | windsurf | `needs-files/subprocess/oauth-broker` | No | Chromium localStorage, IDE databases, and binary protobuf decoding supply the current session. | | zed | `needs-files/subprocess/oauth-broker` | No | Zed server settings and a named Keychain credential must be read locally. | diff --git a/docs/plugin-prototype.md b/docs/plugin-prototype.md index 94ad252da0..9093142747 100644 --- a/docs/plugin-prototype.md +++ b/docs/plugin-prototype.md @@ -231,7 +231,8 @@ context, but it cannot interrupt the abandoned JavaScriptCore thread, which may ## Current limitations The remaining bundled-conversion flag is macOS-only and compiled out when JavaScriptCore is unavailable. It supports bundled -first-party IDs and the generic snapshot and declarative details only: no provider-specific Swift payloads, +first-party IDs, the generic snapshot, declarative details, and explicitly allowlisted behavioral payload projections: +no arbitrary provider-specific Swift payloads, OAuth/refresh broker, local files or databases, subprocesses, arbitrary/form POST bodies, PTY, WebView, binary/protobuf responses, private-network HTTP, or unvalidated dynamic origins. The separate user-plugin path adds local `.js`/`.ts` discovery, approval, and settings without changing these @@ -243,4 +244,4 @@ first-party flag semantics. Browser cookies remain restricted to declared domain Display-only provider payloads now use `details` on both the Swift and JavaScript paths. The remaining bespoke `UsageSnapshot` fields drive behavior rather than presentation: Codex reset-credit actions, Command Code refresh stabilization, DeepSeek profile selection/transition state, and provider-derived token-cost pipelines for OpenAI API, -Mistral, and OpenCode Go. They are not plugin compatibility shims. +Mistral and OpenCode Go. They are not plugin compatibility shims. diff --git a/docs/providers.md b/docs/providers.md index e4d663a99f..20eaf0e2da 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -21,9 +21,11 @@ headers, source selection, provider ordering, and token accounts are stored in ` ## 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. +Settings → Usage & Spend combines local 7-, 30-, or 365-day estimated history for enabled descriptors that advertise +token-cost support: Codex, Claude, Vertex AI, OpenAI, Mistral, AWS Bedrock, Cursor, and OpenCode Go. Its tracked-access +section separately lists every saved subscription/key plus live authenticated provider sources. Sources without a +cost-history contract stay visible there and are explicitly excluded from cost totals instead of appearing as zero +spend. 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 diff --git a/docs/screenshots/spend-dashboard-proof/README.md b/docs/screenshots/spend-dashboard-proof/README.md new file mode 100644 index 0000000000..74de562949 --- /dev/null +++ b/docs/screenshots/spend-dashboard-proof/README.md @@ -0,0 +1,31 @@ +# Spend dashboard proof + +The wide and narrow settings PNGs are rendered by the production +`SpendDashboardHeader` and `SpendTrackedAccessPanel` SwiftUI components. The +Overview capture uses the production `StatusItemController`, +`MenuRowContainerView`, and 310-point menu width. The share capture opens the +production `ShareStatsPresenter` from that Overview. + +All provider names, account labels, spend, and token values are synthetic. No +real account data, keys, or usage values are included. + +Regenerate the settings PNGs with a full Xcode toolchain: + +```sh +CODEXBAR_SPEND_DASHBOARD_PROOF_DIR="$PWD/docs/screenshots/spend-dashboard-proof" \ + swift test --filter SpendDashboardTrackedSourceTests +``` + +The narrow render verifies that the range controls wrap below the title and the +tracked-source grid collapses to one column. The wide render uses two columns. +Both states keep cost-history inclusion and exclusion explicit. + +`overview-all-providers.png` and `share-all-providers.png` are local synthetic +runtime captures rather than deterministic golden outputs, so they follow the +Mac's active appearance. They show the same six-provider roster: three sources +with known spend and three sources whose spend is explicitly unavailable. The +Overview is captured at the production 310-point menu width, keeps Codex +prominent, and uses compact rows for the remaining providers. The share capture +uses the production `ShareStatsWindowController`, preserves all six connected +sources, and labels partial spend with its reporting denominator. Its values and +model labels are synthetic UI fixtures rather than claims about live provider coverage. diff --git a/docs/screenshots/spend-dashboard-proof/overview-all-providers.png b/docs/screenshots/spend-dashboard-proof/overview-all-providers.png new file mode 100644 index 0000000000..59152df96a Binary files /dev/null and b/docs/screenshots/spend-dashboard-proof/overview-all-providers.png differ diff --git a/docs/screenshots/spend-dashboard-proof/share-all-providers.png b/docs/screenshots/spend-dashboard-proof/share-all-providers.png new file mode 100644 index 0000000000..39e735fa6d Binary files /dev/null and b/docs/screenshots/spend-dashboard-proof/share-all-providers.png differ diff --git a/docs/screenshots/spend-dashboard-proof/spend-dashboard-tracked-access-narrow.png b/docs/screenshots/spend-dashboard-proof/spend-dashboard-tracked-access-narrow.png new file mode 100644 index 0000000000..faf07c98a4 Binary files /dev/null and b/docs/screenshots/spend-dashboard-proof/spend-dashboard-tracked-access-narrow.png differ diff --git a/docs/screenshots/spend-dashboard-proof/spend-dashboard-tracked-access-wide.png b/docs/screenshots/spend-dashboard-proof/spend-dashboard-tracked-access-wide.png new file mode 100644 index 0000000000..6d2cd907d7 Binary files /dev/null and b/docs/screenshots/spend-dashboard-proof/spend-dashboard-tracked-access-wide.png differ