diff --git a/README.md b/README.md index 30000e31fd..9aef14f2d9 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,7 @@ show an incident indicator. - Optional Codex web dashboard enrichments (code review remaining, usage breakdown, credits history). - Inline spend and usage charts for API-backed providers such as OpenAI, Claude Admin API, OpenRouter, LiteLLM, z.ai, MiniMax, Mistral, and AWS Bedrock. - Configurable cost-usage scans for Codex + Claude, plus reused chart UI for supported provider histories. -- A persistent Settings → Usage & Spend view for local 7/30-day estimates, grouped by native currency and limited to providers that expose cost history. +- 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..1fd0b74fee 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,36 @@ 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.state == .connected + ? 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 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 spendDashboardCostCoverageText(_ group: SpendDashboardModel.CurrencyGroup) -> String { + "\(codexBarLocalizedInteger(group.knownCostProviderCount)) / " + + "\(codexBarLocalizedInteger(group.providers.count)) \(L("Accounts"))" +} + enum SpendDashboardModelHistoryPresentation: Equatable { case unavailable case empty @@ -83,6 +118,7 @@ struct SpendDashboardPane: View { self.header self.codexCostCatchUpPanel self.content + self.trackedAccess self.provenance self.shareAction } @@ -91,6 +127,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 +147,7 @@ struct SpendDashboardPane: View { } .onDisappear { self.isVisible = false + self.settings.setSpendDashboardHistoryDaysOverride(nil) self.controller.stop() } .onReceive(NotificationCenter.default.publisher(for: .NSCalendarDayChanged)) { _ in @@ -128,34 +166,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 +355,20 @@ struct SpendDashboardPane: View { } } + @ViewBuilder + private var trackedAccess: some View { + let sources = self.configuration.trackedSources + 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() @@ -388,7 +418,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.state == .connected ? "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.state == .connected ? .green : .orange + } + return .secondary } } @@ -412,15 +621,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) + 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 +659,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) ?? "—") + 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,6 +688,7 @@ private struct SpendSummaryValue: View { .font(.system(.title2, design: .rounded, weight: .semibold)) .monospacedDigit() } + .frame(maxWidth: .infinity, alignment: .leading) } } 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/Providers/OpenRouter/OpenRouterProviderImplementation.swift b/Sources/CodexBar/Providers/OpenRouter/OpenRouterProviderImplementation.swift index b285e6710c..bcf16bca05 100644 --- a/Sources/CodexBar/Providers/OpenRouter/OpenRouterProviderImplementation.swift +++ b/Sources/CodexBar/Providers/OpenRouter/OpenRouterProviderImplementation.swift @@ -39,7 +39,8 @@ struct OpenRouterProviderImplementation: ProviderImplementation { title: "API key", subtitle: "Stored in ~/.codexbar/config.json. " + "Get your key from openrouter.ai/settings/keys and set a key spending limit " - + "there to enable API key quota tracking.", + + "there to enable API key quota tracking. A Management API key also enables " + + "per-model spend and token history for the last 30 completed UTC days.", kind: .secure, placeholder: "sk-or-v1-...", binding: context.providerConfigBinding(.apiKey), diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index fe272aea1f..7f01ced1ad 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"; @@ -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/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index 780a7d0bb2..d7a323f60f 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"; @@ -1293,6 +1294,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 7e7efe37bd..93b9ae33ea 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"; @@ -1291,6 +1292,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 6ea1e5ad6e..70221bda8e 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"; @@ -1272,6 +1273,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 a7330c1b0b..4d0cff8c5d 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"; @@ -1289,6 +1290,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 a767f825e1..be7c35c3ae 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"; @@ -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/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index 4dbb5cc7cb..511bd015ae 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"; @@ -1290,6 +1291,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 187e21a4c0..91f64e0233 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"; @@ -1290,6 +1291,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 30a0cca7e1..d8619de00b 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"; @@ -1294,6 +1295,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 ff1de9051a..806f4d3d66 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"; @@ -1294,6 +1295,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 e3ec7b2206..dcf138a5ea 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 キー"; @@ -1291,6 +1292,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 fd60241cea..d337a3f08c 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 키"; @@ -1258,6 +1259,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 366f9f70e9..1b3efbe03f 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"; @@ -1290,6 +1291,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 5a124cc642..ced1acb192 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"; @@ -1294,6 +1295,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 7d28033576..a102d3934a 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"; @@ -1291,6 +1292,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 1da4644d2f..bbba763225 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-ключ"; @@ -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/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index b4648a6712..f41e655ac8 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"; @@ -1289,6 +1290,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 9a3a190ce9..5aaf47c9fb 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"; @@ -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/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index 0736d5a0dc..fba35f5bae 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ı"; @@ -1292,6 +1293,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 c1a569c71f..eab89e9b7a 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"; @@ -1290,6 +1291,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 1c1541a7bd..a28610ec03 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"; @@ -1291,6 +1292,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 c20fba0ed2..170c5dcf76 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 密钥"; @@ -1269,6 +1270,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 e1b144e945..3ab03c1140 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 金鑰"; @@ -1321,6 +1322,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 687fdc65ae..01ca0d6dbf 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -525,6 +525,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 { diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index b0277ef06f..65e8597a70 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..2a085784f6 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,8 +119,12 @@ 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 { @@ -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) diff --git a/Sources/CodexBar/ShareStatsPayload.swift b/Sources/CodexBar/ShareStatsPayload.swift index 8a8e2945e9..18f039ea11 100644 --- a/Sources/CodexBar/ShareStatsPayload.swift +++ b/Sources/CodexBar/ShareStatsPayload.swift @@ -11,9 +11,16 @@ struct ShareStatsProviderPayload: Sendable, Equatable { let coveredDayCount: Int } +struct ShareStatsProviderRosterEntry: Sendable, Equatable { + let provider: UsageProvider + let providerName: String + let currencyCode: String +} + struct ShareStatsModelPayload: Sendable, Equatable { let provider: UsageProvider let providerName: String + let modelIdentity: String let modelName: String let currencyCode: String let totalTokens: Int? @@ -23,6 +30,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 +82,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 +94,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 +108,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 +157,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 +200,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,9 +304,10 @@ 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( provider: row.provider, @@ -235,6 +319,26 @@ enum ShareStatsBuilder { coveredDayCount: row.coveredDayCount) } } + let providers: [ShareStatsProviderPayload] + if providerRoster.isEmpty { + providers = trackedProviders + } else { + let trackedByProvider = trackedProviders.reduce(into: [UsageProvider: ShareStatsProviderPayload]()) { + if $0[$1.provider] == nil { + $0[$1.provider] = $1 + } + } + providers = providerRoster.map { entry in + trackedByProvider[entry.provider] ?? ShareStatsProviderPayload( + provider: entry.provider, + providerName: entry.providerName, + subscriptionName: nil, + currencyCode: entry.currencyCode, + totalTokens: nil, + estimatedCost: nil, + coveredDayCount: 0) + } + } let sanitizedModels = model.groups.filter { $0.modelHistoryCompleteness == .complete }.flatMap { group in @@ -246,6 +350,7 @@ enum ShareStatsBuilder { return ShareStatsModelPayload( provider: row.provider, providerName: row.providerName, + modelIdentity: modelName.lowercased(), modelName: modelName, currencyCode: group.currencyCode, totalTokens: row.totalTokens, @@ -257,6 +362,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,16 +381,31 @@ 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 trackedProviderKinds = Set(trackedProviders.map(\.provider)) + let rosterHasUntrackedProviders = providerRoster.contains { + !trackedProviderKinds.contains($0.provider) + } let currencies = model.groups.map { ShareStatsCurrencyPayload( currencyCode: $0.currencyCode, - estimatedCost: self.finiteCost($0.totalCost), - coveredDayCount: $0.coveredDayCount) + estimatedCost: self.finiteCost($0.totalCost ?? $0.knownCost), + coveredDayCount: $0.coveredDayCount, + isPartial: rosterHasUntrackedProviders || $0.totalCost == nil) } - let totalTokens = self.combinedTotalTokens(model.groups.map(\.totalTokens)) + let knownTokenValues = trackedProviders.compactMap(\.totalTokens) + let totalTokens = self.safeTokenSum(knownTokenValues) + let totalTokensIsPartial = trackedProviders.contains { $0.totalTokens == nil } || + rosterHasUntrackedProviders || + model.groups.contains { $0.totalTokens == nil } let periodEnd = model.groups.map(\.chartDomain.upperBound).max() ?? Date() let payload = ShareStatsPayload( days: model.requestedDays, @@ -292,7 +413,8 @@ enum ShareStatsBuilder { providers: providers, topModels: topModels, currencies: currencies, - totalTokens: totalTokens) + totalTokens: totalTokens, + totalTokensIsPartial: totalTokensIsPartial) return payload.hasShareableData ? payload : nil } @@ -311,6 +433,16 @@ 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 + } } enum ShareStatsFormatting { @@ -345,10 +477,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) connected 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 +519,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 606b989435..c7e234cab9 100644 --- a/Sources/CodexBar/SpendDashboardController.swift +++ b/Sources/CodexBar/SpendDashboardController.swift @@ -3,12 +3,30 @@ 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 +} + 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 +36,7 @@ struct SpendDashboardConfiguration: Equatable, Sendable { providerIDs: [String], codexAccountIdentities: [String], codexAccountDisplayNames: [String: String] = [:], + trackedSources: [SpendDashboardTrackedSource] = [], sourceOwnershipFingerprints: [String] = [], sourceRevisions: [String] = []) { @@ -26,6 +45,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 +87,7 @@ struct SpendDashboardLoadRequest: Sendable { let codexRequests: [CodexSpendScanRequest] let now: Date let force: Bool + let historyDays: Int init( configuration: SpendDashboardConfiguration, @@ -75,7 +96,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 +106,7 @@ struct SpendDashboardLoadRequest: Sendable { self.codexRequests = codexRequests self.now = now self.force = force + self.historyDays = max(1, min(365, historyDays)) } } @@ -126,9 +149,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 +176,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 +192,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 +200,8 @@ enum SpendDashboardSource { unavailableSourceIDs: [], codexRequests: [], now: now ?? nowProvider(), - force: mode.forcesLoader) + force: mode.forcesLoader, + historyDays: historyDays) } let initialProviders = self.costCapableProviders(store: store) @@ -215,7 +239,8 @@ enum SpendDashboardSource { unavailableSourceIDs: [], codexRequests: [], now: captureNow, - force: mode.forcesLoader) + force: mode.forcesLoader, + historyDays: historyDays) } var inputs: [SpendDashboardModel.ProviderInput] = [] @@ -252,7 +277,8 @@ enum SpendDashboardSource { confirmedEmptySourceIDs: confirmedEmptySourceIDs, codexRequests: codexRequests, now: captureNow, - force: mode.forcesLoader) + force: mode.forcesLoader, + historyDays: historyDays) } static func load(_ request: SpendDashboardLoadRequest) async -> SpendDashboardLoadResult { @@ -309,7 +335,7 @@ enum SpendDashboardSource { cacheRoot: cacheRootResolver(account), now: request.now, force: false, - historyDays: Self.scanDays, + historyDays: request.historyDays, refreshPricingInBackground: false, includePiSessions: false)) guard !Task.isCancelled, @@ -355,7 +381,7 @@ enum SpendDashboardSource { cacheRoot: cacheRoot, now: request.now, force: request.force, - historyDays: Self.scanDays, + historyDays: request.historyDays, refreshPricingInBackground: false, includePiSessions: false)) try Task.checkCancellation() @@ -434,6 +460,124 @@ 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 + 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)) + }) + 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)) + }) + 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))) + } + + 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 @@ -1199,6 +1343,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 1ec6fde68e..a8cac51668 100644 --- a/Sources/CodexBar/SpendDashboardModel.swift +++ b/Sources/CodexBar/SpendDashboardModel.swift @@ -94,6 +94,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 @@ -119,7 +134,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 } @@ -228,7 +243,8 @@ struct SpendDashboardModel: Equatable, Sendable { 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. @@ -581,7 +597,10 @@ 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 = input.provider == .openrouter + ? bucketCalendar.date(byAdding: .day, value: -1, to: snapshotDay) ?? snapshotDay + : 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 @@ -636,9 +655,9 @@ 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. + guard provider == .mistral || provider == .openrouter else { return displayCalendar } + // Mistral and OpenRouter label 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 a0780767b5..28d415b64f 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,7 +655,7 @@ extension StatusItemController { item.action = #selector(self.selectOverviewProvider(_:)) } menu.addItem(item) - if index < rows.count - 1 { + if index == 0, rows.count > 1 { menu.addItem(.separator()) } } diff --git a/Sources/CodexBar/StatusItemController+MenuTypes.swift b/Sources/CodexBar/StatusItemController+MenuTypes.swift index 47ed1a890b..5a6b781b77 100644 --- a/Sources/CodexBar/StatusItemController+MenuTypes.swift +++ b/Sources/CodexBar/StatusItemController+MenuTypes.swift @@ -33,50 +33,318 @@ 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 + 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..7f20a2649a --- /dev/null +++ b/Sources/CodexBar/StatusItemController+OverviewSpend.swift @@ -0,0 +1,23 @@ +import CodexBarCore +import Foundation + +extension StatusItemController { + func overviewSpendDashboardModel(providers: [UsageProvider]) -> 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 + let now = inputs.map(\.snapshot.updatedAt).max() ?? Date() + 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 8d828965d8..9d056043cd 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -702,8 +702,13 @@ extension UsageStore { self.lastKnownResetSnapshots[provider.instanceID] } let profileStable = self.preservingDeepSeekProfileCatalog(in: accountScoped, provider: provider) + let historyStable = provider == .openrouter + ? self.preservingOpenRouterActivityIfCurrent( + profileStable, + previous: self.snapshots[provider.instanceID]) + : profileStable let stabilized = Self.commandCodeSnapshotResolvingDepletionOnEnrichmentFailure( - current: profileStable, + current: historyStable, previous: self.snapshots[provider.instanceID]) let backfilled = stabilized.backfillingResetTimes(from: resetBackfillSource) let warningAccountDiscriminator = Self.warningAccountDiscriminator( diff --git a/Sources/CodexBar/UsageStore+SpendDashboardCodexCostCatchUp.swift b/Sources/CodexBar/UsageStore+SpendDashboardCodexCostCatchUp.swift index 0ee8c911ec..29002813ab 100644 --- a/Sources/CodexBar/UsageStore+SpendDashboardCodexCostCatchUp.swift +++ b/Sources/CodexBar/UsageStore+SpendDashboardCodexCostCatchUp.swift @@ -31,8 +31,9 @@ extension UsageStore { return } - let scopeSignature = accounts - .map { "\($0.id)|\($0.cacheIdentity)" } + let historyDays = self.settings.effectiveCostUsageHistoryDays + let scopeSignature = (["historyDays=\(historyDays)"] + accounts + .map { "\($0.id)|\($0.cacheIdentity)" }) .joined(separator: "\u{0}") if self.spendDashboardCodexCostCatchUpTask != nil, self.spendDashboardCodexCostCatchUpScopeSignature == scopeSignature @@ -51,7 +52,7 @@ extension UsageStore { let context = SpendDashboardCodexCostCatchUpContext( token: token, accounts: accounts, - historyDays: SpendDashboardSource.scanDays, + historyDays: historyDays, scopeSignature: scopeSignature, providerConfigRevision: self.settings.providerConfigRevision(for: .codex), costUsageSettingsRevision: self.settings.costUsageSettingsRevision) @@ -243,6 +244,7 @@ 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 && self.settings.isCostUsageEffectivelyEnabled(for: .codex) diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index 8e6e2a4820..6d8283c17a 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( @@ -1452,7 +1452,12 @@ extension UsageStore { ? labeled.preservingDeepSeekPlatformProfiles( from: self.presentationSnapshot(for: .deepseek)) : labeled - let backfilled = profileStable.backfillingResetTimes( + let historyStable = provider == .openrouter + ? self.preservingOpenRouterActivityIfCurrent( + profileStable, + previous: self.snapshots[provider.instanceID]) + : profileStable + let backfilled = historyStable.backfillingResetTimes( from: self.lastKnownResetSnapshots[provider.instanceID]) let warningAccountDiscriminator = Self.warningTokenAccountDiscriminator(account) self.handleQuotaWarningTransitions( diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index 24cf19d654..b3c8fe453b 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 } @@ -421,15 +434,17 @@ extension UsageStore { switch provider { case .openai: snapshot?.openAIAPIUsage?.toCostUsageTokenSnapshot() + case .openrouter: + snapshot?.openRouterActivityUsage?.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 @@ -438,7 +453,7 @@ extension UsageStore { nonisolated static func tokenCostRequiresProviderSnapshot(_ provider: UsageProvider) -> Bool { switch provider { - case .mistral, .openai, .opencodego: + case .mistral, .openai, .openrouter, .opencodego: true default: false @@ -488,4 +503,15 @@ extension UsageStore { nonisolated static func tokenCostNoDataMessage(for provider: UsageProvider) -> String { ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.noDataMessage() } + + func preservingOpenRouterActivityIfCurrent( + _ snapshot: UsageSnapshot, + previous: UsageSnapshot?) -> UsageSnapshot + { + guard snapshot.openRouterActivityUsage == nil, + self.tokenSnapshotPublicationForCurrentProviderConfig(for: .openrouter)?.snapshot != nil, + let activity = previous?.openRouterActivityUsage + else { return snapshot } + return snapshot.withOpenRouterActivityUsage(activity) + } } diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 945afdbeff..7d4fa1b84d 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1442,7 +1442,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/Plugins/ProviderPluginSnapshotMapper.swift b/Sources/CodexBarCore/Plugins/ProviderPluginSnapshotMapper.swift index c629e0e7ee..b54c9ed5c5 100644 --- a/Sources/CodexBarCore/Plugins/ProviderPluginSnapshotMapper.swift +++ b/Sources/CodexBarCore/Plugins/ProviderPluginSnapshotMapper.swift @@ -15,6 +15,9 @@ enum ProviderPluginSnapshotMapper { let tertiary = try self.window(value, property: "tertiary") let extraRateWindows = try self.extraWindows(value) let providerCost = try self.cost(value, now: now) + let openRouterActivityUsage = provider == .openrouter + ? self.openRouterActivityUsage(value, now: now) + : nil let details = try self.details(value) let identity = try self.identity(value, provider: provider) let subscriptionRenewsAt = try self.optionalDate(value, property: "subscriptionRenewsAt") @@ -35,6 +38,7 @@ enum ProviderPluginSnapshotMapper { extraRateWindows: extraRateWindows, providerCost: providerCost, details: details, + openRouterActivityUsage: openRouterActivityUsage, subscriptionExpiresAt: subscriptionExpiresAt, subscriptionRenewsAt: subscriptionRenewsAt, updatedAt: now, @@ -42,6 +46,23 @@ enum ProviderPluginSnapshotMapper { dataConfidence: dataConfidence) } + /// Activity is an optional management-key enrichment. Malformed or unsupported + /// activity must not invalidate an otherwise healthy provider snapshot. + private static func openRouterActivityUsage( + _ root: JSValue, + now: Date) -> OpenRouterActivityUsageSnapshot? + { + guard let value = root.forProperty("openRouterActivityUsage"), + value.isObject, + !value.isArray, + !value.isNull, + let object = value.toObject(), + JSONSerialization.isValidJSONObject(object), + let data = try? JSONSerialization.data(withJSONObject: object) + else { return nil } + return try? OpenRouterActivityUsageSnapshot(data: data, now: now) + } + private static func dataConfidence(_ root: JSValue) throws -> UsageDataConfidence { guard let value = root.forProperty("dataConfidence"), !value.isUndefined, !value.isNull else { return .unknown diff --git a/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterActivityUsageSnapshot.swift b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterActivityUsageSnapshot.swift new file mode 100644 index 0000000000..27078ee663 --- /dev/null +++ b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterActivityUsageSnapshot.swift @@ -0,0 +1,299 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// OpenRouter's account activity for completed UTC days, aggregated by routed model slug. +public struct OpenRouterActivityUsageSnapshot: Codable, Equatable, Sendable { + public struct ModelBreakdown: Codable, Equatable, Sendable, Identifiable { + public let model: String + public let promptTokens: Int + public let completionTokens: Int + /// Informational only. OpenRouter includes these tokens in `completionTokens`. + public let reasoningTokens: Int + public let requests: Int + public let usageUSD: Double + + public var id: String { + self.model + } + + public var totalTokens: Int { + self.promptTokens + self.completionTokens + } + } + + public struct DailyBucket: Codable, Equatable, Sendable, Identifiable { + public let date: String + public let promptTokens: Int + public let completionTokens: Int + /// Informational only. OpenRouter includes these tokens in `completionTokens`. + public let reasoningTokens: Int + public let requests: Int + public let usageUSD: Double + public let models: [ModelBreakdown] + + public var id: String { + self.date + } + + public var totalTokens: Int { + self.promptTokens + self.completionTokens + } + } + + public let daily: [DailyBucket] + public let historyDays: Int + public let updatedAt: Date + + /// Decodes the documented `/api/v1/activity` envelope and keeps only the requested + /// completed UTC-day window. OpenRouter currently exposes at most 30 completed days. + public init(data: Data, now: Date, historyDays: Int = 30) throws { + let response = try JSONDecoder().decode(Response.self, from: data) + let days = max(1, min(30, historyDays)) + let calendar = Self.utcCalendar + let today = calendar.startOfDay(for: now) + guard let firstDay = calendar.date(byAdding: .day, value: -days, to: today) else { + throw DecodeError.invalidDateWindow + } + + var accumulators: [BucketKey: Accumulator] = [:] + for item in response.data { + guard let date = Self.date(from: item.date) else { throw DecodeError.invalidActivityItem } + guard date >= firstDay, date < today else { continue } + let model = item.model.trimmingCharacters(in: .whitespacesAndNewlines) + guard !model.isEmpty, + item.promptTokens >= 0, + item.completionTokens >= 0, + item.reasoningTokens >= 0, + item.requests >= 0, + item.usage.isFinite, + item.usage >= 0 + else { + throw DecodeError.invalidActivityItem + } + let key = BucketKey(date: item.date, model: model) + try accumulators[key, default: Accumulator()].add(item) + } + + let modelBuckets = Dictionary(grouping: accumulators, by: { $0.key.date }) + let daily = try modelBuckets.map { date, values in + let models = try values.map { key, accumulator in + try accumulator.modelBreakdown(model: key.model) + }.sorted(by: Self.modelSort) + let promptTokens = try Self.sum(models.map(\.promptTokens)) + let completionTokens = try Self.sum(models.map(\.completionTokens)) + _ = try Self.add(promptTokens, completionTokens) + return try DailyBucket( + date: date, + promptTokens: promptTokens, + completionTokens: completionTokens, + reasoningTokens: Self.sum(models.map(\.reasoningTokens)), + requests: Self.sum(models.map(\.requests)), + usageUSD: Self.sum(models.map(\.usageUSD)), + models: models) + }.sorted { $0.date < $1.date } + _ = try Self.sum(daily.map(\.totalTokens)) + _ = try Self.sum(daily.map(\.requests)) + _ = try Self.sum(daily.map(\.usageUSD)) + self.daily = daily + self.historyDays = days + self.updatedAt = now + } + + public func toCostUsageTokenSnapshot() -> CostUsageTokenSnapshot { + let daily = self.daily.map { bucket in + CostUsageDailyReport.Entry( + date: bucket.date, + inputTokens: bucket.promptTokens, + outputTokens: bucket.completionTokens, + totalTokens: bucket.totalTokens, + requestCount: bucket.requests, + costUSD: bucket.usageUSD, + modelsUsed: bucket.models.map(\.model), + modelBreakdowns: bucket.models.map { model in + CostUsageDailyReport.ModelBreakdown( + modelName: model.model, + costUSD: model.usageUSD, + totalTokens: model.totalTokens, + requestCount: model.requests) + }) + } + return CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + sessionRequests: nil, + last30DaysTokens: self.daily.reduce(0) { $0 + $1.totalTokens }, + last30DaysCostUSD: self.daily.reduce(0) { $0 + $1.usageUSD }, + last30DaysRequests: self.daily.reduce(0) { $0 + $1.requests }, + currencyCode: "USD", + historyDays: self.historyDays, + historyCoverageIsEstablished: true, + historyLabel: "Last \(self.historyDays) completed UTC days", + daily: daily, + updatedAt: self.updatedAt) + } + + private struct Response: Decodable { + let data: [Item] + } + + private struct Item: Decodable { + let date: String + let model: String + let promptTokens: Int + let completionTokens: Int + let reasoningTokens: Int + let requests: Int + let usage: Double + + private enum CodingKeys: String, CodingKey { + case date + case model + case promptTokens = "prompt_tokens" + case completionTokens = "completion_tokens" + case reasoningTokens = "reasoning_tokens" + case requests + case usage + } + } + + private struct BucketKey: Hashable { + let date: String + let model: String + } + + private struct Accumulator { + var promptTokens = 0 + var completionTokens = 0 + var reasoningTokens = 0 + var requests = 0 + var usageUSD = 0.0 + + mutating func add(_ item: Item) throws { + self.promptTokens = try OpenRouterActivityUsageSnapshot.add(self.promptTokens, item.promptTokens) + self.completionTokens = try OpenRouterActivityUsageSnapshot.add( + self.completionTokens, + item.completionTokens) + self.reasoningTokens = try OpenRouterActivityUsageSnapshot.add( + self.reasoningTokens, + item.reasoningTokens) + self.requests = try OpenRouterActivityUsageSnapshot.add(self.requests, item.requests) + self.usageUSD += item.usage + guard self.usageUSD.isFinite else { throw DecodeError.numericOverflow } + } + + func modelBreakdown(model: String) throws -> ModelBreakdown { + _ = try OpenRouterActivityUsageSnapshot.add(self.promptTokens, self.completionTokens) + return ModelBreakdown( + model: model, + promptTokens: self.promptTokens, + completionTokens: self.completionTokens, + reasoningTokens: self.reasoningTokens, + requests: self.requests, + usageUSD: self.usageUSD) + } + } + + private enum DecodeError: Error { + case invalidDateWindow + case invalidActivityItem + case numericOverflow + } + + private static let utcCalendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.locale = Locale(identifier: "en_US_POSIX") + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + }() + + private static func date(from key: String) -> Date? { + guard key.range(of: #"^\d{4}-\d{2}-\d{2}$"#, options: .regularExpression) != nil else { return nil } + let parts = key.split(separator: "-").compactMap { Int($0) } + guard parts.count == 3, + let date = self.utcCalendar.date(from: DateComponents( + timeZone: self.utcCalendar.timeZone, + year: parts[0], + month: parts[1], + day: parts[2])), + self.dateKey(date) == key + else { return nil } + return date + } + + private static func dateKey(_ date: Date) -> String { + let components = self.utcCalendar.dateComponents([.year, .month, .day], from: date) + return String(format: "%04d-%02d-%02d", components.year ?? 0, components.month ?? 0, components.day ?? 0) + } + + private static func add(_ lhs: Int, _ rhs: Int) throws -> Int { + let result = lhs.addingReportingOverflow(rhs) + guard !result.overflow else { throw DecodeError.numericOverflow } + return result.partialValue + } + + private static func sum(_ values: [Int]) throws -> Int { + try values.reduce(0) { try self.add($0, $1) } + } + + private static func sum(_ values: [Double]) throws -> Double { + let total = values.reduce(0, +) + guard total.isFinite else { throw DecodeError.numericOverflow } + return total + } + + private static func modelSort(_ lhs: ModelBreakdown, _ rhs: ModelBreakdown) -> Bool { + if lhs.usageUSD != rhs.usageUSD { return lhs.usageUSD > rhs.usageUSD } + if lhs.totalTokens != rhs.totalTokens { return lhs.totalTokens > rhs.totalTokens } + return lhs.model.localizedStandardCompare(rhs.model) == .orderedAscending + } +} + +/// Optional `/activity` enrichment. Any endpoint, transport, or decoding failure leaves the +/// already-valid credits/key snapshot intact; explicit task cancellation still propagates. +public enum OpenRouterActivityUsageFetcher { + private static let timeoutSeconds: TimeInterval = 5 + + public static func fetchOptional( + apiKey: String, + baseURL: URL, + transport: any ProviderHTTPTransport, + now: Date = Date(), + historyDays: Int = 30) async throws -> OpenRouterActivityUsageSnapshot? + { + guard !apiKey.isEmpty else { return nil } + let sourceTask = Task { + var request = URLRequest(url: baseURL.appendingPathComponent("activity")) + request.httpMethod = "GET" + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.timeoutInterval = Self.timeoutSeconds + do { + let response = try await transport.response(for: request) + guard response.statusCode == 200 else { return nil } + return try OpenRouterActivityUsageSnapshot( + data: response.data, + now: now, + historyDays: historyDays) + } catch is CancellationError { + throw CancellationError() + } catch { + return nil + } + } + let race = BoundedTaskJoin(sourceTask: sourceTask) + switch await race.value(joinGrace: .seconds(Self.timeoutSeconds)) { + case let .value(snapshot): + try Task.checkCancellation() + return snapshot + case .timedOut: + try Task.checkCancellation() + return nil + case .failure: + sourceTask.cancel() + try Task.checkCancellation() + return nil + } + } +} diff --git a/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterProviderDescriptor.swift b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterProviderDescriptor.swift index c274026f62..f92225e282 100644 --- a/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterProviderDescriptor.swift @@ -63,8 +63,10 @@ public enum OpenRouterProviderDescriptor { ], widgetColor: ProviderColor(red: 111 / 255, green: 66 / 255, blue: 193 / 255)), tokenCost: ProviderTokenCostConfig( - supportsTokenCost: false, - noDataMessage: { "OpenRouter cost summary is not yet supported." }), + supportsTokenCost: true, + noDataMessage: { "OpenRouter activity history requires a Management API key." }, + menuHintLines: [.literal("Reported by OpenRouter for completed UTC days.")], + primaryValue: .latestDaily), presentation: ProviderUsagePresentation( menuCard: ProviderMenuCardPresentation( showsCreditsSection: false, diff --git a/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift index 3ea5602433..b2e712c190 100644 --- a/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift +++ b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift @@ -104,6 +104,7 @@ public struct OpenRouterUsageSnapshot: Codable, Sendable { public let keyUsageWeekly: Double? public let keyUsageMonthly: Double? public let rateLimit: OpenRouterRateLimit? + public let activityUsage: OpenRouterActivityUsageSnapshot? public let updatedAt: Date public init( @@ -120,6 +121,7 @@ public struct OpenRouterUsageSnapshot: Codable, Sendable { keyUsageWeekly: Double? = nil, keyUsageMonthly: Double? = nil, rateLimit: OpenRouterRateLimit?, + activityUsage: OpenRouterActivityUsageSnapshot? = nil, updatedAt: Date) { self.totalCredits = totalCredits @@ -136,6 +138,7 @@ public struct OpenRouterUsageSnapshot: Codable, Sendable { self.keyUsageWeekly = keyUsageWeekly self.keyUsageMonthly = keyUsageMonthly self.rateLimit = rateLimit + self.activityUsage = activityUsage self.updatedAt = updatedAt } @@ -144,6 +147,10 @@ public struct OpenRouterUsageSnapshot: Codable, Sendable { self.totalCredits >= 0 } + public func toCostUsageTokenSnapshot() -> CostUsageTokenSnapshot? { + self.activityUsage?.toCostUsageTokenSnapshot() + } + public var hasValidKeyQuota: Bool { guard self.keyDataFetched, let keyLimit else { return false @@ -297,6 +304,7 @@ extension OpenRouterUsageSnapshot { tertiary: nil, providerCost: nil, details: details, + openRouterActivityUsage: self.activityUsage, updatedAt: self.updatedAt, identity: identity) } @@ -359,6 +367,12 @@ public struct OpenRouterUsageFetcher: Sendable { baseURL: baseURL, timeoutSeconds: Self.rateLimitTimeoutSeconds, transport: transport) + let now = Date() + let activityUsage = try await OpenRouterActivityUsageFetcher.fetchOptional( + apiKey: apiKey, + baseURL: baseURL, + transport: transport, + now: now) return OpenRouterUsageSnapshot( totalCredits: creditsResponse.data.totalCredits, @@ -374,7 +388,8 @@ public struct OpenRouterUsageFetcher: Sendable { keyUsageWeekly: keyFetch.data?.usageWeekly, keyUsageMonthly: keyFetch.data?.usageMonthly, rateLimit: keyFetch.data?.rateLimit, - updatedAt: Date()) + activityUsage: activityUsage, + updatedAt: now) } catch is CancellationError { throw CancellationError() } catch let error as DecodingError { diff --git a/Sources/CodexBarCore/Resources/Plugins/openrouter.js b/Sources/CodexBarCore/Resources/Plugins/openrouter.js index f72029f8e5..55604f3b75 100644 --- a/Sources/CodexBarCore/Resources/Plugins/openrouter.js +++ b/Sources/CodexBarCore/Resources/Plugins/openrouter.js @@ -68,6 +68,29 @@ defineProvider({ } } catch (_) {} + // /activity is management-key-only and optional. Credits and key quota remain valid + // when analytics is forbidden, unsupported by an endpoint override, slow, or malformed. + let openRouterActivityUsage = null; + try { + const activityResponse = await ctx.http.get(`${base}/activity`, { timeoutSeconds: 5 }); + if (activityResponse.status === 200) { + const activityPayload = JSON.parse(activityResponse.bodyText); + if (activityPayload && Array.isArray(activityPayload.data)) { + openRouterActivityUsage = { + data: activityPayload.data.map(item => ({ + date: item.date, + model: item.model, + prompt_tokens: item.prompt_tokens, + completion_tokens: item.completion_tokens, + reasoning_tokens: item.reasoning_tokens, + requests: item.requests, + usage: item.usage, + })), + }; + } + } + } catch (_) {} + function resetWindowUsage(reset) { const windowKey = reset === "daily" ? "usage_daily" : @@ -163,6 +186,7 @@ defineProvider({ details, }; if (primary) result.primary = primary; + if (openRouterActivityUsage) result.openRouterActivityUsage = openRouterActivityUsage; return result; }, }); diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index 389470e7bd..7a9f088c9d 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -151,6 +151,7 @@ public struct UsageSnapshot: Codable, Sendable { public let deepseekPlatformProfiles: [DeepSeekPlatformProfile] public let opencodegoUsage: OpenCodeGoUsageSnapshot? public let openAIAPIUsage: OpenAIAPIUsageSnapshot? + public let openRouterActivityUsage: OpenRouterActivityUsageSnapshot? public let codexResetCredits: CodexRateLimitResetCreditsSnapshot? public let mistralUsage: MistralUsageSnapshot? /// Live-only marker for optional Command Code subscription lookup failure. @@ -173,6 +174,7 @@ public struct UsageSnapshot: Codable, Sendable { case providerCost case details case openAIAPIUsage + case openRouterActivityUsage case codexResetCredits case mistralUsage case subscriptionExpiresAt @@ -196,6 +198,7 @@ public struct UsageSnapshot: Codable, Sendable { deepseekPlatformProfiles: [DeepSeekPlatformProfile] = [], opencodegoUsage: OpenCodeGoUsageSnapshot? = nil, openAIAPIUsage: OpenAIAPIUsageSnapshot? = nil, + openRouterActivityUsage: OpenRouterActivityUsageSnapshot? = nil, codexResetCredits: CodexRateLimitResetCreditsSnapshot? = nil, mistralUsage: MistralUsageSnapshot? = nil, commandCodeSubscriptionEnrichmentUnavailable: Bool = false, @@ -220,6 +223,7 @@ public struct UsageSnapshot: Codable, Sendable { self.deepseekPlatformProfiles = deepseekPlatformProfiles self.opencodegoUsage = opencodegoUsage self.openAIAPIUsage = openAIAPIUsage + self.openRouterActivityUsage = openRouterActivityUsage self.codexResetCredits = codexResetCredits self.mistralUsage = mistralUsage self.commandCodeSubscriptionEnrichmentUnavailable = commandCodeSubscriptionEnrichmentUnavailable @@ -269,6 +273,9 @@ public struct UsageSnapshot: Codable, Sendable { self.deepseekPlatformProfiles = [] // Live-only browser profile catalog self.opencodegoUsage = nil // Not persisted, fetched fresh each time self.openAIAPIUsage = try container.decodeIfPresent(OpenAIAPIUsageSnapshot.self, forKey: .openAIAPIUsage) + self.openRouterActivityUsage = try container.decodeIfPresent( + OpenRouterActivityUsageSnapshot.self, + forKey: .openRouterActivityUsage) self.codexResetCredits = try container.decodeIfPresent( CodexRateLimitResetCreditsSnapshot.self, forKey: .codexResetCredits) @@ -314,6 +321,7 @@ public struct UsageSnapshot: Codable, Sendable { try container.encode(self.details, forKey: .details) } try container.encodeIfPresent(self.openAIAPIUsage, forKey: .openAIAPIUsage) + try container.encodeIfPresent(self.openRouterActivityUsage, forKey: .openRouterActivityUsage) try container.encodeIfPresent(self.codexResetCredits, forKey: .codexResetCredits) try container.encodeIfPresent(self.mistralUsage, forKey: .mistralUsage) try container.encodeIfPresent(self.subscriptionExpiresAt, forKey: .subscriptionExpiresAt) @@ -388,6 +396,10 @@ public struct UsageSnapshot: Codable, Sendable { self.replacing(dataConfidence: .value(dataConfidence)) } + public func withOpenRouterActivityUsage(_ activity: OpenRouterActivityUsageSnapshot) -> UsageSnapshot { + self.replacing(openRouterActivityUsage: .value(activity)) + } + public func scoped(to provider: UsageProvider) -> UsageSnapshot { guard let identity else { return self } let scopedIdentity = identity.scoped(to: provider) @@ -467,6 +479,7 @@ public struct UsageSnapshot: Codable, Sendable { details: Replacement<[ProviderDetailSection]> = .unchanged, deepseekDetailedUsageState: Replacement = .unchanged, deepseekPlatformProfiles: Replacement<[DeepSeekPlatformProfile]> = .unchanged, + openRouterActivityUsage: Replacement = .unchanged, codexResetCredits: Replacement = .unchanged, subscriptionExpiresAt: Replacement = .unchanged, subscriptionRenewsAt: Replacement = .unchanged, @@ -484,6 +497,7 @@ public struct UsageSnapshot: Codable, Sendable { deepseekPlatformProfiles: deepseekPlatformProfiles.resolving(self.deepseekPlatformProfiles), opencodegoUsage: self.opencodegoUsage, openAIAPIUsage: self.openAIAPIUsage, + openRouterActivityUsage: openRouterActivityUsage.resolving(self.openRouterActivityUsage), codexResetCredits: codexResetCredits.resolving(self.codexResetCredits), mistralUsage: self.mistralUsage, commandCodeSubscriptionEnrichmentUnavailable: self.commandCodeSubscriptionEnrichmentUnavailable, 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/OpenRouterActivityUsageTests.swift b/Tests/CodexBarTests/OpenRouterActivityUsageTests.swift new file mode 100644 index 0000000000..31003c92ff --- /dev/null +++ b/Tests/CodexBarTests/OpenRouterActivityUsageTests.swift @@ -0,0 +1,303 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +struct OpenRouterActivityUsageTests { + @Test + func `activity rows become thirty day daily and model cost history`() throws { + let activity = try OpenRouterActivityUsageSnapshot( + data: Data(Self.activityFixture.utf8), + now: Self.now, + historyDays: 30) + + let snapshot = activity.toCostUsageTokenSnapshot() + + #expect(snapshot.historyDays == 30) + #expect(snapshot.currencyCode == "USD") + #expect(snapshot.last30DaysTokens == 325) + #expect(abs((snapshot.last30DaysCostUSD ?? -1) - 0.05) < 0.000_000_001) + #expect(snapshot.last30DaysRequests == 10) + #expect(snapshot.daily.map(\.date) == ["2026-08-05", "2026-08-06"]) + + let firstDay = try #require(snapshot.daily.first) + #expect(firstDay.inputTokens == 60) + #expect(firstDay.outputTokens == 145) + // OpenRouter documents reasoning_tokens as a subset of completion_tokens. + // Counting it again would overstate this day by 30 tokens. + #expect(firstDay.totalTokens == 205) + #expect(firstDay.requestCount == 7) + #expect(abs((firstDay.costUSD ?? -1) - 0.02) < 0.000_000_001) + #expect(firstDay.modelsUsed == ["anthropic/claude-sonnet-4-6"]) + + let model = try #require(firstDay.modelBreakdowns?.only) + #expect(model.modelName == "anthropic/claude-sonnet-4-6") + #expect(model.totalTokens == 205) + #expect(model.requestCount == 7) + #expect(abs((model.costUSD ?? -1) - 0.02) < 0.000_000_001) + } + + @Test + func `duplicate routed models aggregate without adding estimated BYOK spend`() throws { + let activity = try OpenRouterActivityUsageSnapshot( + data: Data(Self.activityFixture.utf8), + now: Self.now) + + let snapshot = activity.toCostUsageTokenSnapshot() + let firstDay = try #require(snapshot.daily.first) + let claudeRows = try #require(firstDay.modelBreakdowns?.filter { + $0.modelName == "anthropic/claude-sonnet-4-6" + }) + + #expect(claudeRows.count == 1) + #expect(claudeRows.only?.totalTokens == 205) + #expect(claudeRows.only?.requestCount == 7) + #expect(abs((claudeRows.only?.costUSD ?? -1) - 0.02) < 0.000_000_001) + // The fixture also carries $1.25 of byok_usage_inference. `usage` is the + // OpenRouter spend field; combining the two would fabricate $1.27. + #expect(abs((firstDay.costUSD ?? -1) - 0.02) < 0.000_000_001) + } + + @Test + func `activity fetch requests the thirty completed day endpoint`() async throws { + let requests = OpenRouterActivityRequestRecorder() + let transport = ProviderHTTPTransportHandler { request in + await requests.append(request) + return try Self.response(request, body: Self.activityFixture) + } + + let activity = try #require(try await OpenRouterActivityUsageFetcher.fetchOptional( + apiKey: "fixture-management-key", + baseURL: #require(URL(string: "https://openrouter.test/api/v1")), + transport: transport, + now: Self.now, + historyDays: 30)) + + let request = try #require(await requests.requests.only) + #expect(request.httpMethod == "GET") + #expect(request.url?.absoluteString == "https://openrouter.test/api/v1/activity") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer fixture-management-key") + #expect(activity.toCostUsageTokenSnapshot().historyDays == 30) + } + + @Test(arguments: [403, 404]) + func `unavailable activity is optional and never fabricates rows`(statusCode: Int) async throws { + let transport = ProviderHTTPTransportHandler { request in + try Self.response( + request, + body: #"{"error":{"message":"activity unavailable"}}"#, + statusCode: statusCode) + } + + let activity = try await OpenRouterActivityUsageFetcher.fetchOptional( + apiKey: "fixture-key", + baseURL: #require(URL(string: "https://openrouter.test/api/v1")), + transport: transport, + now: Self.now) + + #expect(activity == nil) + } + + @Test + func `malformed optional activity never fabricates rows`() async throws { + let transport = ProviderHTTPTransportHandler { request in + try Self.response(request, body: #"{"data":[{"date":"2026-08-06","usage":"free"}]}"#) + } + + let activity = try await OpenRouterActivityUsageFetcher.fetchOptional( + apiKey: "fixture-key", + baseURL: #require(URL(string: "https://openrouter.test/api/v1")), + transport: transport, + now: Self.now) + + #expect(activity == nil) + } + + @Test @MainActor + func `transient activity failure preserves only current credential history`() throws { + let activity = try OpenRouterActivityUsageSnapshot( + data: Data(Self.activityFixture.utf8), + now: Self.now) + let previous = UsageSnapshot( + primary: nil, + secondary: nil, + openRouterActivityUsage: activity, + updatedAt: Self.now) + let current = UsageSnapshot(primary: nil, secondary: nil, updatedAt: Self.now) + let settings = testSettingsStore( + suiteName: "OpenRouterActivityUsageTests-\(UUID().uuidString)", + tokenAccountStore: InMemoryTokenAccountStore()) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + store.publishTokenSnapshot(activity.toCostUsageTokenSnapshot(), for: .openrouter) + + let preserved = store.preservingOpenRouterActivityIfCurrent(current, previous: previous) + #expect(preserved.openRouterActivityUsage == activity) + + settings[providerConfig: .openrouter, field: .apiKey] = "changed-scope-fixture" + let rejected = store.preservingOpenRouterActivityIfCurrent(current, previous: previous) + #expect(rejected.openRouterActivityUsage == nil) + } + + #if canImport(JavaScriptCore) + @Test + func `bundled plugin projects activity into provider snapshot`() async throws { + let requests = OpenRouterActivityRequestRecorder() + let transport = ProviderHTTPTransportHandler { request in + await requests.append(request) + switch request.url?.lastPathComponent { + case "activity": + return try Self.response(request, body: Self.activityFixture) + case "key": + return try Self.response(request, body: #"{"data":{}}"#) + default: + return try Self.response(request, body: #"{"data":{"total_credits":100,"total_usage":40}}"#) + } + } + let runtime = try ProviderPluginRuntime(bundledPlugin: "openrouter", transport: transport) + + let usage = try await runtime.fetchUsage( + settings: [ + OpenRouterSettingsReader.apiURLEnvironmentKey: "https://openrouter.test/api/v1", + ], + secrets: [OpenRouterSettingsReader.envKey: "fixture-management-key"], + now: Self.now) + + let paths = await requests.requests.compactMap(\.url?.path) + #expect(paths.contains("/api/v1/activity")) + let activity = try #require(usage.openRouterActivityUsage) + #expect(activity.toCostUsageTokenSnapshot().last30DaysTokens == 325) + #expect(abs((activity.toCostUsageTokenSnapshot().last30DaysCostUSD ?? -1) - 0.05) < 0.000_000_001) + } + + @Test + func `bundled plugin treats activity forbidden as optional`() async throws { + let requests = OpenRouterActivityRequestRecorder() + let transport = ProviderHTTPTransportHandler { request in + await requests.append(request) + switch request.url?.lastPathComponent { + case "activity": + return try Self.response( + request, + body: #"{"error":{"message":"management key required"}}"#, + statusCode: 403) + case "key": + return try Self.response(request, body: #"{"data":{}}"#) + default: + return try Self.response(request, body: #"{"data":{"total_credits":100,"total_usage":40}}"#) + } + } + let runtime = try ProviderPluginRuntime(bundledPlugin: "openrouter", transport: transport) + + let usage = try await runtime.fetchUsage( + settings: [ + OpenRouterSettingsReader.apiURLEnvironmentKey: "https://openrouter.test/api/v1", + ], + secrets: [OpenRouterSettingsReader.envKey: "ordinary-api-key"]) + + let paths = await requests.requests.compactMap(\.url?.path) + #expect(paths.contains("/api/v1/activity")) + #expect(usage.openRouterActivityUsage == nil) + #expect(usage.detailRow(label: "Remaining")?.value == "$60.00") + } + #endif + + #if !canImport(JavaScriptCore) + @Test + func `provider snapshot exposes optional activity cost history`() throws { + let activity = try OpenRouterActivityUsageSnapshot( + data: Data(Self.activityFixture.utf8), + now: Self.now) + let provider = OpenRouterUsageSnapshot( + totalCredits: 100, + totalUsage: 40, + balance: 60, + usedPercent: 40, + rateLimit: nil, + activityUsage: activity, + updatedAt: Self.now) + + #expect(provider.activityUsage?.toCostUsageTokenSnapshot() == activity.toCostUsageTokenSnapshot()) + #expect(provider.toCostUsageTokenSnapshot() == activity.toCostUsageTokenSnapshot()) + } + #endif + + private static let now = Date(timeIntervalSince1970: 1_785_974_400) // 2026-08-07 00:00:00 UTC + + private static let activityFixture = #""" + { + "data": [ + { + "byok_usage_inference": 1.0, + "completion_tokens": 125, + "date": "2026-08-05", + "endpoint_id": "endpoint-claude-a", + "model": "anthropic/claude-sonnet-4-6", + "model_permaslug": "anthropic/claude-sonnet-4-6-20260219", + "prompt_tokens": 50, + "provider_name": "Anthropic", + "reasoning_tokens": 25, + "requests": 5, + "usage": 0.015 + }, + { + "byok_usage_inference": 0.25, + "completion_tokens": 20, + "date": "2026-08-05", + "endpoint_id": "endpoint-claude-b", + "model": "anthropic/claude-sonnet-4-6", + "model_permaslug": "anthropic/claude-sonnet-4-6-20260219", + "prompt_tokens": 10, + "provider_name": "Anthropic", + "reasoning_tokens": 5, + "requests": 2, + "usage": 0.005 + }, + { + "byok_usage_inference": 0, + "completion_tokens": 80, + "date": "2026-08-06", + "endpoint_id": "endpoint-gpt", + "model": "openai/gpt-5.4-mini", + "model_permaslug": "openai/gpt-5.4-mini-20260801", + "prompt_tokens": 40, + "provider_name": "OpenAI", + "reasoning_tokens": 20, + "requests": 3, + "usage": 0.03 + } + ] + } + """# + + private static func response( + _ request: URLRequest, + body: String, + statusCode: Int = 200) throws -> (Data, URLResponse) + { + let response = try #require(try HTTPURLResponse( + url: #require(request.url), + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])) + return (Data(body.utf8), response) + } +} + +private actor OpenRouterActivityRequestRecorder { + private(set) var requests: [URLRequest] = [] + + func append(_ request: URLRequest) { + self.requests.append(request) + } +} + +extension Array { + fileprivate var only: Element? { + self.count == 1 ? self[0] : nil + } +} diff --git a/Tests/CodexBarTests/OpenRouterUsageStatsTests.swift b/Tests/CodexBarTests/OpenRouterUsageStatsTests.swift index 77b3c3cb16..1179c99123 100644 --- a/Tests/CodexBarTests/OpenRouterUsageStatsTests.swift +++ b/Tests/CodexBarTests/OpenRouterUsageStatsTests.swift @@ -74,13 +74,17 @@ struct OpenRouterPluginGoldenTests { secrets: [OpenRouterSettingsReader.envKey: "sk-or-v1-test"]) let recorded = await requests.requests - #expect(recorded.count == 2) + #expect(recorded.count == 3) #expect(recorded[0].timeoutInterval == 15) #expect(recorded[0].value(forHTTPHeaderField: "HTTP-Referer") == "https://codexbar.example") #expect(recorded[0].value(forHTTPHeaderField: "X-Title") == "CodexBar QA") #expect(recorded[1].timeoutInterval == 1) #expect(recorded[1].value(forHTTPHeaderField: "HTTP-Referer") == nil) #expect(recorded[1].value(forHTTPHeaderField: "X-Title") == nil) + #expect(recorded[2].url?.path.hasSuffix("/activity") == true) + #expect(recorded[2].timeoutInterval == 5) + #expect(recorded[2].value(forHTTPHeaderField: "HTTP-Referer") == nil) + #expect(recorded[2].value(forHTTPHeaderField: "X-Title") == nil) #expect(usage.detailRow(label: "Today")?.value == "$0.12") #expect(usage.detailRow(label: "This week")?.value == "$0.74") #expect(usage.detailRow(label: "This month")?.value == "$4.56") diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 7d2c0f5aea..87670c8a49 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -2363,11 +2363,27 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardModel.swift", - line: 639, - anchor: "guard provider == .mistral else { return displayCalendar }", - expectedProviderIDs: ["mistral"], + line: 601, + anchor: "let bucketEnd = input.provider == .openrouter", + expectedProviderIDs: ["openrouter"], expectedReferenceCount: 1, - expectedReferenceFingerprint: ["mistral@0"], + expectedReferenceFingerprint: ["openrouter@0"], + reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), + AllowedProviderConstruct( + path: "Sources/CodexBar/SpendDashboardModel.swift", + line: 658, + anchor: "guard provider == .mistral || provider == .openrouter else { return displayCalendar }", + expectedProviderIDs: ["mistral", "openrouter"], + expectedReferenceCount: 2, + expectedReferenceFingerprint: ["mistral@0", "openrouter@0"], + reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), + AllowedProviderConstruct( + path: "Sources/CodexBarCore/Plugins/ProviderPluginSnapshotMapper.swift", + line: 18, + anchor: "let openRouterActivityUsage = provider == .openrouter", + expectedProviderIDs: ["openrouter"], + expectedReferenceCount: 1, + expectedReferenceFingerprint: ["openrouter@0"], reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/StatusItemController+AccountMenuDisplay.swift", @@ -2809,6 +2825,14 @@ struct ProviderArchitectureGatekeeperTests { expectedReferenceCount: 2, expectedReferenceFingerprint: ["codex@0", "codex@10"], reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), + AllowedProviderConstruct( + path: "Sources/CodexBar/UsageStore+Refresh.swift", + line: 705, + anchor: "let historyStable = provider == .openrouter", + expectedProviderIDs: ["openrouter"], + expectedReferenceCount: 1, + expectedReferenceFingerprint: ["openrouter@0"], + reason: "This exact app-runtime bridge preserves current-scope optional provider history."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+Refresh.swift", line: 721, @@ -3020,6 +3044,14 @@ struct ProviderArchitectureGatekeeperTests { expectedReferenceCount: 1, expectedReferenceFingerprint: ["deepseek@0"], reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), + AllowedProviderConstruct( + path: "Sources/CodexBar/UsageStore+TokenAccounts.swift", + line: 1455, + anchor: "let historyStable = provider == .openrouter", + expectedProviderIDs: ["openrouter"], + expectedReferenceCount: 1, + expectedReferenceFingerprint: ["openrouter@0"], + reason: "This exact app-runtime bridge preserves current-scope optional provider history."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenAccounts.swift", line: 1466, @@ -3044,6 +3076,14 @@ struct ProviderArchitectureGatekeeperTests { expectedReferenceCount: 1, expectedReferenceFingerprint: ["deepseek@0"], reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), + AllowedProviderConstruct( + path: "Sources/CodexBar/UsageStore+TokenCost.swift", + line: 512, + anchor: "self.tokenSnapshotPublicationForCurrentProviderConfig(for: .openrouter)?.snapshot != nil,", + expectedProviderIDs: ["openrouter"], + expectedReferenceCount: 1, + expectedReferenceFingerprint: ["openrouter@0"], + reason: "This exact app-runtime bridge preserves current-scope optional provider history."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", line: 218, @@ -3104,19 +3144,19 @@ 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, - expectedReferenceFingerprint: ["openai@0", "mistral@2", "opencodego@4"], + expectedProviderIDs: ["mistral", "openai", "openrouter", "opencodego"], + expectedReferenceCount: 4, + expectedReferenceFingerprint: ["openai@0", "openrouter@2", "mistral@4", "opencodego@6"], reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 441, - anchor: "case .mistral, .openai, .opencodego:", - expectedProviderIDs: ["mistral", "openai", "opencodego"], - expectedReferenceCount: 3, - expectedReferenceFingerprint: ["mistral@0", "openai@0", "opencodego@0"], + line: 456, + anchor: "case .mistral, .openai, .openrouter, .opencodego:", + expectedProviderIDs: ["mistral", "openai", "openrouter", "opencodego"], + expectedReferenceCount: 4, + expectedReferenceFingerprint: ["mistral@0", "openai@0", "openrouter@0", "opencodego@0"], reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", diff --git a/Tests/CodexBarTests/ProviderPluginDetailsParityTests.swift b/Tests/CodexBarTests/ProviderPluginDetailsParityTests.swift index a483a780c6..d3e556b538 100644 --- a/Tests/CodexBarTests/ProviderPluginDetailsParityTests.swift +++ b/Tests/CodexBarTests/ProviderPluginDetailsParityTests.swift @@ -54,6 +54,7 @@ struct ProviderPluginDetailsParityTests { switch request.url?.path { case "/api/v1/credits": Self.openRouterCredits case "/api/v1/key": Self.openRouterKey + case "/api/v1/activity": Self.openRouterActivity default: throw FixtureError.unexpectedURL(request.url) } } @@ -145,17 +146,21 @@ struct ProviderPluginDetailsParityTests { secrets: [OpenRouterSettingsReader.envKey: "fixture-key"]) let recorded = await requests.requests - #expect(recorded.count == 2) + #expect(recorded.count == 3) #expect(recorded[0].url?.absoluteString == (overridden ? "https://router.example.test/gateway/v1/credits" : "https://openrouter.ai/api/v1/credits")) #expect(recorded[1].url?.absoluteString == (overridden ? "https://router.example.test/gateway/v1/key" : "https://openrouter.ai/api/v1/key")) + #expect(recorded[2].url?.absoluteString == (overridden + ? "https://router.example.test/gateway/v1/activity" + : "https://openrouter.ai/api/v1/activity")) #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[2].value(forHTTPHeaderField: "X-Title") == nil) } @Test @@ -489,6 +494,7 @@ struct ProviderPluginDetailsParityTests { "usage_daily":1,"usage_weekly":2,"usage_monthly":4, "rate_limit":{"requests":120,"interval":"10s"}}} """# + private static let openRouterActivity = #"{"data":[]}"# private static let poeBalance = #"{"current_point_balance":2500}"# private static let poeHistory = #""" {"data":[ diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift index 0155c6397c..f94c817e3b 100644 --- a/Tests/CodexBarTests/SettingsStoreTests.swift +++ b/Tests/CodexBarTests/SettingsStoreTests.swift @@ -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 @@ -690,7 +691,7 @@ struct SettingsStoreTests { #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..59e0c4bb2c 100644 --- a/Tests/CodexBarTests/ShareStatsTests.swift +++ b/Tests/CodexBarTests/ShareStatsTests.swift @@ -31,24 +31,113 @@ 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 connected services report spend")) #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")) + #expect(ShareStatsFormatting.text(payload).contains("2/6 connected services report spend")) + } + + @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.only?.isPartial == true) + #expect(payload.spendReportingProviderCount == 1) + #expect(payload.providers.count == 2) + } + + @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 +166,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 +213,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 +298,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 +472,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/SpendDashboardDateTruthTests.swift b/Tests/CodexBarTests/SpendDashboardDateTruthTests.swift index ea29bd0f58..2c94181052 100644 --- a/Tests/CodexBarTests/SpendDashboardDateTruthTests.swift +++ b/Tests/CodexBarTests/SpendDashboardDateTruthTests.swift @@ -13,6 +13,33 @@ struct SpendDashboardDateTruthTests { let chartCost: Double? } + @Test + func `OpenRouter completed UTC history never fabricates the current day`() throws { + var utc = Calendar(identifier: .gregorian) + utc.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let now = try #require(ISO8601DateFormatter().date(from: "2026-08-07T12:00:00Z")) + let august6 = try #require(utc.date(from: DateComponents(year: 2026, month: 8, day: 6))) + let snapshot = Self.snapshot( + currency: "USD", + entries: [ + Self.entry(day: "2026-08-05", cost: 1, tokens: 10), + Self.entry(day: "2026-08-06", cost: 2, tokens: 20), + ], + historyDays: 2, + updatedAt: now) + let group = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .openrouter, displayName: "OpenRouter", snapshot: snapshot)], + requestedDays: 2, + now: now, + calendar: utc).groups.first) + + #expect(group.providers.first?.coveredDayCount == 1) + #expect(group.totalCost == 2) + #expect(group.totalTokens == 20) + #expect(group.dailyPoints.map(\.day) == [august6]) + #expect(group.coveredDayCount == 1) + } + @Test func `Mistral UTC buckets map into Pacific dashboard days at midnight UTC`() throws { var pacific = Calendar(identifier: .gregorian) diff --git a/Tests/CodexBarTests/SpendDashboardIntegratedModelCoverageTests.swift b/Tests/CodexBarTests/SpendDashboardIntegratedModelCoverageTests.swift new file mode 100644 index 0000000000..5c2b79295f --- /dev/null +++ b/Tests/CodexBarTests/SpendDashboardIntegratedModelCoverageTests.swift @@ -0,0 +1,140 @@ +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, + .openrouter, + .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-05", + 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 f932c2103f..bcd7f3affd 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,17 @@ 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, + .openrouter, + .mistral, + .bedrock, + .cursor, + .opencodego, + ]) } @Test @@ -261,12 +250,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 +388,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 +749,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", @@ -980,4 +975,26 @@ extension SpendDashboardModelTests { #expect(group.modelHistoryCompleteness == .incomplete) #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) == "پوشش: ۳ / ۳۰") + } + } } diff --git a/Tests/CodexBarTests/SpendDashboardTrackedSourceTests.swift b/Tests/CodexBarTests/SpendDashboardTrackedSourceTests.swift new file mode 100644 index 0000000000..cae5ce4965 --- /dev/null +++ b/Tests/CodexBarTests/SpendDashboardTrackedSourceTests.swift @@ -0,0 +1,241 @@ +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) == [.cursor, .gemini, .grok, .openrouter]) + #expect(rows[.grok]?.state == .connected) + #expect(rows[.gemini]?.state == .needsAttention) + #expect(rows[.openrouter]?.state == .awaitingUsage) + #expect(rows.values.filter(\.contributesCostHistory).map(\.provider) == [.cursor]) + } + + @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), + SpendDashboardTrackedSource( + id: "claude:account:team", + provider: .claude, + providerName: "Claude", + accountName: "Team", + state: .connected, + supportsCostHistory: true, + contributesCostHistory: 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/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 8a6e711d40..f97d4c44f3 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,196 @@ 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 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 @@ -185,14 +357,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 93a5419a75..d3cf7c051f 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) @@ -145,6 +147,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() + } + private static func makeStore(suite: String) throws -> UsageStore { let settings = testSettingsStore( suiteName: "UsageStoreSpendDashboardCodexCostCatchUpTests-\(suite)") diff --git a/docs/codex.md b/docs/codex.md index bf75aea5d1..dd337a744e 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/providers.md b/docs/providers.md index c1fab9957f..e9fab8f2cc 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..52772f144b --- /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, labels partial spend with its reporting denominator, and ranks routed +OpenRouter models alongside first-class Claude and Codex variants. 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