diff --git a/Sources/CodexBar/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift index 6da71e00ba..4806d4f026 100644 --- a/Sources/CodexBar/PreferencesSpendDashboardPane.swift +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -11,6 +11,7 @@ func spendDashboardDayRangeText(_ days: Int) -> String { switch days { case 7: template = L("7d") case 30: template = L("30d") + case 90: template = L("90d") default: return codexBarLocalizedInteger(days) } return template.replacingOccurrences( @@ -30,6 +31,26 @@ func spendDashboardCoverageText(covered: Int, requested: Int) -> String { "\(L("Coverage")): \(codexBarLocalizedInteger(covered)) / \(codexBarLocalizedInteger(requested))" } +func spendDashboardTokenMixValue(_ value: Int?) -> String { + value.map(UsageFormatter.tokenCountString) ?? "—" +} + +func spendDashboardCoverageChipText(_ coverage: CostUsageCoverageCounts) -> String { + "\(L("Priced")) \(codexBarLocalizedInteger(coverage.priced)) · " + + "\(L("Unpriced")) \(codexBarLocalizedInteger(coverage.unpriced)) · " + + "\(L("Unmetered")) \(codexBarLocalizedInteger(coverage.unmetered)) · " + + "\(L("Estimated")) \(codexBarLocalizedInteger(coverage.estimated))" +} + +func spendDashboardProvenanceText(_ provenance: CostProvenance) -> String { + switch provenance { + case .listPriceEstimate: L("List-price equivalent") + case .vendorMetered: L("Plan metered") + case .mixed: L("Metered and list-price") + case .unknown: L("Spend unavailable") + } +} + func codexCostCatchUpProgressText(_ activity: CodexCostCatchUpActivity) -> String { if activity.totalBytes > 0 { let processed = ByteCountFormatter.string( @@ -143,11 +164,12 @@ struct SpendDashboardPane: View { Picker(L("Time range"), selection: self.daysBinding) { Text(spendDashboardDayRangeText(7)).tag(7) Text(spendDashboardDayRangeText(30)).tag(30) + Text(spendDashboardDayRangeText(90)).tag(90) Text(spendDashboardDayRangeText(SpendDashboardSource.scanDays)).tag(SpendDashboardSource.scanDays) } .labelsHidden() .pickerStyle(.segmented) - .frame(width: 188) + .frame(width: 248) Button { self.controller.refresh() @@ -318,7 +340,12 @@ struct SpendDashboardPane: View { if self.settings.costUsageEnabled, !self.controller.model.tokenActivity.isEmpty { SpendDashboardPanel { - SpendActivityHeatmapView(points: self.controller.model.tokenActivity) + SpendActivityHeatmapView( + points: self.controller.model.tokenActivity, + selectedDay: self.controller.selectedDay, + onSelectDay: { day in + self.controller.selectDay(day) + }) } } @@ -332,21 +359,44 @@ struct SpendDashboardPane: View { } private var provenance: some View { - HStack(alignment: .top, spacing: 10) { - Image(systemName: "lock.shield.fill") - .foregroundStyle(.secondary) - Text(L("Native currencies stay separate; Codex account rows exclude Pi session history.")) - .font(.caption) - .foregroundStyle(.secondary) - Spacer() - Toggle(L("Track costs"), isOn: self.$settings.costUsageEnabled) - .toggleStyle(.switch) - .controlSize(.small) + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "lock.shield.fill") + .foregroundStyle(.secondary) + Text(L("List-price equivalent — not a billing receipt.")) + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Toggle(L("Track costs"), isOn: self.$settings.costUsageEnabled) + .toggleStyle(.switch) + .controlSize(.small) + } + if self.settings.costUsageEnabled { + Toggle(L("Include OpenCodex usage logs"), isOn: self.$settings.openCodexUsageLogsEnabled) + .toggleStyle(.switch) + .controlSize(.small) + if self.settings.openCodexUsageLogsEnabled { + Toggle( + L("Hide native Codex when OpenCodex is present"), + isOn: self.$settings.hideNativeCodexCostWhenOpenCodexPresent) + .toggleStyle(.switch) + .controlSize(.small) + } + if !self.controller.model.groups.isEmpty { + SpendDashboardSourceFilter(settings: self.settings, model: self.controller.model) + } + } } } private var shareAction: some View { HStack { + Button { + self.exportJSON() + } label: { + Label(L("Export JSON"), systemImage: "square.and.arrow.down") + } + .disabled(self.controller.model.groups.isEmpty) Spacer() Button { guard let payload = self.sharePayload else { return } @@ -358,6 +408,20 @@ struct SpendDashboardPane: View { } } + private func exportJSON() { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + let payload = SpendDashboardExportPayload.make( + model: self.controller.model, + hiddenSourceIDs: self.settings.spendDashboardHiddenSourceIDs) + guard let data = try? encoder.encode(payload), + let json = String(bytes: data, encoding: .utf8) + else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(json, forType: .string) + } + private var sharePayload: ShareStatsPayload? { ShareStatsBuilder.make( model: self.controller.model, @@ -434,22 +498,59 @@ struct SpendDashboardCurrencySection: View { .foregroundStyle(.secondary) SpendDashboardPanel { - HStack(spacing: 24) { - SpendSummaryValue( - title: L("Estimated spend"), - value: self.group.totalCost == nil ? "—" : spendDashboardGroupCostText(self.group)) - SpendSummaryValue( - title: L("Tracked tokens"), - value: spendDashboardGroupTokenText(self.group)) - SpendSummaryValue( - title: L("Subscriptions"), - value: codexBarLocalizedInteger(self.group.providers.count)) - Spacer() + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 24) { + SpendSummaryValue( + title: L("Estimated spend"), + value: self.group.totalCost == nil ? "—" : spendDashboardGroupCostText(self.group)) + SpendSummaryValue( + title: L("Tracked tokens"), + value: spendDashboardGroupTokenText(self.group)) + if let metered = self.group.meteredCost { + SpendSummaryValue( + title: L("Plan metered"), + value: UsageFormatter.currencyString(metered, currencyCode: self.group.currencyCode)) + } + SpendSummaryValue( + title: L("Subscriptions"), + value: codexBarLocalizedInteger(self.group.providers.count)) + Spacer() + } + HStack(spacing: 24) { + SpendSummaryValue( + title: L("Input"), + value: spendDashboardTokenMixValue(self.group.tokenMix.inputTokens)) + SpendSummaryValue( + title: L("Output"), + value: spendDashboardTokenMixValue(self.group.tokenMix.outputTokens)) + SpendSummaryValue( + title: L("Cache read"), + value: spendDashboardTokenMixValue(self.group.tokenMix.cacheReadTokens)) + SpendSummaryValue( + title: L("Cache write"), + value: spendDashboardTokenMixValue(self.group.tokenMix.cacheCreationTokens)) + SpendSummaryValue( + title: L("Reasoning"), + value: spendDashboardTokenMixValue(self.group.tokenMix.reasoningTokens)) + Spacer() + } + Text(spendDashboardCoverageChipText(self.group.coverage)) + .font(.caption) + .foregroundStyle(.secondary) + Text(spendDashboardProvenanceText(self.group.provenance)) + .font(.caption) + .foregroundStyle(.secondary) + if let selectedDay = self.group.selectedDay { + Text(SpendActivityDateFormatting.mediumDateString(selectedDay)) + .font(.caption) + .foregroundStyle(.secondary) + } } } SpendProviderPanel(group: self.group) SpendModelPanel(group: self.group) + SpendSessionPanel(group: self.group) if !self.group.projects.isEmpty { SpendProjectPanel(group: self.group) } @@ -490,7 +591,7 @@ private struct SpendProviderPanel: View { .font(.caption.monospacedDigit()) .foregroundStyle(.tertiary) .frame(width: 26, alignment: .leading) - SpendProviderIcon(provider: row.provider) + SpendProviderIcon(provider: row.provider, sourceKind: row.sourceKind) Text(row.displayName).lineLimit(1) Spacer() Text(row.totalCost.map { @@ -508,9 +609,6 @@ private struct SpendProviderPanel: View { private struct SpendModelPanel: View { let group: SpendDashboardModel.CurrencyGroup - @State private var showsAllRows = false - - private static let collapsedRowCount = 8 var body: some View { SpendDashboardPanel { @@ -533,7 +631,7 @@ private struct SpendModelPanel: View { .foregroundStyle(.secondary) .padding(.bottom, 6) } - ForEach(self.visibleRows) { row in + ForEach(self.group.displayedModels) { row in if row.rank > 1 { Divider() } @@ -562,19 +660,18 @@ private struct SpendModelPanel: View { } .padding(.vertical, 9) } - SpendPanelExpandButton( - rowCount: self.group.models.count, - collapsedRowCount: Self.collapsedRowCount, - showsAllRows: self.$showsAllRows) + if self.group.overflowModelCount > 0 { + Divider() + Text( + "\(L("Other models")): \(codexBarLocalizedInteger(self.group.overflowModelCount))") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.top, 8) + } } } } } - - private var visibleRows: ArraySlice { - self.group.models.prefix( - self.showsAllRows ? self.group.models.count : Self.collapsedRowCount) - } } private struct SpendProjectPanel: View { @@ -743,10 +840,14 @@ private struct SpendDailyChart: View { private struct SpendProviderIcon: View { let provider: UsageProvider + var sourceKind: SpendDashboardModel.SourceKind = .native var body: some View { Group { - if let icon = ProviderBrandIcon.image(for: self.provider) { + if self.sourceKind == .openCodex { + Image(systemName: "arrow.triangle.branch") + .font(.body.weight(.semibold)) + } else if let icon = ProviderBrandIcon.image(for: self.provider) { Image(nsImage: icon).resizable().scaledToFit() } else { Image(systemName: "circle.dotted") @@ -757,6 +858,149 @@ private struct SpendProviderIcon: View { } } +private struct SpendSessionPanel: View { + let group: SpendDashboardModel.CurrencyGroup + + var body: some View { + if !self.group.sessions.isEmpty { + SpendDashboardPanel { + VStack(alignment: .leading, spacing: 0) { + Text(L("Sessions")).font(.headline).padding(.bottom, 8) + ForEach(Array(self.group.sessions.enumerated()), id: \.element.id) { index, row in + if index > 0 { + Divider() + } + HStack(spacing: 10) { + SpendProviderIcon(provider: row.provider, sourceKind: .native) + VStack(alignment: .leading, spacing: 2) { + Text(row.displayName).lineLimit(1) + Text(row.modelName ?? SpendActivityDateFormatting.mediumDateString(row.lastActivity)) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Text(row.totalCost.map { + UsageFormatter.currencyString($0, currencyCode: self.group.currencyCode) + } ?? spendDashboardTokenMixValue(row.totalTokens)) + .monospacedDigit() + } + .padding(.vertical, 9) + } + } + } + } + } +} + +private struct SpendDashboardSourceFilter: View { + @Bindable var settings: SettingsStore + let model: SpendDashboardModel + + var body: some View { + let ids = self.sourceIDs + if !ids.isEmpty { + VStack(alignment: .leading, spacing: 6) { + Text(L("Sources")).font(.caption).foregroundStyle(.secondary) + ForEach(ids, id: \.self) { sourceID in + Toggle(isOn: self.visibilityBinding(sourceID)) { + Text(self.label(for: sourceID)).lineLimit(1) + } + .toggleStyle(.checkbox) + .controlSize(.small) + } + } + } + } + + private var sourceIDs: [String] { + self.model.availableSources.map(\.id) + } + + private func label(for sourceID: String) -> String { + self.model.availableSources.first { $0.id == sourceID }?.displayName ?? sourceID + } + + private func visibilityBinding(_ sourceID: String) -> Binding { + Binding( + get: { !self.settings.spendDashboardHiddenSourceIDs.contains(sourceID) }, + set: { isVisible in + var hidden = Set(self.settings.spendDashboardHiddenSourceIDs) + if isVisible { + hidden.remove(sourceID) + } else { + hidden.insert(sourceID) + } + self.settings.spendDashboardHiddenSourceIDs = Array(hidden) + }) + } +} + +struct SpendDashboardExportPayload: Encodable, Sendable { + let requestedDays: Int + let selectedDay: Date? + let groups: [Group] + let hiddenSourceIDs: [String] + + struct Group: Encodable, Sendable { + let currencyCode: String + let totalTokens: Int? + let totalCost: Double? + let meteredCost: Double? + let provenance: String + let coverage: CostUsageCoverageCounts + let tokenMix: CostUsageTokenMix + let providers: [Provider] + let models: [Model] + } + + struct Provider: Encodable, Sendable { + let id: String + let displayName: String + let sourceKind: String + let totalTokens: Int? + let totalCost: Double? + } + + struct Model: Encodable, Sendable { + let provider: String + let modelName: String + let totalTokens: Int? + let totalCost: Double? + } + + static func make(model: SpendDashboardModel, hiddenSourceIDs: [String]) -> Self { + Self( + requestedDays: model.requestedDays, + selectedDay: model.selectedDay, + groups: model.groups.map { group in + Group( + currencyCode: group.currencyCode, + totalTokens: group.totalTokens, + totalCost: group.totalCost, + meteredCost: group.meteredCost, + provenance: group.provenance.rawValue, + coverage: group.coverage, + tokenMix: group.tokenMix, + providers: group.providers.map { + Provider( + id: $0.id, + displayName: $0.displayName, + sourceKind: $0.sourceKind.rawValue, + totalTokens: $0.totalTokens, + totalCost: $0.totalCost) + }, + models: group.models.map { + Model( + provider: $0.provider.rawValue, + modelName: $0.modelName, + totalTokens: $0.totalTokens, + totalCost: $0.totalCost) + }) + }, + hiddenSourceIDs: hiddenSourceIDs) + } +} + private struct SpendDashboardPanel: View { @ViewBuilder let content: Content diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index 39df0a39c3..5dc4c0ffd5 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -1467,3 +1467,25 @@ "Total usage" = "إجمالي الاستخدام"; "claude_oauth_keychain_access_revoked" = "تم إلغاء وصول CodexBar إلى سلسلة مفاتيح Claude بسبب تدوير الرمز المميز في Claude Code. انقر على «تحديث» لمنح الوصول مجددًا، أو بدّل مصدر استخدام Claude إلى CLI/Web."; "claude_showing_last_known_usage" = "يتم عرض آخر بيانات استخدام معروفة، تم التقاطها %@."; +/* Spend dashboard cost analysis */ +"90d" = "90d"; +"Input" = "Input"; +"Output" = "Output"; +"Cache write" = "Cache write"; +"Reasoning" = "Reasoning"; +"Sessions" = "Sessions"; +"Projects" = "Projects"; +"Plan metered" = "Plan metered"; +"List-price equivalent" = "List-price equivalent"; +"Metered and list-price" = "Metered and list-price"; +"List-price equivalent — not a billing receipt." = "List-price equivalent — not a billing receipt."; +"Priced" = "Priced"; +"Unpriced" = "Unpriced"; +"Unmetered" = "Unmetered"; +"Estimated" = "Estimated"; +"Other models" = "Other models"; +"Include OpenCodex usage logs" = "Include OpenCodex usage logs"; +"Hide native Codex when OpenCodex is present" = "Hide native Codex when OpenCodex is present"; +"Export JSON" = "Export JSON"; +"Sources" = "Sources"; +"OpenCodex" = "OpenCodex"; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index ebc83442fd..75a520fda7 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -1466,3 +1466,25 @@ "Total usage" = "Ús total"; "claude_oauth_keychain_access_revoked" = "L'accés al clauer de Claude s'ha revocat per la rotació del testimoni de Claude Code. Feu clic a Actualitza per tornar a concedir l'accés, o canvieu l'origen d'ús de Claude a CLI/Web."; "claude_showing_last_known_usage" = "Es mostra l'últim ús conegut capturat %@."; +/* Spend dashboard cost analysis */ +"90d" = "90d"; +"Input" = "Input"; +"Output" = "Output"; +"Cache write" = "Cache write"; +"Reasoning" = "Reasoning"; +"Sessions" = "Sessions"; +"Projects" = "Projects"; +"Plan metered" = "Plan metered"; +"List-price equivalent" = "List-price equivalent"; +"Metered and list-price" = "Metered and list-price"; +"List-price equivalent — not a billing receipt." = "List-price equivalent — not a billing receipt."; +"Priced" = "Priced"; +"Unpriced" = "Unpriced"; +"Unmetered" = "Unmetered"; +"Estimated" = "Estimated"; +"Other models" = "Other models"; +"Include OpenCodex usage logs" = "Include OpenCodex usage logs"; +"Hide native Codex when OpenCodex is present" = "Hide native Codex when OpenCodex is present"; +"Export JSON" = "Export JSON"; +"Sources" = "Sources"; +"OpenCodex" = "OpenCodex"; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index 071bc11baa..5fc0a92474 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -1464,3 +1464,25 @@ "Total usage" = "Gesamtnutzung"; "claude_oauth_keychain_access_revoked" = "Der Zugriff auf den Claude-Schlüsselbund wurde durch die Token-Rotation von Claude Code widerrufen. Klicken Sie auf „Aktualisieren“, um den Zugriff erneut zu gewähren, oder stellen Sie die Claude-Nutzungsquelle auf CLI/Web um."; "claude_showing_last_known_usage" = "Letzte bekannte Nutzung wird angezeigt (erfasst: %@)."; +/* Spend dashboard cost analysis */ +"90d" = "90d"; +"Input" = "Input"; +"Output" = "Output"; +"Cache write" = "Cache write"; +"Reasoning" = "Reasoning"; +"Sessions" = "Sessions"; +"Projects" = "Projects"; +"Plan metered" = "Plan metered"; +"List-price equivalent" = "List-price equivalent"; +"Metered and list-price" = "Metered and list-price"; +"List-price equivalent — not a billing receipt." = "List-price equivalent — not a billing receipt."; +"Priced" = "Priced"; +"Unpriced" = "Unpriced"; +"Unmetered" = "Unmetered"; +"Estimated" = "Estimated"; +"Other models" = "Other models"; +"Include OpenCodex usage logs" = "Include OpenCodex usage logs"; +"Hide native Codex when OpenCodex is present" = "Hide native Codex when OpenCodex is present"; +"Export JSON" = "Export JSON"; +"Sources" = "Sources"; +"OpenCodex" = "OpenCodex"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 2514dbc25f..9be7db8a76 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -13,6 +13,7 @@ "(System)" = "(System)"; "30d" = "30d"; "7d" = "7d"; +"90d" = "90d"; "A managed Codex login is already running. Wait for it to finish before adding " = "A managed Codex login is already running. Wait for it to finish before adding "; "API key" = "API key"; "API region" = "API region"; @@ -1311,6 +1312,26 @@ "By subscription" = "By subscription"; "No model-level history" = "No model-level history"; "Daily estimated spend" = "Daily estimated spend"; +"Input" = "Input"; +"Output" = "Output"; +"Cache write" = "Cache write"; +"Reasoning" = "Reasoning"; +"Sessions" = "Sessions"; +"Projects" = "Projects"; +"Plan metered" = "Plan metered"; +"List-price equivalent" = "List-price equivalent"; +"Metered and list-price" = "Metered and list-price"; +"List-price equivalent — not a billing receipt." = "List-price equivalent — not a billing receipt."; +"Priced" = "Priced"; +"Unpriced" = "Unpriced"; +"Unmetered" = "Unmetered"; +"Estimated" = "Estimated"; +"Other models" = "Other models"; +"Include OpenCodex usage logs" = "Include OpenCodex usage logs"; +"Hide native Codex when OpenCodex is present" = "Hide native Codex when OpenCodex is present"; +"Export JSON" = "Export JSON"; +"Sources" = "Sources"; +"OpenCodex" = "OpenCodex"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d full 5h windows of weekly left · %d windows until reset"; "Weekly cannot run out before reset at this pace" = "Weekly cannot run out before reset at this pace"; "Weekly can run out ≈%d windows early" = "Weekly can run out ≈%d windows early"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index fb7a095b57..a9affd827a 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -1462,3 +1462,25 @@ "Total usage" = "Uso total"; "claude_oauth_keychain_access_revoked" = "El acceso al llavero de Claude fue revocado por la rotación del token de Claude Code. Haz clic en Actualizar para volver a conceder acceso o cambia el origen del uso de Claude a CLI/Web."; "claude_showing_last_known_usage" = "Mostrando el último uso conocido, capturado %@."; +/* Spend dashboard cost analysis */ +"90d" = "90d"; +"Input" = "Input"; +"Output" = "Output"; +"Cache write" = "Cache write"; +"Reasoning" = "Reasoning"; +"Sessions" = "Sessions"; +"Projects" = "Projects"; +"Plan metered" = "Plan metered"; +"List-price equivalent" = "List-price equivalent"; +"Metered and list-price" = "Metered and list-price"; +"List-price equivalent — not a billing receipt." = "List-price equivalent — not a billing receipt."; +"Priced" = "Priced"; +"Unpriced" = "Unpriced"; +"Unmetered" = "Unmetered"; +"Estimated" = "Estimated"; +"Other models" = "Other models"; +"Include OpenCodex usage logs" = "Include OpenCodex usage logs"; +"Hide native Codex when OpenCodex is present" = "Hide native Codex when OpenCodex is present"; +"Export JSON" = "Export JSON"; +"Sources" = "Sources"; +"OpenCodex" = "OpenCodex"; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index c91fe1096c..fcea722368 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -1467,3 +1467,25 @@ "Total usage" = "مصرف کل"; "claude_oauth_keychain_access_revoked" = "دسترسی به Keychain کلود با چرخش توکن Claude Code لغو شد. برای اعطای مجدد دسترسی روی «تازه‌سازی» کلیک کنید، یا منبع استفاده Claude را به CLI/Web تغییر دهید."; "claude_showing_last_known_usage" = "آخرین میزان استفاده شناخته‌شده که در %@ ثبت شده نمایش داده می‌شود."; +/* Spend dashboard cost analysis */ +"90d" = "90d"; +"Input" = "Input"; +"Output" = "Output"; +"Cache write" = "Cache write"; +"Reasoning" = "Reasoning"; +"Sessions" = "Sessions"; +"Projects" = "Projects"; +"Plan metered" = "Plan metered"; +"List-price equivalent" = "List-price equivalent"; +"Metered and list-price" = "Metered and list-price"; +"List-price equivalent — not a billing receipt." = "List-price equivalent — not a billing receipt."; +"Priced" = "Priced"; +"Unpriced" = "Unpriced"; +"Unmetered" = "Unmetered"; +"Estimated" = "Estimated"; +"Other models" = "Other models"; +"Include OpenCodex usage logs" = "Include OpenCodex usage logs"; +"Hide native Codex when OpenCodex is present" = "Hide native Codex when OpenCodex is present"; +"Export JSON" = "Export JSON"; +"Sources" = "Sources"; +"OpenCodex" = "OpenCodex"; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index f9bfb81fe4..45039d5c79 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -1463,3 +1463,25 @@ "Total usage" = "Utilisation totale"; "claude_oauth_keychain_access_revoked" = "L’accès au trousseau Claude a été révoqué par la rotation du jeton de Claude Code. Cliquez sur Actualiser pour accorder à nouveau l’accès, ou définissez la source d’utilisation de Claude sur CLI/Web."; "claude_showing_last_known_usage" = "Affichage de la dernière utilisation connue, capturée %@."; +/* Spend dashboard cost analysis */ +"90d" = "90d"; +"Input" = "Input"; +"Output" = "Output"; +"Cache write" = "Cache write"; +"Reasoning" = "Reasoning"; +"Sessions" = "Sessions"; +"Projects" = "Projects"; +"Plan metered" = "Plan metered"; +"List-price equivalent" = "List-price equivalent"; +"Metered and list-price" = "Metered and list-price"; +"List-price equivalent — not a billing receipt." = "List-price equivalent — not a billing receipt."; +"Priced" = "Priced"; +"Unpriced" = "Unpriced"; +"Unmetered" = "Unmetered"; +"Estimated" = "Estimated"; +"Other models" = "Other models"; +"Include OpenCodex usage logs" = "Include OpenCodex usage logs"; +"Hide native Codex when OpenCodex is present" = "Hide native Codex when OpenCodex is present"; +"Export JSON" = "Export JSON"; +"Sources" = "Sources"; +"OpenCodex" = "OpenCodex"; diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings index aa4d70082a..c263a6a0a6 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -1463,3 +1463,25 @@ "Total usage" = "Uso total"; "claude_oauth_keychain_access_revoked" = "O acceso ao chaveiro de Claude foi revogado pola rotación do token de Claude Code. Preme Actualizar para volver conceder o acceso ou cambia a orixe de uso de Claude a CLI/Web."; "claude_showing_last_known_usage" = "Mostrando o último uso coñecido, capturado %@."; +/* Spend dashboard cost analysis */ +"90d" = "90d"; +"Input" = "Input"; +"Output" = "Output"; +"Cache write" = "Cache write"; +"Reasoning" = "Reasoning"; +"Sessions" = "Sessions"; +"Projects" = "Projects"; +"Plan metered" = "Plan metered"; +"List-price equivalent" = "List-price equivalent"; +"Metered and list-price" = "Metered and list-price"; +"List-price equivalent — not a billing receipt." = "List-price equivalent — not a billing receipt."; +"Priced" = "Priced"; +"Unpriced" = "Unpriced"; +"Unmetered" = "Unmetered"; +"Estimated" = "Estimated"; +"Other models" = "Other models"; +"Include OpenCodex usage logs" = "Include OpenCodex usage logs"; +"Hide native Codex when OpenCodex is present" = "Hide native Codex when OpenCodex is present"; +"Export JSON" = "Export JSON"; +"Sources" = "Sources"; +"OpenCodex" = "OpenCodex"; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index 747a329847..e05041081a 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -1467,3 +1467,25 @@ "Total usage" = "Total penggunaan"; "claude_oauth_keychain_access_revoked" = "Akses Rantai Kunci Claude dicabut akibat rotasi token Claude Code. Klik Segarkan untuk memberikan akses lagi, atau ubah sumber penggunaan Claude ke CLI/Web."; "claude_showing_last_known_usage" = "Menampilkan penggunaan terakhir yang diketahui, diambil %@."; +/* Spend dashboard cost analysis */ +"90d" = "90d"; +"Input" = "Input"; +"Output" = "Output"; +"Cache write" = "Cache write"; +"Reasoning" = "Reasoning"; +"Sessions" = "Sessions"; +"Projects" = "Projects"; +"Plan metered" = "Plan metered"; +"List-price equivalent" = "List-price equivalent"; +"Metered and list-price" = "Metered and list-price"; +"List-price equivalent — not a billing receipt." = "List-price equivalent — not a billing receipt."; +"Priced" = "Priced"; +"Unpriced" = "Unpriced"; +"Unmetered" = "Unmetered"; +"Estimated" = "Estimated"; +"Other models" = "Other models"; +"Include OpenCodex usage logs" = "Include OpenCodex usage logs"; +"Hide native Codex when OpenCodex is present" = "Hide native Codex when OpenCodex is present"; +"Export JSON" = "Export JSON"; +"Sources" = "Sources"; +"OpenCodex" = "OpenCodex"; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index a22059a630..1458147b7c 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -1467,3 +1467,25 @@ "Total usage" = "Utilizzo totale"; "claude_oauth_keychain_access_revoked" = "L'accesso al portachiavi di Claude è stato revocato dalla rotazione del token di Claude Code. Fai clic su Aggiorna per concedere nuovamente l'accesso oppure imposta la fonte di utilizzo di Claude su CLI/Web."; "claude_showing_last_known_usage" = "Visualizzazione dell'ultimo utilizzo noto, acquisito %@."; +/* Spend dashboard cost analysis */ +"90d" = "90 g"; +"Input" = "Ingresso"; +"Output" = "Uscita"; +"Cache write" = "Scrittura cache"; +"Reasoning" = "Ragionamento"; +"Sessions" = "Sessioni"; +"Projects" = "Progetti"; +"Plan metered" = "Piano misurato"; +"List-price equivalent" = "Equivalente a listino"; +"Metered and list-price" = "Misurato e a listino"; +"List-price equivalent — not a billing receipt." = "Equivalente a listino — non è una fattura."; +"Priced" = "Prezzati"; +"Unpriced" = "Non prezzati"; +"Unmetered" = "Non misurati"; +"Estimated" = "Stimati"; +"Other models" = "Altri modelli"; +"Include OpenCodex usage logs" = "Includi i log di utilizzo OpenCodex"; +"Hide native Codex when OpenCodex is present" = "Nascondi Codex nativo quando è presente OpenCodex"; +"Export JSON" = "Esporta JSON"; +"Sources" = "Origini"; +"OpenCodex" = "OpenCodex"; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index f5927a568a..85db70223a 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -1464,3 +1464,25 @@ "Total usage" = "合計使用量"; "claude_oauth_keychain_access_revoked" = "Claude Code のトークン更新により、Claude キーチェーンへのアクセスが取り消されました。「更新」をクリックしてアクセスを再許可するか、Claude の使用量の取得元を CLI/Web に切り替えてください。"; "claude_showing_last_known_usage" = "最後に取得した既知の使用量を表示しています(取得: %@)。"; +/* Spend dashboard cost analysis */ +"90d" = "90d"; +"Input" = "Input"; +"Output" = "Output"; +"Cache write" = "Cache write"; +"Reasoning" = "Reasoning"; +"Sessions" = "Sessions"; +"Projects" = "Projects"; +"Plan metered" = "Plan metered"; +"List-price equivalent" = "List-price equivalent"; +"Metered and list-price" = "Metered and list-price"; +"List-price equivalent — not a billing receipt." = "List-price equivalent — not a billing receipt."; +"Priced" = "Priced"; +"Unpriced" = "Unpriced"; +"Unmetered" = "Unmetered"; +"Estimated" = "Estimated"; +"Other models" = "Other models"; +"Include OpenCodex usage logs" = "Include OpenCodex usage logs"; +"Hide native Codex when OpenCodex is present" = "Hide native Codex when OpenCodex is present"; +"Export JSON" = "Export JSON"; +"Sources" = "Sources"; +"OpenCodex" = "OpenCodex"; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index 0aa8b81736..875beef0fa 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -1431,3 +1431,25 @@ "Total usage" = "총 사용량"; "claude_oauth_keychain_access_revoked" = "Claude Code의 토큰 교체로 Claude 키체인 접근 권한이 취소되었습니다. 새로 고침을 클릭해 접근 권한을 다시 부여하거나 Claude 사용량 소스를 CLI/Web으로 전환하세요."; "claude_showing_last_known_usage" = "마지막으로 확인된 사용량을 표시 중입니다(캡처: %@)."; +/* Spend dashboard cost analysis */ +"90d" = "90d"; +"Input" = "Input"; +"Output" = "Output"; +"Cache write" = "Cache write"; +"Reasoning" = "Reasoning"; +"Sessions" = "Sessions"; +"Projects" = "Projects"; +"Plan metered" = "Plan metered"; +"List-price equivalent" = "List-price equivalent"; +"Metered and list-price" = "Metered and list-price"; +"List-price equivalent — not a billing receipt." = "List-price equivalent — not a billing receipt."; +"Priced" = "Priced"; +"Unpriced" = "Unpriced"; +"Unmetered" = "Unmetered"; +"Estimated" = "Estimated"; +"Other models" = "Other models"; +"Include OpenCodex usage logs" = "Include OpenCodex usage logs"; +"Hide native Codex when OpenCodex is present" = "Hide native Codex when OpenCodex is present"; +"Export JSON" = "Export JSON"; +"Sources" = "Sources"; +"OpenCodex" = "OpenCodex"; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index 8153149387..7c91c2ead5 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -1463,3 +1463,25 @@ "Total usage" = "Totaal gebruik"; "claude_oauth_keychain_access_revoked" = "De toegang tot de Claude-sleutelhanger is ingetrokken door de tokenrotatie van Claude Code. Klik op Vernieuwen om opnieuw toegang te verlenen of zet de Claude-gebruiksbron op CLI/Web."; "claude_showing_last_known_usage" = "De laatst bekende gebruiksgegevens worden weergegeven (vastgelegd: %@)."; +/* Spend dashboard cost analysis */ +"90d" = "90d"; +"Input" = "Input"; +"Output" = "Output"; +"Cache write" = "Cache write"; +"Reasoning" = "Reasoning"; +"Sessions" = "Sessions"; +"Projects" = "Projects"; +"Plan metered" = "Plan metered"; +"List-price equivalent" = "List-price equivalent"; +"Metered and list-price" = "Metered and list-price"; +"List-price equivalent — not a billing receipt." = "List-price equivalent — not a billing receipt."; +"Priced" = "Priced"; +"Unpriced" = "Unpriced"; +"Unmetered" = "Unmetered"; +"Estimated" = "Estimated"; +"Other models" = "Other models"; +"Include OpenCodex usage logs" = "Include OpenCodex usage logs"; +"Hide native Codex when OpenCodex is present" = "Hide native Codex when OpenCodex is present"; +"Export JSON" = "Export JSON"; +"Sources" = "Sources"; +"OpenCodex" = "OpenCodex"; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index 2fce14fea5..dac43187b7 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -1467,3 +1467,25 @@ "Total usage" = "Łączne użycie"; "claude_oauth_keychain_access_revoked" = "Dostęp do pęku kluczy Claude został cofnięty wskutek rotacji tokenu przez Claude Code. Kliknij Odśwież, aby ponownie przyznać dostęp, albo przełącz źródło użycia Claude na CLI/Web."; "claude_showing_last_known_usage" = "Wyświetlane jest ostatnie znane użycie zarejestrowane %@."; +/* Spend dashboard cost analysis */ +"90d" = "90d"; +"Input" = "Input"; +"Output" = "Output"; +"Cache write" = "Cache write"; +"Reasoning" = "Reasoning"; +"Sessions" = "Sessions"; +"Projects" = "Projects"; +"Plan metered" = "Plan metered"; +"List-price equivalent" = "List-price equivalent"; +"Metered and list-price" = "Metered and list-price"; +"List-price equivalent — not a billing receipt." = "List-price equivalent — not a billing receipt."; +"Priced" = "Priced"; +"Unpriced" = "Unpriced"; +"Unmetered" = "Unmetered"; +"Estimated" = "Estimated"; +"Other models" = "Other models"; +"Include OpenCodex usage logs" = "Include OpenCodex usage logs"; +"Hide native Codex when OpenCodex is present" = "Hide native Codex when OpenCodex is present"; +"Export JSON" = "Export JSON"; +"Sources" = "Sources"; +"OpenCodex" = "OpenCodex"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index 568c176f7e..e90e08e6b7 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -1464,3 +1464,25 @@ "Total usage" = "Uso total"; "claude_oauth_keychain_access_revoked" = "O acesso às Chaves do Claude foi revogado pela rotação do token do Claude Code. Clique em Atualizar para conceder o acesso novamente ou altere a fonte de uso do Claude para CLI/Web."; "claude_showing_last_known_usage" = "Exibindo o último uso conhecido, capturado %@."; +/* Spend dashboard cost analysis */ +"90d" = "90d"; +"Input" = "Input"; +"Output" = "Output"; +"Cache write" = "Cache write"; +"Reasoning" = "Reasoning"; +"Sessions" = "Sessions"; +"Projects" = "Projects"; +"Plan metered" = "Plan metered"; +"List-price equivalent" = "List-price equivalent"; +"Metered and list-price" = "Metered and list-price"; +"List-price equivalent — not a billing receipt." = "List-price equivalent — not a billing receipt."; +"Priced" = "Priced"; +"Unpriced" = "Unpriced"; +"Unmetered" = "Unmetered"; +"Estimated" = "Estimated"; +"Other models" = "Other models"; +"Include OpenCodex usage logs" = "Include OpenCodex usage logs"; +"Hide native Codex when OpenCodex is present" = "Hide native Codex when OpenCodex is present"; +"Export JSON" = "Export JSON"; +"Sources" = "Sources"; +"OpenCodex" = "OpenCodex"; diff --git a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings index 79df5b3714..2decbbfc40 100644 --- a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings @@ -1465,3 +1465,25 @@ "Total usage" = "Общее использование"; "claude_oauth_keychain_access_revoked" = "Доступ к Связке ключей Claude был отозван из-за ротации токена в Claude Code. Нажмите «Обновить», чтобы повторно предоставить доступ, или переключите источник использования Claude на CLI/Web."; "claude_showing_last_known_usage" = "Показаны последние известные данные об использовании (получены %@)."; +/* Spend dashboard cost analysis */ +"90d" = "90d"; +"Input" = "Input"; +"Output" = "Output"; +"Cache write" = "Cache write"; +"Reasoning" = "Reasoning"; +"Sessions" = "Sessions"; +"Projects" = "Projects"; +"Plan metered" = "Plan metered"; +"List-price equivalent" = "List-price equivalent"; +"Metered and list-price" = "Metered and list-price"; +"List-price equivalent — not a billing receipt." = "List-price equivalent — not a billing receipt."; +"Priced" = "Priced"; +"Unpriced" = "Unpriced"; +"Unmetered" = "Unmetered"; +"Estimated" = "Estimated"; +"Other models" = "Other models"; +"Include OpenCodex usage logs" = "Include OpenCodex usage logs"; +"Hide native Codex when OpenCodex is present" = "Hide native Codex when OpenCodex is present"; +"Export JSON" = "Export JSON"; +"Sources" = "Sources"; +"OpenCodex" = "OpenCodex"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index 3f8f7ca525..a62f9bfe83 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -1462,3 +1462,25 @@ "Total usage" = "Total användning"; "claude_oauth_keychain_access_revoked" = "Åtkomsten till Claudes nyckelring återkallades när Claude Code roterade token. Klicka på Uppdatera för att ge åtkomst igen, eller byt Claudes användningskälla till CLI/Web."; "claude_showing_last_known_usage" = "Visar senast kända användning, registrerad %@."; +/* Spend dashboard cost analysis */ +"90d" = "90d"; +"Input" = "Input"; +"Output" = "Output"; +"Cache write" = "Cache write"; +"Reasoning" = "Reasoning"; +"Sessions" = "Sessions"; +"Projects" = "Projects"; +"Plan metered" = "Plan metered"; +"List-price equivalent" = "List-price equivalent"; +"Metered and list-price" = "Metered and list-price"; +"List-price equivalent — not a billing receipt." = "List-price equivalent — not a billing receipt."; +"Priced" = "Priced"; +"Unpriced" = "Unpriced"; +"Unmetered" = "Unmetered"; +"Estimated" = "Estimated"; +"Other models" = "Other models"; +"Include OpenCodex usage logs" = "Include OpenCodex usage logs"; +"Hide native Codex when OpenCodex is present" = "Hide native Codex when OpenCodex is present"; +"Export JSON" = "Export JSON"; +"Sources" = "Sources"; +"OpenCodex" = "OpenCodex"; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index 79d23ee6ad..ad5b01c1ff 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -1467,3 +1467,25 @@ "Total usage" = "การใช้งานทั้งหมด"; "claude_oauth_keychain_access_revoked" = "สิทธิ์เข้าถึงพวงกุญแจ Claude ถูกเพิกถอนจากการหมุนเวียนโทเค็นของ Claude Code คลิกรีเฟรชเพื่อให้สิทธิ์อีกครั้ง หรือเปลี่ยนแหล่งที่มาการใช้งาน Claude เป็น CLI/Web"; "claude_showing_last_known_usage" = "กำลังแสดงการใช้งานล่าสุดที่ทราบ ซึ่งบันทึกเมื่อ %@"; +/* Spend dashboard cost analysis */ +"90d" = "90d"; +"Input" = "Input"; +"Output" = "Output"; +"Cache write" = "Cache write"; +"Reasoning" = "Reasoning"; +"Sessions" = "Sessions"; +"Projects" = "Projects"; +"Plan metered" = "Plan metered"; +"List-price equivalent" = "List-price equivalent"; +"Metered and list-price" = "Metered and list-price"; +"List-price equivalent — not a billing receipt." = "List-price equivalent — not a billing receipt."; +"Priced" = "Priced"; +"Unpriced" = "Unpriced"; +"Unmetered" = "Unmetered"; +"Estimated" = "Estimated"; +"Other models" = "Other models"; +"Include OpenCodex usage logs" = "Include OpenCodex usage logs"; +"Hide native Codex when OpenCodex is present" = "Hide native Codex when OpenCodex is present"; +"Export JSON" = "Export JSON"; +"Sources" = "Sources"; +"OpenCodex" = "OpenCodex"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index 23fbd135fe..1c2e4ef4c1 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -1465,3 +1465,25 @@ "Total usage" = "Toplam kullanım"; "claude_oauth_keychain_access_revoked" = "Claude Anahtar Zinciri erişimi, Claude Code'un belirteç yenilemesi nedeniyle iptal edildi. Erişimi yeniden vermek için Yenile'ye tıklayın veya Claude Kullanım kaynağını CLI/Web olarak değiştirin."; "claude_showing_last_known_usage" = "Bilinen son kullanım gösteriliyor (yakalanma zamanı: %@)."; +/* Spend dashboard cost analysis */ +"90d" = "90d"; +"Input" = "Input"; +"Output" = "Output"; +"Cache write" = "Cache write"; +"Reasoning" = "Reasoning"; +"Sessions" = "Sessions"; +"Projects" = "Projects"; +"Plan metered" = "Plan metered"; +"List-price equivalent" = "List-price equivalent"; +"Metered and list-price" = "Metered and list-price"; +"List-price equivalent — not a billing receipt." = "List-price equivalent — not a billing receipt."; +"Priced" = "Priced"; +"Unpriced" = "Unpriced"; +"Unmetered" = "Unmetered"; +"Estimated" = "Estimated"; +"Other models" = "Other models"; +"Include OpenCodex usage logs" = "Include OpenCodex usage logs"; +"Hide native Codex when OpenCodex is present" = "Hide native Codex when OpenCodex is present"; +"Export JSON" = "Export JSON"; +"Sources" = "Sources"; +"OpenCodex" = "OpenCodex"; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index fe9fca5782..65448ff73e 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -1463,3 +1463,25 @@ "Total usage" = "Загальне використання"; "claude_oauth_keychain_access_revoked" = "Доступ до В’язки ключів Claude було відкликано через ротацію токена в Claude Code. Натисніть «Оновити», щоб повторно надати доступ, або перемкніть джерело використання Claude на CLI/Web."; "claude_showing_last_known_usage" = "Показано останні відомі дані про використання (отримано %@)."; +/* Spend dashboard cost analysis */ +"90d" = "90d"; +"Input" = "Input"; +"Output" = "Output"; +"Cache write" = "Cache write"; +"Reasoning" = "Reasoning"; +"Sessions" = "Sessions"; +"Projects" = "Projects"; +"Plan metered" = "Plan metered"; +"List-price equivalent" = "List-price equivalent"; +"Metered and list-price" = "Metered and list-price"; +"List-price equivalent — not a billing receipt." = "List-price equivalent — not a billing receipt."; +"Priced" = "Priced"; +"Unpriced" = "Unpriced"; +"Unmetered" = "Unmetered"; +"Estimated" = "Estimated"; +"Other models" = "Other models"; +"Include OpenCodex usage logs" = "Include OpenCodex usage logs"; +"Hide native Codex when OpenCodex is present" = "Hide native Codex when OpenCodex is present"; +"Export JSON" = "Export JSON"; +"Sources" = "Sources"; +"OpenCodex" = "OpenCodex"; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index aea7b56c5a..6c7e669fa8 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -1464,3 +1464,25 @@ "Total usage" = "Tổng mức sử dụng"; "claude_oauth_keychain_access_revoked" = "Quyền truy cập Chuỗi khóa Claude đã bị thu hồi do Claude Code xoay vòng mã thông báo. Nhấp vào Làm mới để cấp lại quyền truy cập hoặc chuyển nguồn sử dụng Claude sang CLI/Web."; "claude_showing_last_known_usage" = "Đang hiển thị mức sử dụng đã biết gần nhất, được ghi nhận %@."; +/* Spend dashboard cost analysis */ +"90d" = "90d"; +"Input" = "Input"; +"Output" = "Output"; +"Cache write" = "Cache write"; +"Reasoning" = "Reasoning"; +"Sessions" = "Sessions"; +"Projects" = "Projects"; +"Plan metered" = "Plan metered"; +"List-price equivalent" = "List-price equivalent"; +"Metered and list-price" = "Metered and list-price"; +"List-price equivalent — not a billing receipt." = "List-price equivalent — not a billing receipt."; +"Priced" = "Priced"; +"Unpriced" = "Unpriced"; +"Unmetered" = "Unmetered"; +"Estimated" = "Estimated"; +"Other models" = "Other models"; +"Include OpenCodex usage logs" = "Include OpenCodex usage logs"; +"Hide native Codex when OpenCodex is present" = "Hide native Codex when OpenCodex is present"; +"Export JSON" = "Export JSON"; +"Sources" = "Sources"; +"OpenCodex" = "OpenCodex"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 574e160118..94dd167507 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -59,6 +59,7 @@ "(System)" = "(System)"; "30d" = "30 天"; "7d" = "7 天"; +"90d" = "90 天"; "A managed Codex login is already running. Wait for it to finish before adding " = "托管 Codex 登录已在运行。请等待其完成后再添加 "; "API key" = "API 密钥"; "API key limit" = "API 密钥限制"; @@ -1306,6 +1307,26 @@ "By subscription" = "按订阅"; "No model-level history" = "暂无模型级历史"; "Daily estimated spend" = "每日估算支出"; +"Input" = "输入"; +"Output" = "输出"; +"Cache write" = "缓存写入"; +"Reasoning" = "推理"; +"Sessions" = "会话"; +"Projects" = "项目"; +"Plan metered" = "套餐计量"; +"List-price equivalent" = "目录价等值"; +"Metered and list-price" = "计量与目录价"; +"List-price equivalent — not a billing receipt." = "目录价等值,不是账单。"; +"Priced" = "已定价"; +"Unpriced" = "未定价"; +"Unmetered" = "未计量"; +"Estimated" = "估算"; +"Other models" = "其他模型"; +"Include OpenCodex usage logs" = "纳入 OpenCodex 用量日志"; +"Hide native Codex when OpenCodex is present" = "存在 OpenCodex 时隐藏原生 Codex"; +"Export JSON" = "导出 JSON"; +"Sources" = "来源"; +"OpenCodex" = "OpenCodex"; "≈%d full 5h windows of weekly left · %d windows until reset" = "每周额度约剩 %d 个完整 5 小时窗口 · 距重置还有 %d 个窗口"; "Weekly cannot run out before reset at this pace" = "按此速度,每周额度无法在重置前用完"; "Weekly can run out ≈%d windows early" = "每周额度可能提前约 %d 个窗口用完"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index 7cf56026eb..2eb83c29e8 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -1494,3 +1494,25 @@ "Total usage" = "總用量"; "claude_oauth_keychain_access_revoked" = "Claude Code 輪替權杖後撤銷了 Claude 鑰匙圈的存取權。按一下「重新整理」以重新授權,或將 Claude 使用量來源切換為 CLI/Web。"; "claude_showing_last_known_usage" = "正在顯示於 %@ 擷取的最後已知使用量。"; +/* Spend dashboard cost analysis */ +"90d" = "90d"; +"Input" = "Input"; +"Output" = "Output"; +"Cache write" = "Cache write"; +"Reasoning" = "Reasoning"; +"Sessions" = "Sessions"; +"Projects" = "Projects"; +"Plan metered" = "Plan metered"; +"List-price equivalent" = "List-price equivalent"; +"Metered and list-price" = "Metered and list-price"; +"List-price equivalent — not a billing receipt." = "List-price equivalent — not a billing receipt."; +"Priced" = "Priced"; +"Unpriced" = "Unpriced"; +"Unmetered" = "Unmetered"; +"Estimated" = "Estimated"; +"Other models" = "Other models"; +"Include OpenCodex usage logs" = "Include OpenCodex usage logs"; +"Hide native Codex when OpenCodex is present" = "Hide native Codex when OpenCodex is present"; +"Export JSON" = "Export JSON"; +"Sources" = "Sources"; +"OpenCodex" = "OpenCodex"; diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index c1e59085d4..e5fe14eb9b 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -517,6 +517,9 @@ extension SettingsStore { if changed { self.costUsageSettingsRevision &+= 1 } + if newValue { + self.pinCostUsageBucketTimeZoneIfNeeded() + } self.noteBackgroundWorkSettingsChanged() } } @@ -544,6 +547,66 @@ extension SettingsStore { } } + var costUsageBucketTimeZoneIdentifier: String { + get { self.defaultsState.costUsageBucketTimeZoneIdentifier } + set { + let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) + let normalized = CostUsageBucketTimeZone.isValidIdentifier(trimmed) ? trimmed : "" + let changed = self.defaultsState.costUsageBucketTimeZoneIdentifier != normalized + self.defaultsState.costUsageBucketTimeZoneIdentifier = normalized + self.userDefaults.set(normalized, forKey: "tokenCostUsageBucketTimeZone") + if changed { + self.costUsageSettingsRevision &+= 1 + } + } + } + + var costUsageBucketCalendar: Calendar { + CostUsageBucketTimeZone.calendar(identifier: self.costUsageBucketTimeZoneIdentifier) + } + + var openCodexUsageLogsEnabled: Bool { + get { self.defaultsState.openCodexUsageLogsEnabled } + set { + let changed = self.defaultsState.openCodexUsageLogsEnabled != newValue + self.defaultsState.openCodexUsageLogsEnabled = newValue + self.userDefaults.set(newValue, forKey: "openCodexUsageLogsEnabled") + if changed { + self.costUsageSettingsRevision &+= 1 + } + } + } + + var hideNativeCodexCostWhenOpenCodexPresent: Bool { + get { self.defaultsState.hideNativeCodexCostWhenOpenCodexPresent } + set { + let changed = self.defaultsState.hideNativeCodexCostWhenOpenCodexPresent != newValue + self.defaultsState.hideNativeCodexCostWhenOpenCodexPresent = newValue + self.userDefaults.set(newValue, forKey: "hideNativeCodexCostWhenOpenCodexPresent") + if changed { + self.costUsageSettingsRevision &+= 1 + } + } + } + + var spendDashboardHiddenSourceIDs: [String] { + get { self.defaultsState.spendDashboardHiddenSourceIDs } + set { + let normalized = Array(Set(newValue.filter { !$0.isEmpty })).sorted() + let changed = self.defaultsState.spendDashboardHiddenSourceIDs != normalized + self.defaultsState.spendDashboardHiddenSourceIDs = normalized + self.userDefaults.set(normalized, forKey: "spendDashboardHiddenSourceIDs") + if changed { + self.costUsageSettingsRevision &+= 1 + } + } + } + + func pinCostUsageBucketTimeZoneIfNeeded() { + guard self.costUsageBucketTimeZoneIdentifier.isEmpty else { return } + self.costUsageBucketTimeZoneIdentifier = CostUsageBucketTimeZone.pinIdentifier() + } + var costComparisonPeriodsEnabled: Bool { get { self.defaultsState.costComparisonPeriodsEnabled } set { diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index 3b01482606..d144a963a5 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -46,6 +46,10 @@ extension SettingsStore { _ = self.costUsageEnabled _ = self.codexLocalSessionCostLedgerEnabled _ = self.costUsageHistoryDays + _ = self.costUsageBucketTimeZoneIdentifier + _ = self.openCodexUsageLogsEnabled + _ = self.hideNativeCodexCostWhenOpenCodexPresent + _ = self.spendDashboardHiddenSourceIDs _ = self.costComparisonPeriodsEnabled _ = self.costSummaryDisplayStyle _ = self.appLanguage diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 22cea5d532..93a8bc56e8 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -546,6 +546,17 @@ extension SettingsStore { forKey: "codexLocalSessionCostLedgerEnabled") as? Bool ?? false let rawCostUsageHistoryDays = userDefaults.object(forKey: "tokenCostUsageHistoryDays") as? Int ?? 30 let costUsageHistoryDays = max(1, min(365, rawCostUsageHistoryDays)) + let storedBucketTimeZone = userDefaults.string(forKey: "tokenCostUsageBucketTimeZone") ?? "" + let costUsageBucketTimeZoneIdentifier = CostUsageBucketTimeZone.isValidIdentifier(storedBucketTimeZone) + ? storedBucketTimeZone + : (costUsageEnabled ? CostUsageBucketTimeZone.pinIdentifier() : "") + if costUsageEnabled, storedBucketTimeZone.isEmpty, !costUsageBucketTimeZoneIdentifier.isEmpty { + userDefaults.set(costUsageBucketTimeZoneIdentifier, forKey: "tokenCostUsageBucketTimeZone") + } + let openCodexUsageLogsEnabled = userDefaults.object(forKey: "openCodexUsageLogsEnabled") as? Bool ?? false + let hideNativeCodexCostWhenOpenCodexPresent = userDefaults.object( + forKey: "hideNativeCodexCostWhenOpenCodexPresent") as? Bool ?? false + let spendDashboardHiddenSourceIDs = userDefaults.stringArray(forKey: "spendDashboardHiddenSourceIDs") ?? [] let costComparisonPeriodsEnabled = userDefaults.object( forKey: "costComparisonPeriodsEnabled") as? Bool ?? false let costSummaryDisplayStyleRaw = Self.loadCostSummaryDisplayStyleRaw( @@ -673,6 +684,10 @@ extension SettingsStore { costUsageEnabled: costUsageEnabled, codexLocalSessionCostLedgerEnabled: codexLocalSessionCostLedgerEnabled, costUsageHistoryDays: costUsageHistoryDays, + costUsageBucketTimeZoneIdentifier: costUsageBucketTimeZoneIdentifier, + openCodexUsageLogsEnabled: openCodexUsageLogsEnabled, + hideNativeCodexCostWhenOpenCodexPresent: hideNativeCodexCostWhenOpenCodexPresent, + spendDashboardHiddenSourceIDs: spendDashboardHiddenSourceIDs, costComparisonPeriodsEnabled: costComparisonPeriodsEnabled, costSummaryDisplayStyleRaw: costSummaryDisplayStyleRaw, hidePersonalInfo: hidePersonalInfo, diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index d34a34926a..b1547135c2 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -47,6 +47,10 @@ struct SettingsDefaultsState { var costUsageEnabled: Bool var codexLocalSessionCostLedgerEnabled: Bool var costUsageHistoryDays: Int + var costUsageBucketTimeZoneIdentifier: String + var openCodexUsageLogsEnabled: Bool + var hideNativeCodexCostWhenOpenCodexPresent: Bool + var spendDashboardHiddenSourceIDs: [String] var costComparisonPeriodsEnabled: Bool var costSummaryDisplayStyleRaw: String var hidePersonalInfo: Bool diff --git a/Sources/CodexBar/SpendActivityHeatmap.swift b/Sources/CodexBar/SpendActivityHeatmap.swift index a74b0d6d17..b27840a79f 100644 --- a/Sources/CodexBar/SpendActivityHeatmap.swift +++ b/Sources/CodexBar/SpendActivityHeatmap.swift @@ -20,6 +20,14 @@ enum SpendActivityViewMode: String, CaseIterable, Identifiable { } } +enum SpendActivityDaySelection { + static func day(from series: SpendActivitySeries, at index: Int, selectedDay: Date?) -> Date? { + guard series.isCovered.indices.contains(index), series.isCovered[index] else { return nil } + let day = series.date(at: index).map { series.calendar.startOfDay(for: $0) } + return day == selectedDay ? nil : day + } +} + struct SpendActivitySeries { static let weekCount = 53 static let dayCount = 7 @@ -364,13 +372,22 @@ enum SpendActivityAccessibility { struct SpendActivityHeatmapView: View { let points: [SpendDashboardModel.TokenActivityPoint] let now: Date + let selectedDay: Date? + let onSelectDay: ((Date?) -> Void)? @AppStorage("spendActivityViewMode") private var mode: SpendActivityViewMode = .daily @State private var series: SpendActivitySeries - init(points: [SpendDashboardModel.TokenActivityPoint], now: Date = Date()) { + init( + points: [SpendDashboardModel.TokenActivityPoint], + now: Date = Date(), + selectedDay: Date? = nil, + onSelectDay: ((Date?) -> Void)? = nil) + { self.points = points self.now = now + self.selectedDay = selectedDay + self.onSelectDay = onSelectDay self._series = State(initialValue: SpendActivitySeries.make(from: points, now: now)) } @@ -411,7 +428,10 @@ struct SpendActivityHeatmapView: View { if hasActivity || hasUnknownCoverage { switch self.mode { case .daily: - SpendActivityDailyGrid(series: self.series) + SpendActivityDailyGrid( + series: self.series, + selectedDay: self.selectedDay, + onSelectDay: self.onSelectDay) self.dailyLegend case .weekly: SpendActivityWeekGrid( @@ -496,6 +516,8 @@ struct SpendActivityHeatmapView: View { private struct SpendActivityDailyGrid: View { let series: SpendActivitySeries + var selectedDay: Date? + var onSelectDay: ((Date?) -> Void)? @State private var hoveredIndex: Int? @State private var keyboardIndex: Int? @@ -546,6 +568,9 @@ private struct SpendActivityDailyGrid: View { self.hoveredIndex = nil } } + .gesture(SpatialTapGesture().onEnded { event in + self.handleTap(at: event.location, pitch: pitch) + }) .offset(x: gridFrame.minX) } } @@ -662,6 +687,12 @@ private struct SpendActivityDailyGrid: View { return self.isVisibleCell(index) ? index : nil } + private func handleTap(at location: CGPoint, pitch: CGFloat) { + guard let onSelectDay, let index = self.cellIndex(at: location, pitch: pitch) else { return } + guard self.series.isCovered.indices.contains(index), self.series.isCovered[index] else { return } + onSelectDay(SpendActivityDaySelection.day(from: self.series, at: index, selectedDay: self.selectedDay)) + } + private func isVisibleCell(_ index: Int) -> Bool { self.series.isVisible(index) } diff --git a/Sources/CodexBar/SpendDashboardController.swift b/Sources/CodexBar/SpendDashboardController.swift index a11a222270..505124fac7 100644 --- a/Sources/CodexBar/SpendDashboardController.swift +++ b/Sources/CodexBar/SpendDashboardController.swift @@ -11,6 +11,10 @@ struct SpendDashboardConfiguration: Equatable, Sendable { let codexAccountDisplayNames: [String: String] let sourceOwnershipFingerprints: [String] let sourceRevisions: [String] + let bucketTimeZoneIdentifier: String + let openCodexUsageLogsEnabled: Bool + let hideNativeCodexCostWhenOpenCodexPresent: Bool + let hiddenSourceIDs: [String] init( costUsageEnabled: Bool, @@ -19,7 +23,11 @@ struct SpendDashboardConfiguration: Equatable, Sendable { codexAccountIdentities: [String], codexAccountDisplayNames: [String: String] = [:], sourceOwnershipFingerprints: [String] = [], - sourceRevisions: [String] = []) + sourceRevisions: [String] = [], + bucketTimeZoneIdentifier: String = "", + openCodexUsageLogsEnabled: Bool = false, + hideNativeCodexCostWhenOpenCodexPresent: Bool = false, + hiddenSourceIDs: [String] = []) { self.costUsageEnabled = costUsageEnabled self.preferredCurrencyCode = preferredCurrencyCode @@ -28,6 +36,14 @@ struct SpendDashboardConfiguration: Equatable, Sendable { self.codexAccountDisplayNames = codexAccountDisplayNames self.sourceOwnershipFingerprints = sourceOwnershipFingerprints self.sourceRevisions = sourceRevisions + self.bucketTimeZoneIdentifier = bucketTimeZoneIdentifier + self.openCodexUsageLogsEnabled = openCodexUsageLogsEnabled + self.hideNativeCodexCostWhenOpenCodexPresent = hideNativeCodexCostWhenOpenCodexPresent + self.hiddenSourceIDs = hiddenSourceIDs + } + + var bucketCalendar: Calendar { + CostUsageBucketTimeZone.calendar(identifier: self.bucketTimeZoneIdentifier) } } @@ -115,6 +131,7 @@ struct CodexSpendSnapshotLoadContext: Sendable { let historyDays: Int let refreshPricingInBackground: Bool let includePiSessions: Bool + let calendar: Calendar } enum SpendDashboardSource { @@ -161,7 +178,11 @@ enum SpendDashboardSource { providers: providers, settings: settings, store: store), - sourceRevisions: self.sourceRevisions(providers: providers, settings: settings, store: store)) + sourceRevisions: self.sourceRevisions(providers: providers, settings: settings, store: store), + bucketTimeZoneIdentifier: settings.costUsageBucketTimeZoneIdentifier, + openCodexUsageLogsEnabled: settings.openCodexUsageLogsEnabled, + hideNativeCodexCostWhenOpenCodexPresent: settings.hideNativeCodexCostWhenOpenCodexPresent, + hiddenSourceIDs: settings.spendDashboardHiddenSourceIDs) } @MainActor @@ -295,13 +316,14 @@ enum SpendDashboardSource { request, cacheRootResolver: cacheRootResolver, cachedCodexSnapshotLoader: { context in - await CostUsageFetcher(cacheRoot: context.cacheRoot) + await CostUsageFetcher(cacheRoot: context.cacheRoot, calendar: context.calendar) .loadCachedCodexTokenSnapshotForScopedHome( now: context.now, codexHomePath: context.account.homePath, historyDays: context.historyDays, includePiSessions: false, - includeProjectAndSessionBreakdowns: true) + includeProjectAndSessionBreakdowns: true, + calendar: context.calendar) }) } @@ -325,14 +347,12 @@ enum SpendDashboardSource { guard !Task.isCancelled, self.currentAuthFingerprint(for: account) == account.authFingerprint else { continue } - let snapshot = await cachedCodexSnapshotLoader(CodexSpendSnapshotLoadContext( + let snapshot = await cachedCodexSnapshotLoader(self.snapshotContext( account: account, cacheRoot: cacheRootResolver(account), - now: request.now, + request: request, force: false, - historyDays: Self.scanDays, - refreshPricingInBackground: false, - includePiSessions: false)) + historyDays: Self.scanDays)) guard !Task.isCancelled, let snapshot, self.currentAuthFingerprint(for: account) == account.authFingerprint @@ -344,7 +364,9 @@ enum SpendDashboardSource { modelProviderName: ProviderDescriptorRegistry.descriptor(for: .codex).metadata.displayName, snapshot: snapshot)) } - return SpendDashboardLoadResult(inputs: inputs, failedSourceIDs: request.unavailableSourceIDs) + return SpendDashboardLoadResult( + inputs: self.appendingOpenCodexInput(inputs, request: request), + failedSourceIDs: request.unavailableSourceIDs) } static func load( @@ -388,23 +410,19 @@ enum SpendDashboardSource { continue } let cacheRoot = cacheRootResolver(account) - let snapshot = try await codexSnapshotLoader(CodexSpendSnapshotLoadContext( + let snapshot = try await codexSnapshotLoader(self.snapshotContext( account: account, cacheRoot: cacheRoot, - now: request.now, + request: request, force: request.force, - historyDays: Self.scanDays, - refreshPricingInBackground: false, - includePiSessions: false)) + historyDays: Self.scanDays)) try Task.checkCancellation() - let tokenActivityCache = await codexActivityLoader(CodexSpendSnapshotLoadContext( + let tokenActivityCache = await codexActivityLoader(self.snapshotContext( account: account, cacheRoot: cacheRoot, - now: request.now, + request: request, force: false, - historyDays: Self.activityDays, - refreshPricingInBackground: false, - includePiSessions: false)) + historyDays: Self.activityDays)) try Task.checkCancellation() guard self.codexAuthFingerprintMatches(account) else { failedSourceIDs.insert(sourceID) @@ -437,15 +455,73 @@ enum SpendDashboardSource { invalidatedSourceIDs.formUnion(lateInvalidatedSourceIDs) inputs.removeAll { lateInvalidatedSourceIDs.contains($0.id) } return SpendDashboardLoadResult( - inputs: inputs, + inputs: self.appendingOpenCodexInput(inputs, request: request), failedSourceIDs: failedSourceIDs, invalidatedSourceIDs: invalidatedSourceIDs) } + private static func snapshotContext( + account: CodexSpendScanRequest, + cacheRoot: URL, + request: SpendDashboardLoadRequest, + force: Bool, + historyDays: Int) -> CodexSpendSnapshotLoadContext + { + CodexSpendSnapshotLoadContext( + account: account, + cacheRoot: cacheRoot, + now: request.now, + force: force, + historyDays: historyDays, + refreshPricingInBackground: false, + includePiSessions: false, + calendar: request.configuration.bucketCalendar) + } + + private static func appendingOpenCodexInput( + _ inputs: [SpendDashboardModel.ProviderInput], + request: SpendDashboardLoadRequest) -> [SpendDashboardModel.ProviderInput] + { + guard request.configuration.openCodexUsageLogsEnabled, + let input = self.loadOpenCodexInput(request: request) + else { return inputs } + var merged = inputs + merged.removeAll { $0.id == SpendDashboardModel.openCodexSourceID } + merged.append(input) + return merged + } + + private static func loadOpenCodexInput( + request: SpendDashboardLoadRequest) -> SpendDashboardModel.ProviderInput? + { + let environment = ProcessInfo.processInfo.environment + guard let logURL = OpenCodexUsageLog.usageLogURL(environment: environment) else { return nil } + let cacheRoot = OpenCodexUsageLog.cacheRoot(environment: environment) + let store = OpenCodexUsageStore(cacheRoot: cacheRoot) + guard let snapshot = try? store.loadSnapshot( + logURL: logURL, + now: request.now, + historyDays: Self.scanDays, + calendar: request.configuration.bucketCalendar), + Self.shouldPublishOpenCodexSnapshot(snapshot) + else { return nil } + return SpendDashboardModel.ProviderInput( + id: SpendDashboardModel.openCodexSourceID, + provider: .codex, + displayName: OpenCodexUsageLog.displayName, + modelProviderName: OpenCodexUsageLog.displayName, + snapshot: snapshot, + sourceKind: .openCodex) + } + + static func shouldPublishOpenCodexSnapshot(_ snapshot: CostUsageTokenSnapshot) -> Bool { + !snapshot.daily.isEmpty || !snapshot.sessions.isEmpty + } + private static func loadCodexSnapshot( _ context: CodexSpendSnapshotLoadContext) async throws -> CostUsageTokenSnapshot { - try await CostUsageFetcher(cacheRoot: context.cacheRoot).loadTokenSnapshot( + try await CostUsageFetcher(cacheRoot: context.cacheRoot, calendar: context.calendar).loadTokenSnapshot( provider: .codex, environment: CodexHomeScope.scopedEnvironment(base: [:], codexHome: context.account.homePath), now: context.now, @@ -459,7 +535,7 @@ enum SpendDashboardSource { private static func loadCodexActivity( _ context: CodexSpendSnapshotLoadContext) async -> CostUsageTokenActivityCache? { - await CostUsageFetcher(cacheRoot: context.cacheRoot).loadCachedCodexTokenActivity( + await CostUsageFetcher(cacheRoot: context.cacheRoot, calendar: context.calendar).loadCachedCodexTokenActivity( now: context.now, codexHomePath: context.account.homePath, maximumDays: context.historyDays) @@ -504,6 +580,9 @@ enum SpendDashboardSource { if providers.contains(.codex) { revisions.append("codex-dashboard:\(store.spendDashboardCodexCostCatchUpRevision)") } + if settings.openCodexUsageLogsEnabled { + revisions.append("opencodex:\(settings.costUsageSettingsRevision)") + } revisions += providers.compactMap { provider in // Provider-specific by design: Codex revisions come from catch-up, not captured token publications. guard provider != .codex else { return nil } @@ -858,6 +937,7 @@ final class SpendDashboardController { private(set) var generation: UInt64 = 0 private(set) var configuration: SpendDashboardConfiguration? private(set) var selectedDays: Int + private(set) var selectedDay: Date? private static let daysDefaultsKey = "settingsSpendDashboardDays" private let userDefaults: UserDefaults @@ -934,7 +1014,9 @@ final class SpendDashboardController { false } - guard configuration.costUsageEnabled, !configuration.providerIDs.isEmpty else { + guard configuration.costUsageEnabled, + !configuration.providerIDs.isEmpty || configuration.openCodexUsageLogsEnabled + else { self.loadedInputs = [] self.failedSourceCount = 0 self.isRefreshing = false @@ -1183,6 +1265,14 @@ final class SpendDashboardController { self.rebuildModel() } + func selectDay(_ day: Date?) { + let calendar = self.configuration?.bucketCalendar ?? .current + let normalized = day.map { calendar.startOfDay(for: $0) } + guard normalized != self.selectedDay else { return } + self.selectedDay = normalized + self.rebuildModel() + } + func refreshDateWindow(now: Date? = nil) { self.loadedAt = now ?? self.nowProvider() self.rebuildModel() @@ -1200,11 +1290,16 @@ final class SpendDashboardController { } private func rebuildModel() { + let configuration = self.configuration self.model = SpendDashboardModel.build( inputs: self.loadedInputs, requestedDays: self.selectedDays, now: self.loadedAt, - preferredCurrencyCode: self.configuration?.preferredCurrencyCode ?? "auto") + calendar: configuration?.bucketCalendar ?? .current, + preferredCurrencyCode: configuration?.preferredCurrencyCode ?? "auto", + hiddenSourceIDs: Set(configuration?.hiddenSourceIDs ?? []), + hideNativeCodexWhenOpenCodexPresent: configuration?.hideNativeCodexCostWhenOpenCodexPresent ?? false, + selectedDay: self.selectedDay) } private func refreshRetainedCodexDisplayNames(_ displayNamesByID: [String: String]) { @@ -1233,7 +1328,9 @@ final class SpendDashboardController { provider: input.provider, displayName: displayName, modelProviderName: input.modelProviderName, - snapshot: input.snapshot) + snapshot: input.snapshot, + tokenActivityCache: input.tokenActivityCache, + sourceKind: input.sourceKind) } private static func sameSourceOwnership( @@ -1283,7 +1380,7 @@ final class SpendDashboardController { }) } - private static let supportedDayRanges = [7, 30, SpendDashboardSource.scanDays] + private static let supportedDayRanges = [7, 30, 90, SpendDashboardSource.scanDays] private static func normalizedDays(_ value: Int) -> Int { self.supportedDayRanges.contains(value) ? value : 30 diff --git a/Sources/CodexBar/SpendDashboardModel+ModelBreakdown.swift b/Sources/CodexBar/SpendDashboardModel+ModelBreakdown.swift index c492cdc438..4e51b6adf5 100644 --- a/Sources/CodexBar/SpendDashboardModel+ModelBreakdown.swift +++ b/Sources/CodexBar/SpendDashboardModel+ModelBreakdown.swift @@ -13,6 +13,7 @@ extension SpendDashboardModel { let providerName: String var tokens: Int? var cost: Double? + var mix = CostUsageTokenMix() var sawTokens = false var sawCost = false var invalidTokens = false @@ -65,6 +66,12 @@ extension SpendDashboardModel { } else { aggregate.invalidCost = true } + aggregate.mix.merge(CostUsageTokenMix( + inputTokens: breakdown.inputTokens, + outputTokens: breakdown.outputTokens, + cacheReadTokens: breakdown.cacheReadTokens, + cacheCreationTokens: breakdown.cacheCreationTokens, + reasoningTokens: breakdown.reasoningTokens)) aggregates[key] = aggregate } } @@ -82,7 +89,8 @@ extension SpendDashboardModel { providerName: value.providerName, modelName: key.modelName, totalTokens: value.sawTokens && !value.invalidTokens && !value.overflowedTokens ? value.tokens : nil, - totalCost: value.sawCost && !value.invalidCost && !value.overflowedCost ? value.cost : nil) + totalCost: value.sawCost && !value.invalidCost && !value.overflowedCost ? value.cost : nil, + tokenMix: value.mix) } .sorted { lhs, rhs in switch (lhs.totalCost, rhs.totalCost) { @@ -104,7 +112,8 @@ extension SpendDashboardModel { providerName: row.providerName, modelName: row.modelName, totalTokens: row.totalTokens, - totalCost: row.totalCost) + totalCost: row.totalCost, + tokenMix: row.tokenMix) } return ModelSummary(rows: rows, completeness: completeness) } diff --git a/Sources/CodexBar/SpendDashboardModel.swift b/Sources/CodexBar/SpendDashboardModel.swift index 076ff1d5b2..551fd254ea 100644 --- a/Sources/CodexBar/SpendDashboardModel.swift +++ b/Sources/CodexBar/SpendDashboardModel.swift @@ -1,7 +1,14 @@ import CodexBarCore import Foundation +// swiftlint:disable:next type_body_length struct SpendDashboardModel: Equatable, Sendable { + enum SourceKind: String, Sendable, Equatable { + case native + case openCodex + } + + static let openCodexSourceID = "opencodex" struct ProviderInput: Sendable { let id: String let provider: UsageProvider @@ -16,7 +23,8 @@ struct SpendDashboardModel: Equatable, Sendable { displayName: String, modelProviderName: String? = nil, snapshot: CostUsageTokenSnapshot, - tokenActivityCache: CostUsageTokenActivityCache? = nil) + tokenActivityCache: CostUsageTokenActivityCache? = nil, + sourceKind: SpendDashboardModel.SourceKind = .native) { self.id = id ?? provider.rawValue self.provider = provider @@ -24,7 +32,15 @@ struct SpendDashboardModel: Equatable, Sendable { self.modelProviderName = modelProviderName ?? displayName self.snapshot = snapshot self.tokenActivityCache = tokenActivityCache + self.sourceKind = sourceKind } + + let sourceKind: SpendDashboardModel.SourceKind + } + + struct SourceFilterItem: Identifiable, Equatable, Sendable { + let id: String + let displayName: String } struct ProviderRow: Identifiable, Equatable, Sendable { @@ -35,6 +51,27 @@ struct SpendDashboardModel: Equatable, Sendable { let totalTokens: Int? let totalCost: Double? let coveredDayCount: Int + let sourceKind: SourceKind + + init( + id: String, + rank: Int, + provider: UsageProvider, + displayName: String, + totalTokens: Int?, + totalCost: Double?, + coveredDayCount: Int, + sourceKind: SourceKind = .native) + { + self.id = id + self.rank = rank + self.provider = provider + self.displayName = displayName + self.totalTokens = totalTokens + self.totalCost = totalCost + self.coveredDayCount = coveredDayCount + self.sourceKind = sourceKind + } } struct ModelRow: Identifiable, Equatable, Sendable { @@ -44,10 +81,29 @@ struct SpendDashboardModel: Equatable, Sendable { let modelName: String let totalTokens: Int? let totalCost: Double? + let tokenMix: CostUsageTokenMix var id: String { "\(self.provider.rawValue):\(self.modelName)" } + + init( + rank: Int, + provider: UsageProvider, + providerName: String, + modelName: String, + totalTokens: Int?, + totalCost: Double?, + tokenMix: CostUsageTokenMix = CostUsageTokenMix()) + { + self.rank = rank + self.provider = provider + self.providerName = providerName + self.modelName = modelName + self.totalTokens = totalTokens + self.totalCost = totalCost + self.tokenMix = tokenMix + } } /// A project roll-up scoped to the requested window. Projects are keyed per source so @@ -111,33 +167,113 @@ struct SpendDashboardModel: Equatable, Sendable { let currencyCode: String let providers: [ProviderRow] let models: [ModelRow] - let projects: [ProjectRow] let dailyPoints: [DailyPoint] let totalTokens: Int? let totalCost: Double? let coveredDayCount: Int let chartDomain: ClosedRange let modelHistoryCompleteness: ModelHistoryCompleteness + let tokenMix: CostUsageTokenMix + let coverage: CostUsageCoverageCounts + let provenance: CostProvenance + let meteredCost: Double? + let sessions: [SessionRow] + let projects: [ProjectRow] + let overflowModelCount: Int + let displayedModels: [ModelRow] + let selectedDay: Date? var id: String { self.currencyCode } + + init( + currencyCode: String, + providers: [ProviderRow], + models: [ModelRow], + projects: [ProjectRow] = [], + dailyPoints: [DailyPoint], + totalTokens: Int?, + totalCost: Double?, + coveredDayCount: Int, + chartDomain: ClosedRange, + modelHistoryCompleteness: ModelHistoryCompleteness, + tokenMix: CostUsageTokenMix = CostUsageTokenMix(), + coverage: CostUsageCoverageCounts = CostUsageCoverageCounts(), + provenance: CostProvenance = .unknown, + meteredCost: Double? = nil, + sessions: [SessionRow] = [], + overflowModelCount: Int = 0, + selectedDay: Date? = nil) + { + self.currencyCode = currencyCode + self.providers = providers + self.models = models + self.dailyPoints = dailyPoints + self.totalTokens = totalTokens + self.totalCost = totalCost + self.coveredDayCount = coveredDayCount + self.chartDomain = chartDomain + self.modelHistoryCompleteness = modelHistoryCompleteness + self.tokenMix = tokenMix + self.coverage = coverage + self.provenance = provenance + self.meteredCost = meteredCost + self.sessions = sessions + self.projects = projects + self.overflowModelCount = overflowModelCount + self.displayedModels = Array(models.prefix(Self.modelRowDisplayLimit)) + self.selectedDay = selectedDay + } + + static let modelRowDisplayLimit = 8 + } + + struct SessionRow: Identifiable, Equatable, Sendable { + let id: String + let sourceID: String + let provider: UsageProvider + let displayName: String + let lastActivity: Date + let totalTokens: Int? + let totalCost: Double? + let modelName: String? + } + + struct HourlyPoint: Identifiable, Equatable, Sendable { + let hour: Date + let tokens: Int + let cost: Double? + + var id: Date { + self.hour + } } let requestedDays: Int let groups: [CurrencyGroup] + let availableSources: [SourceFilterItem] let tokenActivity: [TokenActivityPoint] + let hourlyPoints: [HourlyPoint] + let selectedDay: Date? static let tokenActivityDayCount = 365 + static let modelRowDisplayLimit = 8 init( requestedDays: Int, groups: [CurrencyGroup], - tokenActivity: [TokenActivityPoint] = []) + availableSources: [SourceFilterItem] = [], + tokenActivity: [TokenActivityPoint] = [], + hourlyPoints: [HourlyPoint] = [], + selectedDay: Date? = nil) { self.requestedDays = requestedDays self.groups = groups + self.availableSources = availableSources self.tokenActivity = tokenActivity + self.hourlyPoints = hourlyPoints + self.selectedDay = selectedDay } static func build( @@ -145,11 +281,21 @@ struct SpendDashboardModel: Equatable, Sendable { requestedDays: Int, now: Date, calendar: Calendar = .current, - preferredCurrencyCode: String = "auto") -> Self + preferredCurrencyCode: String = "auto", + hiddenSourceIDs: Set = [], + hideNativeCodexWhenOpenCodexPresent: Bool = false, + selectedDay: Date? = nil) -> Self { let days = max(1, min(SpendDashboardSource.scanDays, requestedDays)) let calculationCalendar = Self.gregorianCalendar(timeZone: calendar.timeZone) - let classifiedInputs = inputs.compactMap { input -> ClassifiedInput? in + let availableSources = inputs + .map { SourceFilterItem(id: $0.id, displayName: $0.displayName) } + .sorted { $0.id < $1.id } + let visibleInputs = Self.visibleInputs( + inputs, + hiddenSourceIDs: hiddenSourceIDs, + hideNativeCodexWhenOpenCodexPresent: hideNativeCodexWhenOpenCodexPresent) + let classifiedInputs = visibleInputs.compactMap { input -> ClassifiedInput? in guard let sourceCurrencyCode = Self.currencyCode(input.snapshot.currencyCode) else { return nil } let targetCurrencyCode = UsageFormatter.effectiveCurrencyCode( preferred: preferredCurrencyCode, @@ -170,16 +316,37 @@ struct SpendDashboardModel: Equatable, Sendable { inputs: inputs, days: days, now: now, - calendar: calculationCalendar) + calendar: calculationCalendar, + selectedDay: selectedDay.map { calculationCalendar.startOfDay(for: $0) }) } .sorted { $0.currencyCode < $1.currencyCode } return Self( requestedDays: days, groups: groups, + availableSources: availableSources, tokenActivity: Self.tokenActivity( - inputs: inputs, + inputs: visibleInputs, now: now, - calendar: calculationCalendar)) + calendar: calculationCalendar), + hourlyPoints: Self.hourlyPoints( + inputs: visibleInputs, + days: days, + now: now, + calendar: calculationCalendar), + selectedDay: selectedDay.map { calculationCalendar.startOfDay(for: $0) }) + } + + static func visibleInputs( + _ inputs: [ProviderInput], + hiddenSourceIDs: Set, + hideNativeCodexWhenOpenCodexPresent: Bool) -> [ProviderInput] + { + var filtered = inputs.filter { !hiddenSourceIDs.contains($0.id) } + let hasOpenCodex = filtered.contains { $0.sourceKind == .openCodex } + if hideNativeCodexWhenOpenCodexPresent, hasOpenCodex { + filtered.removeAll { $0.sourceKind == .native && $0.provider == .codex } + } + return filtered } private struct ClassifiedInput { @@ -242,12 +409,14 @@ struct SpendDashboardModel: Equatable, Sendable { } } + // swiftlint:disable:next function_parameter_count private static func buildCurrencyGroup( currencyCode: String, inputs: [ClassifiedInput], days: Int, now: Date, - calendar: Calendar) -> CurrencyGroup + calendar: Calendar, + selectedDay: Date?) -> CurrencyGroup { let bounds = Self.bounds(days: days, now: now, calendar: calendar) let summaries = inputs.map { classified in @@ -258,7 +427,8 @@ struct SpendDashboardModel: Equatable, Sendable { calendar: calendar) } let providers = Self.providerRows(summaries) - let modelSummaries = summaries.filter { summary in + let scopedSummaries = Self.summaries(summaries, matching: selectedDay) + let modelSummaries = scopedSummaries.filter { summary in let summaryModelHistory = Self.modelSummary(summaries: [summary]) if summary.totalCost != nil { return summaryModelHistory.completeness == .complete || @@ -269,11 +439,39 @@ struct SpendDashboardModel: Equatable, Sendable { // Unpriced named models can still list. Incomplete priced coverage stays hidden so a // partial list cannot look like a lower-bound total. let modelSummary = Self.modelSummary(summaries: modelSummaries) - let modelHistoryCompleteness = modelSummaries.count == summaries.count && + let modelHistoryCompleteness = modelSummaries.count == scopedSummaries.count && modelSummary.completeness == .complete ? ModelHistoryCompleteness.complete : ModelHistoryCompleteness.incomplete let dailyPoints = Self.dailyPoints(summaries: summaries) + var tokenMix = CostUsageTokenMix() + var coverage = CostUsageCoverageCounts() + var metered: Double? + var sawMetered = false + var sawEstimate = false + for summary in scopedSummaries { + for windowEntry in summary.entries { + tokenMix.merge(.from(entry: windowEntry.entry)) + coverage.merge(windowEntry.entry.coverageCounts) + } + if selectedDay == nil, + let meteredCost = summary.input.snapshot.meteredCostUSD, + days >= summary.input.snapshot.historyDays + { + sawMetered = true + metered = (metered ?? 0) + meteredCost * summary.costMultiplier + } + if summary.totalCost != nil { + sawEstimate = true + } + } + let provenance: CostProvenance = switch (sawMetered, sawEstimate) { + case (true, true): .mixed + case (true, false): .vendorMetered + case (false, true): .listPriceEstimate + case (false, false): .unknown + } + let overflowCount = max(0, modelSummary.rows.count - CurrencyGroup.modelRowDisplayLimit) return CurrencyGroup( currencyCode: currencyCode, providers: providers, @@ -284,7 +482,29 @@ struct SpendDashboardModel: Equatable, Sendable { totalCost: Self.knownCostSum(providers.map(\.totalCost)), coveredDayCount: Self.commonCoverageDayCount(summaries: summaries, calendar: calendar), chartDomain: Self.chartDomain(bounds: bounds, calendar: calendar), - modelHistoryCompleteness: modelHistoryCompleteness) + modelHistoryCompleteness: modelHistoryCompleteness, + tokenMix: tokenMix, + coverage: coverage, + provenance: provenance, + meteredCost: sawMetered ? metered : nil, + sessions: Self.sessionRows(summaries: summaries, bounds: bounds, calendar: calendar), + overflowModelCount: overflowCount, + selectedDay: selectedDay) + } + + private static func summaries(_ summaries: [InputSummary], matching selectedDay: Date?) -> [InputSummary] { + guard let selectedDay else { return summaries } + return summaries.map { summary in + InputSummary( + input: summary.input, + costMultiplier: summary.costMultiplier, + entries: summary.entries.filter { $0.day == selectedDay }, + totalTokens: summary.totalTokens, + totalCost: summary.totalCost, + coveredInterval: summary.coveredInterval, + coveredDayCount: summary.coveredDayCount, + hasInvalidCostHistory: summary.hasInvalidCostHistory) + } } private static func inputSummary( @@ -366,7 +586,8 @@ struct SpendDashboardModel: Equatable, Sendable { displayName: entry.element.input.displayName, totalTokens: entry.element.totalTokens, totalCost: entry.element.totalCost, - coveredDayCount: entry.element.coveredDayCount) + coveredDayCount: entry.element.coveredDayCount, + sourceKind: entry.element.input.sourceKind) } } @@ -885,6 +1106,63 @@ struct SpendDashboardModel: Equatable, Sendable { } return result } + + static func sessionRows( + summaries: [InputSummary], + bounds: ClosedRange, + calendar: Calendar) -> [SessionRow] + { + let rows = summaries.flatMap { summary -> [SessionRow] in + summary.input.snapshot.sessions.compactMap { session -> SessionRow? in + let day = calendar.startOfDay(for: session.lastActivity) + guard bounds.contains(day) else { return nil } + let modelName = session.modelBreakdowns.max { + ($0.totalTokens ?? 0) < ($1.totalTokens ?? 0) + }?.modelName + return SessionRow( + id: "\(summary.input.id):\(session.sessionID)", + sourceID: summary.input.id, + provider: summary.input.provider, + displayName: summary.input.displayName, + lastActivity: session.lastActivity, + totalTokens: session.totalTokens, + totalCost: session.costUSD.map { $0 * summary.costMultiplier }, + modelName: modelName) + } + } + .sorted { lhs, rhs in + if lhs.lastActivity != rhs.lastActivity { + return lhs.lastActivity > rhs.lastActivity + } + return lhs.id < rhs.id + } + return Array(rows.prefix(12)) + } + + static func hourlyPoints( + inputs: [ProviderInput], + days: Int, + now: Date, + calendar: Calendar) -> [HourlyPoint] + { + let today = calendar.startOfDay(for: now) + guard let windowStart = calendar.date(byAdding: .day, value: -(days - 1), to: today) else { return [] } + var totals: [Date: (tokens: Int, cost: Double)] = [:] + for input in inputs { + for session in input.snapshot.sessions { + let hour = calendar.dateInterval(of: .hour, for: session.lastActivity)?.start ?? session.lastActivity + guard hour >= windowStart else { continue } + let current = totals[hour] ?? (0, 0) + totals[hour] = ( + current.tokens + max(0, session.totalTokens ?? 0), + current.cost + max(0, session.costUSD ?? 0)) + } + } + return totals.keys.sorted().map { hour in + let value = totals[hour] ?? (0, 0) + return HourlyPoint(hour: hour, tokens: value.tokens, cost: value.cost > 0 ? value.cost : nil) + } + } } extension SpendDashboardModel.CurrencyGroup { diff --git a/Sources/CodexBar/UsageStore+CodexCostCatchUp.swift b/Sources/CodexBar/UsageStore+CodexCostCatchUp.swift index d473029996..2794820073 100644 --- a/Sources/CodexBar/UsageStore+CodexCostCatchUp.swift +++ b/Sources/CodexBar/UsageStore+CodexCostCatchUp.swift @@ -300,7 +300,9 @@ extension UsageStore { if let override = self._test_codexCostCatchUpStatusOverride { return await override(codexHomePath) } - return await self.costUsageFetcher.codexScanCatchUpStatus(codexHomePath: codexHomePath) + return await self.costUsageFetcher.codexScanCatchUpStatus( + codexHomePath: codexHomePath, + calendar: self.settings.costUsageBucketCalendar) } private func advanceCodexCostCatchUp( @@ -314,7 +316,8 @@ extension UsageStore { return try await self.costUsageFetcher.advanceCodexScanCatchUp( now: now, codexHomePath: codexHomePath, - historyDays: historyDays) + historyDays: historyDays, + calendar: self.settings.costUsageBucketCalendar) } private func codexCostCatchUpDecision( diff --git a/Sources/CodexBar/UsageStore+SpendDashboardCodexCostCatchUp.swift b/Sources/CodexBar/UsageStore+SpendDashboardCodexCostCatchUp.swift index 86758961c5..13bc2b7b8e 100644 --- a/Sources/CodexBar/UsageStore+SpendDashboardCodexCostCatchUp.swift +++ b/Sources/CodexBar/UsageStore+SpendDashboardCodexCostCatchUp.swift @@ -276,8 +276,11 @@ extension UsageStore { statuses[account.cacheIdentity] = await override(account) } else { statuses[account.cacheIdentity] = await CostUsageFetcher( - cacheRoot: SpendDashboardSource.codexCacheRoot(for: account)) - .codexScanCatchUpStatus(codexHomePath: account.homePath) + cacheRoot: SpendDashboardSource.codexCacheRoot(for: account), + calendar: self.settings.costUsageBucketCalendar) + .codexScanCatchUpStatus( + codexHomePath: account.homePath, + calendar: self.settings.costUsageBucketCalendar) } } return statuses @@ -291,11 +294,14 @@ extension UsageStore { if let override = self._test_spendDashboardCodexCostCatchUpAdvanceOverride { return try await override(account, now, historyDays) } - return try await CostUsageFetcher(cacheRoot: SpendDashboardSource.codexCacheRoot(for: account)) + return try await CostUsageFetcher( + cacheRoot: SpendDashboardSource.codexCacheRoot(for: account), + calendar: self.settings.costUsageBucketCalendar) .advanceCodexScanCatchUp( now: now, codexHomePath: account.homePath, - historyDays: historyDays) + historyDays: historyDays, + calendar: self.settings.costUsageBucketCalendar) } private func spendDashboardCodexCostCatchUpDecision( diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index 6e5cb85969..ce3c4741c5 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -75,7 +75,8 @@ extension UsageStore { historyDays: historyDays, cursorCookieHeaderOverride: cursorCookieHeaderOverride, allowPricingRefresh: allowPricingRefresh, - bypassScannerDebounce: true) + bypassScannerDebounce: true, + calendar: self.settings.costUsageBucketCalendar) } group.addTask { try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000)) @@ -228,7 +229,8 @@ extension UsageStore { await self.costUsageFetcher.loadCachedCodexTokenSnapshotResult( now: now, codexHomePath: scope.codexHomePath, - historyDays: historyDays) + historyDays: historyDays, + calendar: self.settings.costUsageBucketCalendar) .map { ( snapshot: $0.snapshot, diff --git a/Sources/CodexBarCLI/CLICostCommand.swift b/Sources/CodexBarCLI/CLICostCommand.swift index c6c0df9f7e..9e32703956 100644 --- a/Sources/CodexBarCLI/CLICostCommand.swift +++ b/Sources/CodexBarCLI/CLICostCommand.swift @@ -60,7 +60,9 @@ extension CodexBarCLI { Self.writeStderr("Warning: \(warning)\n") } - let fetcher = CostUsageFetcher() + let bucketCalendar = CostUsageBucketTimeZone.calendar( + identifier: Self.stringFromAppDefaults("tokenCostUsageBucketTimeZone")) + let fetcher = CostUsageFetcher(calendar: bucketCalendar) var sections: [String] = [] var payload: [CostPayload] = [] var exitCode: ExitCode = .success @@ -114,6 +116,14 @@ extension CodexBarCLI { } } + if format == .json, + let openCodex = Self.loadOpenCodexCostPayload( + historyDays: historyDays, + calendar: bucketCalendar) + { + payload.append(openCodex) + } + switch format { case .text: if !sections.isEmpty { @@ -403,9 +413,51 @@ extension CodexBarCLI { daily: daily, projects: projects, totals: snapshot.flatMap(Self.costTotals(from:)), + provenance: snapshot.map { $0.summary(forLastDays: $0.historyDays).provenance.rawValue }, + coverage: snapshot.map { $0.summary(forLastDays: $0.historyDays).coverage }, error: error.map { Self.makeErrorPayload($0) }) } + static func makeOpenCodexCostPayload(snapshot: CostUsageTokenSnapshot) -> CostPayload { + let summary = snapshot.summary(forLastDays: snapshot.historyDays) + return CostPayload( + provider: OpenCodexUsageLog.sourceID, + source: "opencodex", + updatedAt: snapshot.updatedAt, + currencyCode: snapshot.currencyCode, + sessionTokens: snapshot.sessionTokens, + sessionCostUSD: snapshot.sessionCostUSD, + historyDays: snapshot.historyDays, + historyCoverageIsEstablished: snapshot.historyCoverageIsEstablished, + last30DaysTokens: snapshot.last30DaysTokens, + last30DaysCostUSD: snapshot.last30DaysCostUSD, + meteredCostUSD: nil, + daily: snapshot.daily.map(self.costDailyPayload(from:)), + projects: [], + totals: self.costTotals(from: snapshot), + provenance: CostProvenance.listPriceEstimate.rawValue, + coverage: summary.coverage, + error: nil) + } + + private static func loadOpenCodexCostPayload( + historyDays: Int, + calendar: Calendar, + now: Date = Date()) -> CostPayload? + { + guard boolFromAppDefaults("openCodexUsageLogsEnabled") == true else { return nil } + let environment = ProcessInfo.processInfo.environment + guard let logURL = OpenCodexUsageLog.usageLogURL(environment: environment) else { return nil } + let store = OpenCodexUsageStore(cacheRoot: OpenCodexUsageLog.cacheRoot(environment: environment)) + guard let snapshot = try? store.loadSnapshot( + logURL: logURL, + now: now, + historyDays: historyDays, + calendar: calendar) + else { return nil } + return self.makeOpenCodexCostPayload(snapshot: snapshot) + } + private static func costDailyPayload(from entry: CostUsageDailyReport.Entry) -> CostDailyEntryPayload { CostDailyEntryPayload( date: entry.date, @@ -413,6 +465,7 @@ extension CodexBarCLI { outputTokens: entry.outputTokens, cacheReadTokens: entry.cacheReadTokens, cacheCreationTokens: entry.cacheCreationTokens, + reasoningTokens: entry.reasoningTokens, totalTokens: entry.totalTokens, costUSD: entry.costUSD, modelsUsed: entry.modelsUsed, @@ -445,12 +498,14 @@ extension CodexBarCLI { var totalOutput = 0 var totalCacheRead = 0 var totalCacheCreation = 0 + var totalReasoning = 0 var totalTokens = 0 var totalCost = 0.0 var sawInput = false var sawOutput = false var sawCacheRead = false var sawCacheCreation = false + var sawReasoning = false var sawTokens = false var sawCost = false @@ -471,6 +526,10 @@ extension CodexBarCLI { totalCacheCreation += cacheCreation sawCacheCreation = true } + if let reasoning = entry.reasoningTokens { + totalReasoning += reasoning + sawReasoning = true + } if let tokens = entry.totalTokens { totalTokens += tokens sawTokens = true @@ -481,14 +540,17 @@ extension CodexBarCLI { } } - // Prefer totals derived from daily rows; fall back to snapshot aggregates when rows omit fields. + let summary = snapshot.summary(forLastDays: snapshot.historyDays) return CostTotalsPayload( totalInputTokens: sawInput ? totalInput : nil, totalOutputTokens: sawOutput ? totalOutput : nil, cacheReadTokens: sawCacheRead ? totalCacheRead : nil, cacheCreationTokens: sawCacheCreation ? totalCacheCreation : nil, + reasoningTokens: sawReasoning ? totalReasoning : nil, totalTokens: sawTokens ? totalTokens : snapshot.last30DaysTokens, - totalCostUSD: sawCost ? totalCost : snapshot.last30DaysCostUSD) + totalCostUSD: sawCost ? totalCost : snapshot.last30DaysCostUSD, + provenance: summary.provenance.rawValue, + coverage: summary.coverage) } private static func decodeCostHistoryDays(from values: ParsedValues) -> Int { @@ -637,6 +699,8 @@ struct CostPayload: Encodable, Sendable { let daily: [CostDailyEntryPayload] let projects: [CostProjectPayload] let totals: CostTotalsPayload? + let provenance: String? + let coverage: CostUsageCoverageCounts? let error: ProviderErrorPayload? init( @@ -654,6 +718,8 @@ struct CostPayload: Encodable, Sendable { daily: [CostDailyEntryPayload], projects: [CostProjectPayload] = [], totals: CostTotalsPayload?, + provenance: String? = nil, + coverage: CostUsageCoverageCounts? = nil, error: ProviderErrorPayload?) { self.provider = provider @@ -670,6 +736,8 @@ struct CostPayload: Encodable, Sendable { self.daily = daily self.projects = projects self.totals = totals + self.provenance = provenance + self.coverage = coverage self.error = error } } @@ -680,6 +748,7 @@ struct CostDailyEntryPayload: Encodable, Sendable { let outputTokens: Int? let cacheReadTokens: Int? let cacheCreationTokens: Int? + let reasoningTokens: Int? let totalTokens: Int? let costUSD: Double? let modelsUsed: [String]? @@ -691,11 +760,36 @@ struct CostDailyEntryPayload: Encodable, Sendable { case outputTokens case cacheReadTokens case cacheCreationTokens + case reasoningTokens case totalTokens case costUSD = "totalCost" case modelsUsed case modelBreakdowns } + + init( + date: String, + inputTokens: Int?, + outputTokens: Int?, + cacheReadTokens: Int?, + cacheCreationTokens: Int?, + reasoningTokens: Int? = nil, + totalTokens: Int?, + costUSD: Double?, + modelsUsed: [String]?, + modelBreakdowns: [CostModelBreakdownPayload]?) + { + self.date = date + self.inputTokens = inputTokens + self.outputTokens = outputTokens + self.cacheReadTokens = cacheReadTokens + self.cacheCreationTokens = cacheCreationTokens + self.reasoningTokens = reasoningTokens + self.totalTokens = totalTokens + self.costUSD = costUSD + self.modelsUsed = modelsUsed + self.modelBreakdowns = modelBreakdowns + } } struct CostModelBreakdownPayload: Encodable, Sendable { @@ -771,16 +865,44 @@ struct CostTotalsPayload: Encodable, Sendable { let totalOutputTokens: Int? let cacheReadTokens: Int? let cacheCreationTokens: Int? + let reasoningTokens: Int? let totalTokens: Int? let totalCostUSD: Double? + let provenance: String? + let coverage: CostUsageCoverageCounts? private enum CodingKeys: String, CodingKey { case totalInputTokens = "inputTokens" case totalOutputTokens = "outputTokens" case cacheReadTokens case cacheCreationTokens + case reasoningTokens case totalTokens case totalCostUSD = "totalCost" + case provenance + case coverage + } + + init( + totalInputTokens: Int?, + totalOutputTokens: Int?, + cacheReadTokens: Int?, + cacheCreationTokens: Int?, + reasoningTokens: Int? = nil, + totalTokens: Int?, + totalCostUSD: Double?, + provenance: String? = nil, + coverage: CostUsageCoverageCounts? = nil) + { + self.totalInputTokens = totalInputTokens + self.totalOutputTokens = totalOutputTokens + self.cacheReadTokens = cacheReadTokens + self.cacheCreationTokens = cacheCreationTokens + self.reasoningTokens = reasoningTokens + self.totalTokens = totalTokens + self.totalCostUSD = totalCostUSD + self.provenance = provenance + self.coverage = coverage } } diff --git a/Sources/CodexBarCLI/CLIHelpers.swift b/Sources/CodexBarCLI/CLIHelpers.swift index 89676cc45f..52c2a3f7b4 100644 --- a/Sources/CodexBarCLI/CLIHelpers.swift +++ b/Sources/CodexBarCLI/CLIHelpers.swift @@ -205,16 +205,36 @@ extension CodexBarCLI { /// serve dashboard follows the setting without a restart, the same way reset style /// and weekly work days already do. static func hidePersonalInfoFromDefaults() -> Bool { + self.boolFromAppDefaults("hidePersonalInfo") ?? false + } + + static func boolFromAppDefaults(_ key: String) -> Bool? { + let domains = [ + "com.steipete.codexbar", + "com.steipete.codexbar.debug", + ] + for domain in domains { + if let value = UserDefaults(suiteName: domain)?.object(forKey: key) as? Bool { + return value + } + } + return UserDefaults.standard.object(forKey: key) as? Bool + } + + static func stringFromAppDefaults(_ key: String) -> String? { let domains = [ "com.steipete.codexbar", "com.steipete.codexbar.debug", ] for domain in domains { - if let value = UserDefaults(suiteName: domain)?.object(forKey: "hidePersonalInfo") as? Bool { + if let value = UserDefaults(suiteName: domain)?.string(forKey: key), !value.isEmpty { return value } } - return UserDefaults.standard.object(forKey: "hidePersonalInfo") as? Bool ?? false + if let value = UserDefaults.standard.string(forKey: key), !value.isEmpty { + return value + } + return nil } static func fetchProviderUsage( diff --git a/Sources/CodexBarCore/CodexLocalProjectUsageIndexer.swift b/Sources/CodexBarCore/CodexLocalProjectUsageIndexer.swift index d645a17f7e..6f4cfdbe9a 100644 --- a/Sources/CodexBarCore/CodexLocalProjectUsageIndexer.swift +++ b/Sources/CodexBarCore/CodexLocalProjectUsageIndexer.swift @@ -446,7 +446,10 @@ extension CodexLocalProjectUsageIndexer { var skippedFiles = 0 var sessionBuckets: [String: SessionBucket] = [:] let files = cache.files.sorted(by: { $0.key < $1.key }).filter { - $0.value.touchesCodexScanWindow(sinceKey: range.sinceKey, untilKey: range.untilKey) + $0.value.touchesCodexScanWindow( + sinceKey: range.sinceKey, + untilKey: range.untilKey, + calendar: range.calendar) } progress?(CodexLocalProjectUsageIndexProgress( phase: .indexingProjects, diff --git a/Sources/CodexBarCore/CostProvenance.swift b/Sources/CodexBarCore/CostProvenance.swift new file mode 100644 index 0000000000..f41d5f77bf --- /dev/null +++ b/Sources/CodexBarCore/CostProvenance.swift @@ -0,0 +1,185 @@ +import Foundation + +/// How a cost figure was produced. This is display-time accounting, not a billing receipt. +public enum CostProvenance: String, Sendable, Equatable, Codable { + /// Token counts × public API list prices. + case listPriceEstimate + /// Vendor-reported metered spend (for example Cursor plan deductions). + case vendorMetered + /// Window mixes list-price rows with vendor-metered rows. + case mixed + case unknown + + public var isBillingReceipt: Bool { + false + } + + /// Narrow a snapshot-level provenance to the costs actually present in a window. + /// Daily vendor-reported rows stay vendor-metered even when `meteredCostUSD` is absent. + public static func forWindow( + snapshot: CostProvenance, + hasWindowCosts: Bool, + includesMetered: Bool) -> CostProvenance + { + switch snapshot { + case .vendorMetered: + includesMetered || hasWindowCosts ? .vendorMetered : .unknown + case .mixed: + switch (includesMetered, hasWindowCosts) { + case (true, true): + .mixed + case (true, false): + .vendorMetered + case (false, true): + .listPriceEstimate + case (false, false): + .unknown + } + case .listPriceEstimate: + hasWindowCosts ? .listPriceEstimate : .unknown + case .unknown: + .unknown + } + } +} + +/// Request/row coverage for a cost window. Counts stay independent so a missing +/// category is `0` rather than collapsing into another bucket. +public struct CostUsageCoverageCounts: Sendable, Equatable, Codable { + public var priced: Int + public var unpriced: Int + public var unmetered: Int + public var estimated: Int + + public init(priced: Int = 0, unpriced: Int = 0, unmetered: Int = 0, estimated: Int = 0) { + self.priced = max(0, priced) + self.unpriced = max(0, unpriced) + self.unmetered = max(0, unmetered) + self.estimated = max(0, estimated) + } + + public var total: Int { + self.priced + self.unpriced + self.unmetered + self.estimated + } + + public var coverageRatio: Double? { + let measured = self.priced + self.estimated + let denominator = self.total + guard denominator > 0 else { return nil } + return Double(measured) / Double(denominator) + } + + public mutating func merge(_ other: CostUsageCoverageCounts) { + self.priced += other.priced + self.unpriced += other.unpriced + self.unmetered += other.unmetered + self.estimated += other.estimated + } + + public static func + (lhs: Self, rhs: Self) -> Self { + var merged = lhs + merged.merge(rhs) + return merged + } +} + +/// Token-class mix. `nil` means the source did not establish that class — never treat as zero. +public struct CostUsageTokenMix: Sendable, Equatable, Codable { + public var inputTokens: Int? + public var outputTokens: Int? + public var cacheReadTokens: Int? + public var cacheCreationTokens: Int? + public var reasoningTokens: Int? + + public init( + inputTokens: Int? = nil, + outputTokens: Int? = nil, + cacheReadTokens: Int? = nil, + cacheCreationTokens: Int? = nil, + reasoningTokens: Int? = nil) + { + self.inputTokens = Self.nonnegative(inputTokens) + self.outputTokens = Self.nonnegative(outputTokens) + self.cacheReadTokens = Self.nonnegative(cacheReadTokens) + self.cacheCreationTokens = Self.nonnegative(cacheCreationTokens) + self.reasoningTokens = Self.nonnegative(reasoningTokens) + } + + public var hasAnyClass: Bool { + self.inputTokens != nil + || self.outputTokens != nil + || self.cacheReadTokens != nil + || self.cacheCreationTokens != nil + || self.reasoningTokens != nil + } + + public mutating func merge(_ other: CostUsageTokenMix) { + self.inputTokens = Self.add(self.inputTokens, other.inputTokens) + self.outputTokens = Self.add(self.outputTokens, other.outputTokens) + self.cacheReadTokens = Self.add(self.cacheReadTokens, other.cacheReadTokens) + self.cacheCreationTokens = Self.add(self.cacheCreationTokens, other.cacheCreationTokens) + self.reasoningTokens = Self.add(self.reasoningTokens, other.reasoningTokens) + } + + public static func + (lhs: Self, rhs: Self) -> Self { + var merged = lhs + merged.merge(rhs) + return merged + } + + public static func from(entry: CostUsageDailyReport.Entry) -> Self { + Self( + inputTokens: entry.inputTokens, + outputTokens: entry.outputTokens, + cacheReadTokens: entry.cacheReadTokens, + cacheCreationTokens: entry.cacheCreationTokens, + reasoningTokens: entry.reasoningTokens) + } + + private static func nonnegative(_ value: Int?) -> Int? { + guard let value, value >= 0 else { return nil } + return value + } + + private static func add(_ lhs: Int?, _ rhs: Int?) -> Int? { + switch (lhs, rhs) { + case let (left?, right?): + let (result, overflow) = left.addingReportingOverflow(right) + return overflow ? nil : result + case let (left?, nil): + return left + case let (nil, right?): + return right + case (nil, nil): + return nil + } + } +} + +/// Pinned IANA timezone used to bucket cost-usage days. Re-bucketing the same history +/// under a different zone would move midnight-adjacent events and inflate totals. +public enum CostUsageBucketTimeZone: Sendable { + public static func calendar(identifier: String?) -> Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = self.timeZone(identifier: identifier) + return calendar + } + + public static func timeZone(identifier: String?) -> TimeZone { + if let identifier { + let trimmed = identifier.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty, let zone = TimeZone(identifier: trimmed) { + return zone + } + } + return .current + } + + public static func pinIdentifier(from timeZone: TimeZone = .current) -> String { + timeZone.identifier + } + + public static func isValidIdentifier(_ identifier: String) -> Bool { + TimeZone(identifier: identifier) != nil + } +} diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 35d50c751b..737bf60b43 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -66,8 +66,17 @@ public struct CostUsageFetcher: Sendable { private let scannerOptions: CostUsageScanner.Options? - public init(cacheRoot: URL? = nil) { - self.scannerOptions = cacheRoot.map { CostUsageScanner.Options(cacheRoot: $0) } + public init(cacheRoot: URL? = nil, calendar: Calendar? = nil) { + if cacheRoot == nil, calendar == nil { + self.scannerOptions = nil + } else { + var options = CostUsageScanner.Options() + options.cacheRoot = cacheRoot + if let calendar { + options.calendar = calendar + } + self.scannerOptions = options + } } init(scannerOptions: CostUsageScanner.Options) { @@ -77,37 +86,40 @@ public struct CostUsageFetcher: Sendable { public func loadCachedCodexTokenSnapshot( now: Date = Date(), codexHomePath: String? = nil, - historyDays: Int = 30) async -> CostUsageTokenSnapshot? + historyDays: Int = 30, + calendar: Calendar? = nil) async -> CostUsageTokenSnapshot? { await Self.loadCachedCodexTokenSnapshot( now: now, codexHomePath: codexHomePath, historyDays: historyDays, - scannerOptions: self.scannerOptionsOverride()) + scannerOptions: self.scannerOptions(calendar: calendar)) } package func loadCachedCodexTokenActivity( now: Date = Date(), codexHomePath: String? = nil, - maximumDays: Int = 365) async -> CostUsageTokenActivityCache? + maximumDays: Int = 365, + calendar: Calendar? = nil) async -> CostUsageTokenActivityCache? { await Self.loadCachedCodexTokenActivity( now: now, codexHomePath: codexHomePath, maximumDays: maximumDays, - scannerOptions: self.scannerOptionsOverride()) + scannerOptions: self.scannerOptions(calendar: calendar)) } package func loadCachedCodexTokenSnapshotResult( now: Date = Date(), codexHomePath: String? = nil, - historyDays: Int = 30) async -> CachedCodexTokenSnapshotResult? + historyDays: Int = 30, + calendar: Calendar? = nil) async -> CachedCodexTokenSnapshotResult? { await Self.loadCachedCodexTokenSnapshotResult( now: now, codexHomePath: codexHomePath, historyDays: historyDays, - scannerOptions: self.scannerOptionsOverride()) + scannerOptions: self.scannerOptions(calendar: calendar)) } package func loadCachedCodexTokenSnapshotForScopedHome( @@ -115,7 +127,8 @@ public struct CostUsageFetcher: Sendable { codexHomePath: String, historyDays: Int = 30, includePiSessions: Bool = false, - includeProjectAndSessionBreakdowns: Bool = false) async -> CostUsageTokenSnapshot? + includeProjectAndSessionBreakdowns: Bool = false, + calendar: Calendar? = nil) async -> CostUsageTokenSnapshot? { await Self.loadCachedCodexTokenSnapshot( now: now, @@ -124,7 +137,7 @@ public struct CostUsageFetcher: Sendable { allowScopedCodexHome: true, includePiSessions: includePiSessions, includeProjectAndSessionBreakdowns: includeProjectAndSessionBreakdowns, - scannerOptions: self.scannerOptionsOverride()) + scannerOptions: self.scannerOptions(calendar: calendar)) } public func loadCachedCodexLocalProjectUsageSnapshot( @@ -207,9 +220,14 @@ public struct CostUsageFetcher: Sendable { allowPricingRefresh: Bool = true, refreshPricingInBackground: Bool = true, includePiSessions: Bool = true, - bypassScannerDebounce: Bool) async throws -> CostUsageTokenSnapshot + bypassScannerDebounce: Bool, + calendar: Calendar? = nil) async throws -> CostUsageTokenSnapshot { - try await Self.loadTokenSnapshot( + var options = self.scannerOptionsOverride() ?? CostUsageScanner.Options() + if let calendar { + options.calendar = calendar + } + return try await Self.loadTokenSnapshot( provider: provider, environment: environment, now: now, @@ -222,7 +240,7 @@ public struct CostUsageFetcher: Sendable { refreshPricingInBackground: refreshPricingInBackground, includePiSessions: includePiSessions, bypassScannerDebounce: bypassScannerDebounce, - scannerOptions: self.scannerOptionsOverride()) + scannerOptions: options) } @available(*, deprecated, message: "Codex token-cost scans are uncapped; this limit is ignored.") @@ -254,12 +272,22 @@ public struct CostUsageFetcher: Sendable { self.scannerOptions } + private func scannerOptions(calendar: Calendar?) -> CostUsageScanner.Options? { + guard calendar != nil || self.scannerOptions != nil else { return self.scannerOptions } + var options = self.scannerOptions ?? CostUsageScanner.Options() + if let calendar { + options.calendar = calendar + } + return options + } + package func codexScanCatchUpStatus( - codexHomePath: String? = nil) async -> CodexScanCatchUpStatus + codexHomePath: String? = nil, + calendar: Calendar? = nil) async -> CodexScanCatchUpStatus { // Provider-specific by design: Codex exposes bounded background catch-up for its incremental JSONL scanner. let options = Self.resolvedScannerOptions( - self.scannerOptionsOverride(), + self.scannerOptions(calendar: calendar), provider: .codex, codexHomePath: codexHomePath) return await (try? CostUsageScanExecutor.run { checkCancellation in @@ -271,10 +299,11 @@ public struct CostUsageFetcher: Sendable { package func advanceCodexScanCatchUp( now: Date = Date(), codexHomePath: String? = nil, - historyDays: Int = 30) async throws -> CodexScanCatchUpStatus + historyDays: Int = 30, + calendar: Calendar? = nil) async throws -> CodexScanCatchUpStatus { var options = Self.resolvedScannerOptions( - self.scannerOptionsOverride(), + self.scannerOptions(calendar: calendar), provider: .codex, codexHomePath: codexHomePath) options.forceRescan = false @@ -487,6 +516,7 @@ public struct CostUsageFetcher: Sendable { historyDays: clampedHistoryDays, calendar: scanOptions.calendar, historyCoverageIsEstablished: scanResult.historyCoverageIsEstablished, + costProvenance: .listPriceEstimate, projects: scanResult.projects, sessions: scanResult.sessions, updatedAt: scanResult.staleSnapshotUpdatedAt) @@ -943,6 +973,7 @@ public struct CostUsageFetcher: Sendable { historyDays: clampedHistoryDays, calendar: options.calendar, historyCoverageIsEstablished: Self.codexHistoryCoverageIsEstablished(options: options), + costProvenance: .listPriceEstimate, projects: Self.mergedProjectBreakdowns(projects), sessions: sessions, updatedAt: scanTimes.min()), @@ -1076,6 +1107,9 @@ public struct CostUsageFetcher: Sendable { historyDays: historyDays, useCurrentLocalDayForSession: true, meteredCostUSD: report.meteredCostUSD, + costProvenance: Self.cursorCostProvenance( + meteredCostUSD: report.meteredCostUSD, + daily: report.daily.data), credentialScopeFingerprint: report.credentialScopeFingerprint) } #endif @@ -1088,6 +1122,7 @@ public struct CostUsageFetcher: Sendable { calendar: Calendar = .current, historyCoverageIsEstablished: Bool = true, meteredCostUSD: Double? = nil, + costProvenance: CostProvenance = .unknown, credentialScopeFingerprint: String? = nil, historyLabel: String? = nil, projects: [CostUsageProjectBreakdown] = [], @@ -1144,6 +1179,7 @@ public struct CostUsageFetcher: Sendable { historyCoverageIsEstablished: historyCoverageIsEstablished, historyLabel: historyLabel, meteredCostUSD: meteredCostUSD, + costProvenance: costProvenance, credentialScopeFingerprint: credentialScopeFingerprint, daily: daily.data, projects: projects, @@ -1168,6 +1204,17 @@ public struct CostUsageFetcher: Sendable { return self.codexAutomaticScanDurationPerRefresh } + private static func cursorCostProvenance( + meteredCostUSD: Double?, + daily: [CostUsageDailyReport.Entry]) -> CostProvenance + { + let hasDailyCosts = daily.contains { $0.costUSD != nil } + if meteredCostUSD != nil, hasDailyCosts { return .mixed } + if meteredCostUSD != nil { return .vendorMetered } + if hasDailyCosts { return .listPriceEstimate } + return .unknown + } + private static func configureScannerRefresh( _ options: inout CostUsageScanner.Options, provider: UsageProvider, @@ -1526,7 +1573,8 @@ extension CostUsageFetcher { from: daily, now: now, historyDays: historyDays, - useCurrentLocalDayForSession: false) + useCurrentLocalDayForSession: false, + costProvenance: .vendorMetered) } #if os(macOS) diff --git a/Sources/CodexBarCore/CostUsageModels.swift b/Sources/CodexBarCore/CostUsageModels.swift index 322f7d59f5..7c5853f3f0 100644 --- a/Sources/CodexBarCore/CostUsageModels.swift +++ b/Sources/CodexBarCore/CostUsageModels.swift @@ -22,19 +22,31 @@ public struct CostUsageWindowSummary: Sendable, Equatable { public let totalCostUSD: Double? public let totalRequests: Int? public let entryCount: Int + public let tokenMix: CostUsageTokenMix + public let coverage: CostUsageCoverageCounts + public let provenance: CostProvenance + public let meteredCostUSD: Double? public init( days: Int, totalTokens: Int?, totalCostUSD: Double?, totalRequests: Int?, - entryCount: Int) + entryCount: Int, + tokenMix: CostUsageTokenMix = CostUsageTokenMix(), + coverage: CostUsageCoverageCounts = CostUsageCoverageCounts(), + provenance: CostProvenance = .unknown, + meteredCostUSD: Double? = nil) { self.days = days self.totalTokens = totalTokens self.totalCostUSD = totalCostUSD self.totalRequests = totalRequests self.entryCount = entryCount + self.tokenMix = tokenMix + self.coverage = coverage + self.provenance = provenance + self.meteredCostUSD = meteredCostUSD } } @@ -46,6 +58,7 @@ public struct CostUsageSessionBreakdown: Sendable, Equatable, Identifiable { public let inputTokens: Int? public let cachedInputTokens: Int? public let outputTokens: Int? + public let reasoningTokens: Int? public let totalTokens: Int? public let requestCount: Int? public let costUSD: Double? @@ -61,6 +74,7 @@ public struct CostUsageSessionBreakdown: Sendable, Equatable, Identifiable { inputTokens: Int?, cachedInputTokens: Int?, outputTokens: Int?, + reasoningTokens: Int? = nil, totalTokens: Int?, requestCount: Int?, costUSD: Double?, @@ -71,6 +85,7 @@ public struct CostUsageSessionBreakdown: Sendable, Equatable, Identifiable { self.inputTokens = inputTokens self.cachedInputTokens = cachedInputTokens self.outputTokens = outputTokens + self.reasoningTokens = reasoningTokens self.totalTokens = totalTokens self.requestCount = requestCount self.costUSD = costUSD @@ -93,6 +108,9 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { /// actually deducts, as opposed to the API-rate estimate. Only some providers (e.g. Cursor) /// report this; `nil` when unknown. public let meteredCostUSD: Double? + /// How this snapshot's costs were produced. Never infer this solely from whether a + /// cost figure exists — Bedrock and OpenAI Admin costs are vendor-reported. + public let costProvenance: CostProvenance /// Internal credential scope used to prevent cross-account cache publication. This is a /// non-reversible fingerprint, not account identity, and is not emitted by CLI payloads. public let credentialScopeFingerprint: String? @@ -113,6 +131,7 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { historyCoverageIsEstablished: Bool = true, historyLabel: String? = nil, meteredCostUSD: Double? = nil, + costProvenance: CostProvenance = .unknown, credentialScopeFingerprint: String? = nil, daily: [CostUsageDailyReport.Entry], projects: [CostUsageProjectBreakdown] = [], @@ -131,6 +150,7 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { self.historyCoverageIsEstablished = historyCoverageIsEstablished self.historyLabel = historyLabel self.meteredCostUSD = meteredCostUSD + self.costProvenance = costProvenance self.credentialScopeFingerprint = credentialScopeFingerprint self.daily = daily self.projects = projects @@ -155,12 +175,27 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { let costs = entries.compactMap(\.costUSD) let tokens = entries.compactMap(\.totalTokens) let requests = entries.compactMap(\.requestCount) + var mix = CostUsageTokenMix() + var coverage = CostUsageCoverageCounts() + for entry in entries { + mix.merge(.from(entry: entry)) + coverage.merge(entry.coverageCounts) + } + let coversFullHistory = days >= self.historyDays + let windowMetered = coversFullHistory ? self.meteredCostUSD : nil return CostUsageWindowSummary( days: days, totalTokens: tokens.isEmpty ? nil : tokens.reduce(0, +), totalCostUSD: costs.isEmpty ? nil : costs.reduce(0, +), totalRequests: requests.isEmpty ? nil : requests.reduce(0, +), - entryCount: entries.count) + entryCount: entries.count, + tokenMix: mix, + coverage: coverage, + provenance: CostProvenance.forWindow( + snapshot: self.costProvenance, + hasWindowCosts: !costs.isEmpty, + includesMetered: windowMetered != nil), + meteredCostUSD: windowMetered) } public func comparisonSummaries( @@ -294,6 +329,11 @@ public struct CostUsageDailyReport: Sendable, Decodable { public let costUSD: Double? public let totalTokens: Int? public let requestCount: Int? + public let inputTokens: Int? + public let outputTokens: Int? + public let cacheReadTokens: Int? + public let cacheCreationTokens: Int? + public let reasoningTokens: Int? public let standardCostUSD: Double? public let priorityCostUSD: Double? public let standardTokens: Int? @@ -306,6 +346,11 @@ public struct CostUsageDailyReport: Sendable, Decodable { case totalTokens case requestCount case requests + case inputTokens + case outputTokens + case cacheReadTokens + case cacheCreationTokens + case reasoningTokens case standardCostUSD case priorityCostUSD case standardTokens @@ -322,6 +367,11 @@ public struct CostUsageDailyReport: Sendable, Decodable { self.requestCount = try container.decodeIfPresent(Int.self, forKey: .requestCount) ?? container.decodeIfPresent(Int.self, forKey: .requests) + self.inputTokens = try container.decodeIfPresent(Int.self, forKey: .inputTokens) + self.outputTokens = try container.decodeIfPresent(Int.self, forKey: .outputTokens) + self.cacheReadTokens = try container.decodeIfPresent(Int.self, forKey: .cacheReadTokens) + self.cacheCreationTokens = try container.decodeIfPresent(Int.self, forKey: .cacheCreationTokens) + self.reasoningTokens = try container.decodeIfPresent(Int.self, forKey: .reasoningTokens) self.standardCostUSD = try container.decodeIfPresent(Double.self, forKey: .standardCostUSD) self.priorityCostUSD = try container.decodeIfPresent(Double.self, forKey: .priorityCostUSD) self.standardTokens = try container.decodeIfPresent(Int.self, forKey: .standardTokens) @@ -333,6 +383,11 @@ public struct CostUsageDailyReport: Sendable, Decodable { costUSD: Double?, totalTokens: Int? = nil, requestCount: Int? = nil, + inputTokens: Int? = nil, + outputTokens: Int? = nil, + cacheReadTokens: Int? = nil, + cacheCreationTokens: Int? = nil, + reasoningTokens: Int? = nil, standardCostUSD: Double? = nil, priorityCostUSD: Double? = nil, standardTokens: Int? = nil, @@ -342,6 +397,11 @@ public struct CostUsageDailyReport: Sendable, Decodable { self.costUSD = costUSD self.totalTokens = totalTokens self.requestCount = requestCount + self.inputTokens = inputTokens + self.outputTokens = outputTokens + self.cacheReadTokens = cacheReadTokens + self.cacheCreationTokens = cacheCreationTokens + self.reasoningTokens = reasoningTokens self.standardCostUSD = standardCostUSD self.priorityCostUSD = priorityCostUSD self.standardTokens = standardTokens @@ -355,11 +415,47 @@ public struct CostUsageDailyReport: Sendable, Decodable { public let cacheReadTokens: Int? public let cacheCreationTokens: Int? public let outputTokens: Int? + public let reasoningTokens: Int? public let totalTokens: Int? public let requestCount: Int? public let costUSD: Double? public let modelsUsed: [String]? public let modelBreakdowns: [ModelBreakdown]? + public let unpricedRequestCount: Int? + public let unmeteredRequestCount: Int? + public let estimatedRequestCount: Int? + + public var coverageCounts: CostUsageCoverageCounts { + let unpriced = max(0, self.unpricedRequestCount ?? 0) + let unmetered = max(0, self.unmeteredRequestCount ?? 0) + let estimated = max(0, self.estimatedRequestCount ?? 0) + if let requests = self.requestCount, requests > 0 { + let priced = if self.costUSD != nil { + max(0, requests - unpriced - unmetered - estimated) + } else { + 0 + } + return CostUsageCoverageCounts( + priced: priced, + unpriced: unpriced, + unmetered: unmetered, + estimated: estimated) + } + if unpriced + unmetered + estimated > 0 { + return CostUsageCoverageCounts( + priced: 0, + unpriced: unpriced, + unmetered: unmetered, + estimated: estimated) + } + if self.costUSD != nil { + return CostUsageCoverageCounts(priced: 1) + } + if (self.totalTokens ?? 0) > 0 { + return CostUsageCoverageCounts(unpriced: 1) + } + return CostUsageCoverageCounts() + } private enum CodingKeys: String, CodingKey { case date @@ -369,6 +465,8 @@ public struct CostUsageDailyReport: Sendable, Decodable { case cacheReadInputTokens case cacheCreationInputTokens case outputTokens + case reasoningTokens + case reasoningOutputTokens case totalTokens case requestCount case requests @@ -377,6 +475,9 @@ public struct CostUsageDailyReport: Sendable, Decodable { case modelsUsed case models case modelBreakdowns + case unpricedRequestCount + case unmeteredRequestCount + case estimatedRequestCount } public init(from decoder: Decoder) throws { @@ -390,6 +491,9 @@ public struct CostUsageDailyReport: Sendable, Decodable { try container.decodeIfPresent(Int.self, forKey: .cacheCreationTokens) ?? container.decodeIfPresent(Int.self, forKey: .cacheCreationInputTokens) self.outputTokens = try container.decodeIfPresent(Int.self, forKey: .outputTokens) + self.reasoningTokens = + try container.decodeIfPresent(Int.self, forKey: .reasoningTokens) + ?? container.decodeIfPresent(Int.self, forKey: .reasoningOutputTokens) self.totalTokens = try container.decodeIfPresent(Int.self, forKey: .totalTokens) self.requestCount = try container.decodeIfPresent(Int.self, forKey: .requestCount) @@ -399,6 +503,9 @@ public struct CostUsageDailyReport: Sendable, Decodable { ?? container.decodeIfPresent(Double.self, forKey: .totalCost) self.modelsUsed = Self.decodeModelsUsed(from: container) self.modelBreakdowns = try container.decodeIfPresent([ModelBreakdown].self, forKey: .modelBreakdowns) + self.unpricedRequestCount = try container.decodeIfPresent(Int.self, forKey: .unpricedRequestCount) + self.unmeteredRequestCount = try container.decodeIfPresent(Int.self, forKey: .unmeteredRequestCount) + self.estimatedRequestCount = try container.decodeIfPresent(Int.self, forKey: .estimatedRequestCount) } public init( @@ -407,22 +514,30 @@ public struct CostUsageDailyReport: Sendable, Decodable { outputTokens: Int?, cacheReadTokens: Int? = nil, cacheCreationTokens: Int? = nil, + reasoningTokens: Int? = nil, totalTokens: Int?, requestCount: Int? = nil, costUSD: Double?, modelsUsed: [String]?, - modelBreakdowns: [ModelBreakdown]?) + modelBreakdowns: [ModelBreakdown]?, + unpricedRequestCount: Int? = nil, + unmeteredRequestCount: Int? = nil, + estimatedRequestCount: Int? = nil) { self.date = date self.inputTokens = inputTokens self.outputTokens = outputTokens self.cacheReadTokens = cacheReadTokens self.cacheCreationTokens = cacheCreationTokens + self.reasoningTokens = reasoningTokens self.totalTokens = totalTokens self.requestCount = requestCount self.costUSD = costUSD self.modelsUsed = modelsUsed self.modelBreakdowns = modelBreakdowns + self.unpricedRequestCount = unpricedRequestCount + self.unmeteredRequestCount = unmeteredRequestCount + self.estimatedRequestCount = estimatedRequestCount } private static func decodeModelsUsed(from container: KeyedDecodingContainer) -> [String]? { @@ -452,6 +567,7 @@ public struct CostUsageDailyReport: Sendable, Decodable { public let totalOutputTokens: Int? public let cacheReadTokens: Int? public let cacheCreationTokens: Int? + public let reasoningTokens: Int? public let totalTokens: Int? public let totalCostUSD: Double? @@ -462,6 +578,7 @@ public struct CostUsageDailyReport: Sendable, Decodable { case cacheCreationTokens case totalCacheReadTokens case totalCacheCreationTokens + case reasoningTokens case totalTokens case totalCostUSD case totalCost @@ -472,6 +589,7 @@ public struct CostUsageDailyReport: Sendable, Decodable { totalOutputTokens: Int?, cacheReadTokens: Int? = nil, cacheCreationTokens: Int? = nil, + reasoningTokens: Int? = nil, totalTokens: Int?, totalCostUSD: Double?) { @@ -479,6 +597,7 @@ public struct CostUsageDailyReport: Sendable, Decodable { self.totalOutputTokens = totalOutputTokens self.cacheReadTokens = cacheReadTokens self.cacheCreationTokens = cacheCreationTokens + self.reasoningTokens = reasoningTokens self.totalTokens = totalTokens self.totalCostUSD = totalCostUSD } @@ -493,6 +612,7 @@ public struct CostUsageDailyReport: Sendable, Decodable { self.cacheCreationTokens = try container.decodeIfPresent(Int.self, forKey: .cacheCreationTokens) ?? container.decodeIfPresent(Int.self, forKey: .totalCacheCreationTokens) + self.reasoningTokens = try container.decodeIfPresent(Int.self, forKey: .reasoningTokens) self.totalTokens = try container.decodeIfPresent(Int.self, forKey: .totalTokens) self.totalCostUSD = try container.decodeIfPresent(Double.self, forKey: .totalCostUSD) diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 34028bb592..6edd791549 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "50cb4b9e11791432" + static let value = "b7726c3088c14277" } diff --git a/Sources/CodexBarCore/Providers/Groq/GroqConsoleUsageSnapshot.swift b/Sources/CodexBarCore/Providers/Groq/GroqConsoleUsageSnapshot.swift index 84cebce395..95d74cd191 100644 --- a/Sources/CodexBarCore/Providers/Groq/GroqConsoleUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/Groq/GroqConsoleUsageSnapshot.swift @@ -195,6 +195,7 @@ public struct GroqConsoleUsageSnapshot: Codable, Equatable, Sendable { last30DaysCostUSD: total.costUSD, last30DaysRequests: total.requests, historyDays: self.historyDays, + costProvenance: .vendorMetered, daily: daily, updatedAt: self.updatedAt) } diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift b/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift index db653f1a84..6233420093 100644 --- a/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift +++ b/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift @@ -291,6 +291,7 @@ public struct MistralUsageSnapshot: Codable, Sendable { historyDays: window.coveredDays, historyCoverageIsEstablished: window.coverageIsEstablished, historyLabel: window.isMonthToDate ? "This month" : nil, + costProvenance: .vendorMetered, daily: entries, updatedAt: window.observationEnd) } diff --git a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageSnapshot.swift b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageSnapshot.swift index 5b4030eca8..6401d78684 100644 --- a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageSnapshot.swift @@ -268,6 +268,7 @@ public struct OpenAIAPIUsageSnapshot: Codable, Equatable, Sendable { last30DaysCostUSD: total.costUSD, last30DaysRequests: total.requests, historyDays: self.historyDays, + costProvenance: .vendorMetered, daily: daily, updatedAt: self.updatedAt) } diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageSnapshot.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageSnapshot.swift index 6f0adc4ebe..de46bcc9ab 100644 --- a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageSnapshot.swift @@ -175,6 +175,7 @@ public struct OpenCodeGoUsageSnapshot: Sendable { CostUsageFetcher.tokenSnapshot( from: CostUsageDailyReport(data: self.daily, summary: nil), now: self.updatedAt, - historyDays: historyDays) + historyDays: historyDays, + costProvenance: .listPriceEstimate) } } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCacheModels.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCacheModels.swift index 8484476d6d..4af8c32169 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCacheModels.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCacheModels.swift @@ -83,6 +83,10 @@ struct CostUsageCodexPreviousReport: Codable, Equatable { var costUSD: Double? var totalTokens: Int? var requestCount: Int? + var inputTokens: Int? + var outputTokens: Int? + var cacheReadTokens: Int? + var reasoningTokens: Int? var standardCostUSD: Double? var priorityCostUSD: Double? var standardTokens: Int? @@ -93,6 +97,10 @@ struct CostUsageCodexPreviousReport: Codable, Equatable { self.costUSD = breakdown.costUSD self.totalTokens = breakdown.totalTokens self.requestCount = breakdown.requestCount + self.inputTokens = breakdown.inputTokens + self.outputTokens = breakdown.outputTokens + self.cacheReadTokens = breakdown.cacheReadTokens + self.reasoningTokens = breakdown.reasoningTokens self.standardCostUSD = breakdown.standardCostUSD self.priorityCostUSD = breakdown.priorityCostUSD self.standardTokens = breakdown.standardTokens @@ -105,6 +113,10 @@ struct CostUsageCodexPreviousReport: Codable, Equatable { costUSD: self.costUSD, totalTokens: self.totalTokens, requestCount: self.requestCount, + inputTokens: self.inputTokens, + outputTokens: self.outputTokens, + cacheReadTokens: self.cacheReadTokens, + reasoningTokens: self.reasoningTokens, standardCostUSD: self.standardCostUSD, priorityCostUSD: self.priorityCostUSD, standardTokens: self.standardTokens, @@ -118,11 +130,15 @@ struct CostUsageCodexPreviousReport: Codable, Equatable { var cacheReadTokens: Int? var cacheCreationTokens: Int? var outputTokens: Int? + var reasoningTokens: Int? var totalTokens: Int? var requestCount: Int? var costUSD: Double? var modelsUsed: [String]? var modelBreakdowns: [ModelBreakdown]? + var unpricedRequestCount: Int? + var unmeteredRequestCount: Int? + var estimatedRequestCount: Int? init(_ entry: CostUsageDailyReport.Entry) { self.date = entry.date @@ -130,11 +146,15 @@ struct CostUsageCodexPreviousReport: Codable, Equatable { self.cacheReadTokens = entry.cacheReadTokens self.cacheCreationTokens = entry.cacheCreationTokens self.outputTokens = entry.outputTokens + self.reasoningTokens = entry.reasoningTokens self.totalTokens = entry.totalTokens self.requestCount = entry.requestCount self.costUSD = entry.costUSD self.modelsUsed = entry.modelsUsed self.modelBreakdowns = entry.modelBreakdowns?.map(ModelBreakdown.init) + self.unpricedRequestCount = entry.unpricedRequestCount + self.unmeteredRequestCount = entry.unmeteredRequestCount + self.estimatedRequestCount = entry.estimatedRequestCount } var dailyReportValue: CostUsageDailyReport.Entry { @@ -144,11 +164,15 @@ struct CostUsageCodexPreviousReport: Codable, Equatable { outputTokens: self.outputTokens, cacheReadTokens: self.cacheReadTokens, cacheCreationTokens: self.cacheCreationTokens, + reasoningTokens: self.reasoningTokens, totalTokens: self.totalTokens, requestCount: self.requestCount, costUSD: self.costUSD, modelsUsed: self.modelsUsed, - modelBreakdowns: self.modelBreakdowns?.map(\.dailyReportValue)) + modelBreakdowns: self.modelBreakdowns?.map(\.dailyReportValue), + unpricedRequestCount: self.unpricedRequestCount, + unmeteredRequestCount: self.unmeteredRequestCount, + estimatedRequestCount: self.estimatedRequestCount) } } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCustomPricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCustomPricing.swift new file mode 100644 index 0000000000..7d5618a545 --- /dev/null +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCustomPricing.swift @@ -0,0 +1,209 @@ +#if canImport(CryptoKit) +import CryptoKit +#else +import Crypto +#endif +import Foundation + +/// User overlay for list-price estimates. Values are USD per million tokens. +/// Exact key match only; `0` is free; a missing field stays unknown and is not +/// filled from models.dev or bundled tables. +public struct CostUsageCustomPricing: Sendable, Equatable { + public struct Rates: Sendable, Equatable { + public var input: Double? + public var output: Double? + public var cacheRead: Double? + public var cacheWrite: Double? + + public init(input: Double? = nil, output: Double? = nil, cacheRead: Double? = nil, cacheWrite: Double? = nil) { + self.input = input + self.output = output + self.cacheRead = cacheRead + self.cacheWrite = cacheWrite + } + + public var hasAnyRate: Bool { + self.input != nil || self.output != nil || self.cacheRead != nil || self.cacheWrite != nil + } + } + + public let entries: [String: Rates] + public let fingerprint: String + + public init(entries: [String: Rates], fingerprint: String) { + self.entries = entries + self.fingerprint = fingerprint + } + + public static let empty = CostUsageCustomPricing(entries: [:], fingerprint: "none") + + public static let fileName = "custom-pricing.json" + + public static func defaultFileURL(fileManager: FileManager = .default) -> URL { + AppGroupSupport.localFallbackDirectory(fileManager: fileManager) + .appendingPathComponent(self.fileName, isDirectory: false) + } + + public static func load( + fileURL: URL? = nil, + fileManager: FileManager = .default, + environment: [String: String] = ProcessInfo.processInfo.environment) -> CostUsageCustomPricing + { + if fileURL == nil, self.isRunningTests(environment) { + return .empty + } + let url = fileURL ?? self.defaultFileURL(fileManager: fileManager) + guard fileManager.fileExists(atPath: url.path), + let data = try? Data(contentsOf: url) + else { return .empty } + return self.parse(data) + } + + private static func isRunningTests(_ environment: [String: String]) -> Bool { + let keys = [ + "XCTestConfigurationFilePath", + "XCTestBundlePath", + "XCTestSessionIdentifier", + "SWIFT_TESTING_ENABLED", + "TESTING_LIBRARY_VERSION", + "SWIFT_TESTING", + ] + if keys.contains(where: { environment[$0] != nil }) { + return true + } + if keys.contains(where: { ProcessInfo.processInfo.environment[$0] != nil }) { + return true + } + return Bundle.allBundles.contains { $0.bundlePath.hasSuffix(".xctest") } + } + + public static func parse(_ data: Data) -> CostUsageCustomPricing { + guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return .empty + } + var entries: [String: Rates] = [:] + for (rawKey, rawValue) in object { + let key = self.normalizeKey(rawKey) + guard !key.isEmpty, let rates = self.rates(from: rawValue) else { continue } + entries[key] = rates + } + let fingerprint = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + return CostUsageCustomPricing(entries: entries, fingerprint: fingerprint) + } + + public func rates(providerID: String? = nil, model: String) -> Rates? { + let modelKey = Self.normalizeKey(model) + guard !modelKey.isEmpty else { return nil } + if let exact = self.entries[modelKey] { + return exact + } + if let providerID { + let combined = Self.normalizeKey("\(providerID)/\(model)") + if let match = self.entries[combined] { + return match + } + } + return nil + } + + public func costUSD( + providerID: String? = nil, + model: String, + inputTokens: Int, + outputTokens: Int, + cacheReadTokens: Int = 0, + cacheWriteTokens: Int = 0) -> Double? + { + guard let rates = self.rates(providerID: providerID, model: model) else { return nil } + return Self.costUSD( + rates: rates, + inputTokens: inputTokens, + outputTokens: outputTokens, + cacheReadTokens: cacheReadTokens, + cacheWriteTokens: cacheWriteTokens) + } + + func estimatedCodexCostUSD( + model: String, + inputTokens: Int, + cachedInputTokens: Int, + outputTokens: Int, + cacheWriteInputTokens: Int) -> Double? + { + let cached = max(0, cachedInputTokens) + let written = max(0, cacheWriteInputTokens) + let uncachedInput = max(0, inputTokens - cached - written) + return self.costUSD( + providerID: CostUsagePricing.codexModelsDevProviderID, + model: model, + inputTokens: uncachedInput, + outputTokens: outputTokens, + cacheReadTokens: cached, + cacheWriteTokens: written) + } + + static func costUSD( + rates: Rates, + inputTokens: Int, + outputTokens: Int, + cacheReadTokens: Int, + cacheWriteTokens: Int) -> Double? + { + var total = 0.0 + if inputTokens > 0 { + guard let rate = rates.input else { return nil } + total += Double(inputTokens) * Self.perToken(rate) + } + if outputTokens > 0 { + guard let rate = rates.output else { return nil } + total += Double(outputTokens) * Self.perToken(rate) + } + if cacheReadTokens > 0 { + guard let rate = rates.cacheRead else { return nil } + total += Double(cacheReadTokens) * Self.perToken(rate) + } + if cacheWriteTokens > 0 { + guard let rate = rates.cacheWrite else { return nil } + total += Double(cacheWriteTokens) * Self.perToken(rate) + } + return total + } + + public static func perToken(_ perMillion: Double) -> Double { + perMillion / 1_000_000 + } + + static func normalizeKey(_ raw: String) -> String { + raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } + + private static func rates(from value: Any) -> Rates? { + guard let object = value as? [String: Any] else { return nil } + let rates = Rates( + input: self.rate(object["input"]), + output: self.rate(object["output"]), + cacheRead: self.rate(object["cacheRead"]) ?? self.rate(object["cache_read"]), + cacheWrite: self.rate(object["cacheWrite"]) + ?? self.rate(object["cache_write"]) + ?? self.rate(object["cacheCreation"]) + ?? self.rate(object["cache_creation"])) + return rates.hasAnyRate ? rates : nil + } + + /// `0` is a valid free rate. Non-finite and negative values are unknown. + private static func rate(_ value: Any?) -> Double? { + guard let value else { return nil } + let number: Double + if let parsed = value as? Double { + number = parsed + } else if let parsed = value as? Int { + number = Double(parsed) + } else if let parsed = value as? NSNumber { + number = parsed.doubleValue + } else { + return nil + } + guard number.isFinite, number >= 0 else { return nil } + return number + } +} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+Overlay.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+Overlay.swift new file mode 100644 index 0000000000..f9b3d97481 --- /dev/null +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+Overlay.swift @@ -0,0 +1,72 @@ +import Foundation + +extension CostUsagePricing { + static func codexCostUSD( + model: String, + inputTokens: Int, + cachedInputTokens: Int, + outputTokens: Int, + cacheWriteInputTokens: Int = 0, + modelsDevCatalog: ModelsDevCatalog? = nil, + modelsDevCacheRoot: URL? = nil, + customPricing: CostUsageCustomPricing? = nil) -> Double? + { + if let cost = (customPricing ?? self.customPricingOverlay()).estimatedCodexCostUSD( + model: model, + inputTokens: inputTokens, + cachedInputTokens: cachedInputTokens, + outputTokens: outputTokens, + cacheWriteInputTokens: cacheWriteInputTokens) + { + return cost + } + guard let pricing = self.resolvedCodexPricing( + model: model, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + else { return nil } + return self.codexCostUSD( + pricing: pricing, + inputTokens: inputTokens, + cachedInputTokens: cachedInputTokens, + cacheWriteInputTokens: cacheWriteInputTokens, + outputTokens: outputTokens) + } + + static func codexAggregateCostUSD( + model: String, + inputTokens: Int, + cachedInputTokens: Int, + outputTokens: Int, + cacheWriteInputTokens: Int = 0, + modelsDevCatalog: ModelsDevCatalog? = nil, + modelsDevCacheRoot: URL? = nil, + customPricing: CostUsageCustomPricing? = nil) -> Double? + { + if let cost = (customPricing ?? self.customPricingOverlay()).estimatedCodexCostUSD( + model: model, + inputTokens: inputTokens, + cachedInputTokens: cachedInputTokens, + outputTokens: outputTokens, + cacheWriteInputTokens: cacheWriteInputTokens) + { + return cost + } + guard let pricing = self.resolvedCodexPricing( + model: model, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + else { return nil } + if let thresholdTokens = pricing.thresholdTokens, + max(0, inputTokens) > thresholdTokens + { + return nil + } + return self.codexCostUSD( + pricing: pricing, + inputTokens: inputTokens, + cachedInputTokens: cachedInputTokens, + cacheWriteInputTokens: cacheWriteInputTokens, + outputTokens: outputTokens) + } +} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift index 4fce93e6ec..30d366cb5e 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift @@ -420,7 +420,7 @@ enum CostUsagePricing { cacheReadInputCostPerTokenAboveThreshold: 6e-7), ] - private static let codexModelsDevProviderID = "openai" + static let codexModelsDevProviderID = "openai" /// Provider IDs emitted by Codex-compatible clients that have matching entries in models.dev. /// /// The route prefix is part of the model identity for local usage estimates. Keep both the @@ -539,56 +539,11 @@ enum CostUsagePricing { return trimmed } - static func codexCostUSD( - model: String, - inputTokens: Int, - cachedInputTokens: Int, - outputTokens: Int, - cacheWriteInputTokens: Int = 0, - modelsDevCatalog: ModelsDevCatalog? = nil, - modelsDevCacheRoot: URL? = nil) -> Double? - { - guard let pricing = self.resolvedCodexPricing( - model: model, - modelsDevCatalog: modelsDevCatalog, - modelsDevCacheRoot: modelsDevCacheRoot) - else { return nil } - return self.codexCostUSD( - pricing: pricing, - inputTokens: inputTokens, - cachedInputTokens: cachedInputTokens, - cacheWriteInputTokens: cacheWriteInputTokens, - outputTokens: outputTokens) - } - - static func codexAggregateCostUSD( - model: String, - inputTokens: Int, - cachedInputTokens: Int, - outputTokens: Int, - cacheWriteInputTokens: Int = 0, - modelsDevCatalog: ModelsDevCatalog? = nil, - modelsDevCacheRoot: URL? = nil) -> Double? - { - guard let pricing = self.resolvedCodexPricing( - model: model, - modelsDevCatalog: modelsDevCatalog, - modelsDevCacheRoot: modelsDevCacheRoot) - else { return nil } - if let thresholdTokens = pricing.thresholdTokens, - max(0, inputTokens) > thresholdTokens - { - return nil - } - return self.codexCostUSD( - pricing: pricing, - inputTokens: inputTokens, - cachedInputTokens: cachedInputTokens, - cacheWriteInputTokens: cacheWriteInputTokens, - outputTokens: outputTokens) + static func customPricingOverlay(fileURL: URL? = nil) -> CostUsageCustomPricing { + CostUsageCustomPricing.load(fileURL: fileURL) } - private static func resolvedCodexPricing( + static func resolvedCodexPricing( model: String, modelsDevCatalog: ModelsDevCatalog?, modelsDevCacheRoot: URL?) -> CodexPricing? @@ -666,7 +621,8 @@ enum CostUsagePricing { cacheWriteInputTokens: Int = 0, outputTokens: Int, modelsDevCatalog: ModelsDevCatalog? = nil, - modelsDevCacheRoot: URL? = nil) -> Double? + modelsDevCacheRoot: URL? = nil, + customPricing: CostUsageCustomPricing? = nil) -> Double? { guard let multiplier = self.codexAPIFastMultiplier(model: model) else { return nil } // OpenAI does not support API Fast processing for long-context requests. Do not combine @@ -682,7 +638,8 @@ enum CostUsagePricing { outputTokens: outputTokens, cacheWriteInputTokens: cacheWriteInputTokens, modelsDevCatalog: modelsDevCatalog, - modelsDevCacheRoot: modelsDevCacheRoot) + modelsDevCacheRoot: modelsDevCacheRoot, + customPricing: customPricing) .map { $0 * multiplier } } @@ -696,7 +653,7 @@ enum CostUsagePricing { } } - private static func codexCostUSD( + static func codexCostUSD( pricing: CodexPricing, inputTokens: Int, cachedInputTokens: Int, diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricingKey.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricingKey.swift index 7148b861c8..27c57944a6 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricingKey.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricingKey.swift @@ -10,11 +10,13 @@ enum CostUsagePricingKey { modelsDevArtifact: ModelsDevCacheArtifact?, formulaVersion: Int, parserHash: String? = nil, - modelsDevProviderIDs: Set = CostUsagePricing.codexModelsDevProviderIDs) -> String + modelsDevProviderIDs: Set = CostUsagePricing.codexModelsDevProviderIDs, + customPricingFingerprint: String = CostUsageCustomPricing.load().fingerprint) -> String { var parts = [ "costFormulaVersion=\(formulaVersion)", "builtInPricing:\n\(CostUsagePricing.codexBuiltInPricingFingerprint())", + "customPricing=\(customPricingFingerprint)", ] if let parserHash { parts.append("parserHash=\(parserHash)") diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift index 31f435d89f..8a2543e9e6 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift @@ -153,7 +153,8 @@ extension CostUsageScanner { rows: [CodexUsageRow], priorityTurns: [String: CodexPriorityTurnMetadata], modelsDevCatalog: ModelsDevCatalog?, - modelsDevCacheRoot: URL?) -> CodexRowCostBreakdown + modelsDevCacheRoot: URL?, + customPricing: CostUsageCustomPricing? = nil) -> CodexRowCostBreakdown { var breakdown = CodexRowCostBreakdown() for row in rows { @@ -183,7 +184,8 @@ extension CostUsageScanner { for: row, priorityTurns: priorityTurns, modelsDevCatalog: modelsDevCatalog, - modelsDevCacheRoot: modelsDevCacheRoot) + modelsDevCacheRoot: modelsDevCacheRoot, + customPricing: customPricing) else { breakdown.hasIncompletePricing = breakdown.hasIncompletePricing || hasTokens continue @@ -1186,7 +1188,8 @@ extension CostUsageScanner { state.contributingSessionIds.contains(sessionId), uniqueRows.isEmpty, usageDays.isEmpty, - parsed.bufferedSubagentLines == nil + parsed.bufferedSubagentLines == nil, + parsed.bufferedUnresolvedForkLines == nil { cache.files.removeValue(forKey: input.metadata.path) return @@ -1350,7 +1353,10 @@ extension CostUsageScanner { guard isForceRescan else { return } for key in cache.files.keys { guard let old = cache.files[key] else { continue } - guard !old.touchesCodexScanWindow(sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) + guard !old.touchesCodexScanWindow( + sinceKey: range.scanSinceKey, + untilKey: range.scanUntilKey, + calendar: range.calendar) else { continue } Self.applyFileDays(cache: &cache, fileDays: old.days, sign: -1) cache.files.removeValue(forKey: key) @@ -1397,124 +1403,76 @@ extension CostUsageScanner { priorityTurns: priorityTurns) } var entries: [CostUsageDailyReport.Entry] = [] - var (totalInput, totalCacheRead, totalOutput, totalTokens) = (0, 0, 0, 0) + var (totalInput, totalCacheRead, totalOutput, totalReasoning, totalTokens) = (0, 0, 0, 0, 0) var (totalCost, costSeen) = (0.0, false) - let dayKeys = self.codexReportDayKeys(cache: reportCache, range: range) - var rowsByDayModel: [String: [String: [CodexUsageRow]]] = [:] - var unresolvedRowGroups = Set() - var modeOwnershipMismatchGroups = Set() - var priorityEvidenceGroups = Set() - var incompletePricingEvidenceGroups = Set() - var authoritativeCostEvidenceGroups = Set() + let unmeteredByDay = Self.unresolvedForkUnmeteredCounts(cache: reportCache, range: range) + let dayKeys = Array(Set(self.codexReportDayKeys(cache: reportCache, range: range) + unmeteredByDay.keys)) + .sorted() + .filter { + CostUsageDayRange.isInRange(dayKey: $0, since: range.sinceKey, until: range.untilKey) + } + let catalog = catalogResolver.load(modelsDevCatalogLoader) + var pricing = CodexReportDayPricingContext( + rowsByDayModel: [:], + unresolvedRowGroups: [], + modeOwnershipMismatchGroups: [], + priorityEvidenceGroups: [], + incompletePricingEvidenceGroups: [], + authoritativeCostEvidenceGroups: [], + priorityTurns: priorityTurns, + modelsDevCatalog: catalog, + modelsDevCacheRoot: modelsDevCacheRoot, + customPricing: CostUsagePricing.customPricingOverlay()) for usage in reportCache.files.values { let reconciled = self.codexCanonicalPricingRows(usage) - unresolvedRowGroups.formUnion(reconciled.unresolvedGroups) + pricing.unresolvedRowGroups.formUnion(reconciled.unresolvedGroups) let modeEvidence = self.codexPricingModeEvidence( usage: usage, reconciledRows: reconciled.rows, range: range, priorityTurns: priorityTurns) - modeOwnershipMismatchGroups.formUnion(modeEvidence.mismatchGroups) - priorityEvidenceGroups.formUnion(modeEvidence.priorityGroups) - incompletePricingEvidenceGroups.formUnion(self.codexIncompletePricingEvidenceGroups( + pricing.modeOwnershipMismatchGroups.formUnion(modeEvidence.mismatchGroups) + pricing.priorityEvidenceGroups.formUnion(modeEvidence.priorityGroups) + pricing.incompletePricingEvidenceGroups.formUnion(self.codexIncompletePricingEvidenceGroups( usage: usage, range: range, priorityTurns: priorityTurns, - modelsDevCatalog: catalogResolver.load(modelsDevCatalogLoader), - modelsDevCacheRoot: modelsDevCacheRoot)) + modelsDevCatalog: catalog, + modelsDevCacheRoot: modelsDevCacheRoot, + customPricing: pricing.customPricing)) for row in usage.codexRows ?? [] where (row.knownCostNanos ?? 0) != 0 { - authoritativeCostEvidenceGroups.insert(CodexDayModelKey(day: row.day, model: row.model)) + pricing.authoritativeCostEvidenceGroups.insert(CodexDayModelKey(day: row.day, model: row.model)) } for row in reconciled.rows where CostUsageDayRange.isInRange( dayKey: row.day, since: range.sinceKey, until: range.untilKey) { - rowsByDayModel[row.day, default: [:]][row.model, default: []].append(row) + pricing.rowsByDayModel[row.day, default: [:]][row.model, default: []].append(row) } } for day in dayKeys { - guard let models = reportCache.days[day] else { continue } - let modelNames = models.keys.sorted() - - var dayInput = 0 - var dayCacheRead = 0 - var dayOutput = 0 - var breakdown: [CostUsageDailyReport.ModelBreakdown] = [] - var dayCost: Double = 0 - var dayCostSeen = false - - for model in modelNames { - let packed = models[model] ?? [0, 0, 0] - let input = packed[safe: 0] ?? 0 - let cached = packed[safe: 1] ?? 0 - let output = packed[safe: 2] ?? 0 - let totalTokens = input + output - - dayInput += input - dayCacheRead += cached - dayOutput += output - - let rows = rowsByDayModel[day]?[model] ?? [] - let rowCost = rows.isEmpty ? nil : Self.codexRowCostBreakdown( - rows: rows, - priorityTurns: priorityTurns, - modelsDevCatalog: catalogResolver.load(modelsDevCatalogLoader), - modelsDevCacheRoot: modelsDevCacheRoot) - let group = CodexDayModelKey(day: day, model: model) - let rowCostIsTrusted = !unresolvedRowGroups.contains(group) - && !modeOwnershipMismatchGroups.contains(group) - && rowCost?.isTrusted(canonicalTotalTokens: totalTokens) == true - let aggregateCost = priorityEvidenceGroups.contains(group) - || incompletePricingEvidenceGroups.contains(group) - || (unresolvedRowGroups.contains(group) && authoritativeCostEvidenceGroups.contains(group)) - || rowCost?.hasIncompletePricing == true - ? nil - : CostUsagePricing.codexAggregateCostUSD( - model: model, - inputTokens: input, - cachedInputTokens: cached, - outputTokens: output, - modelsDevCatalog: catalogResolver.load(modelsDevCatalogLoader), - modelsDevCacheRoot: modelsDevCacheRoot) - let cost = rowCostIsTrusted - ? rowCost?.totalCostUSD ?? aggregateCost - : aggregateCost - let hasModeSplit = rowCostIsTrusted && rowCost?.hasModeSplit == true - breakdown.append( - CostUsageDailyReport.ModelBreakdown( - modelName: model, - costUSD: cost, - totalTokens: totalTokens, - standardCostUSD: hasModeSplit ? rowCost?.optionalStandardCostUSD : nil, - priorityCostUSD: hasModeSplit ? rowCost?.optionalPriorityCostUSD : nil, - standardTokens: hasModeSplit ? rowCost?.optionalStandardTokens : nil, - priorityTokens: hasModeSplit ? rowCost?.optionalPriorityTokens : nil)) - if let cost { - dayCost += cost - dayCostSeen = true + let unmetered = unmeteredByDay[day] ?? 0 + guard let models = reportCache.days[day] else { + if let entry = Self.unmeteredForkReportEntry(day: day, unmetered: unmetered) { + entries.append(entry) } + continue } - - let dayTotal = dayInput + dayOutput - let entryCost = dayCostSeen ? dayCost : nil - entries.append(CostUsageDailyReport.Entry( - date: day, - inputTokens: dayInput, - outputTokens: dayOutput, - cacheReadTokens: dayCacheRead > 0 ? dayCacheRead : nil, - totalTokens: dayTotal, - costUSD: entryCost, - modelsUsed: modelNames, - modelBreakdowns: Self.sortedModelBreakdowns(breakdown))) - - totalInput += dayInput - totalCacheRead += dayCacheRead - totalOutput += dayOutput - totalTokens += dayTotal - if let entryCost { + let entry = Self.makeCodexBilledDayEntry( + day: day, + models: models, + unmetered: unmetered, + pricing: pricing) + entries.append(entry) + totalInput += entry.inputTokens ?? 0 + totalCacheRead += entry.cacheReadTokens ?? 0 + totalOutput += entry.outputTokens ?? 0 + totalReasoning += entry.reasoningTokens ?? 0 + totalTokens += entry.totalTokens ?? 0 + if let entryCost = entry.costUSD { totalCost += entryCost costSeen = true } @@ -1526,6 +1484,7 @@ extension CostUsageScanner { totalInputTokens: totalInput, totalOutputTokens: totalOutput, cacheReadTokens: totalCacheRead > 0 ? totalCacheRead : nil, + reasoningTokens: totalReasoning > 0 ? totalReasoning : nil, totalTokens: totalTokens, totalCostUSD: costSeen ? totalCost : nil) @@ -1603,11 +1562,3 @@ extension [UInt8] { return self[index] } } - -extension CostUsageFileUsage { - func touchesCodexScanWindow(sinceKey: String, untilKey: String) -> Bool { - self.days.keys.contains { - CostUsageScanner.CostUsageDayRange.isInRange(dayKey: $0, since: sinceKey, until: untilKey) - } - } -} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+ForkCoverage.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+ForkCoverage.swift new file mode 100644 index 0000000000..40b7588df3 --- /dev/null +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+ForkCoverage.swift @@ -0,0 +1,196 @@ +import Foundation + +extension CostUsageScanner { + /// Missing-parent forks stay out of priced totals. Count them as unmetered so Spend + /// coverage can show the gap instead of silently dropping the session. + static func unresolvedForkUnmeteredCounts( + cache: CostUsageCache, + range: CostUsageDayRange) -> [String: Int] + { + var counts: [String: Int] = [:] + for usage in cache.files.values { + guard self.isUnresolvedMissingParentFork(usage), + !self.codexFileHasBilledTokens(usage) + else { continue } + let unixMs = usage.codexSession?.startedAtUnixMs + ?? usage.codexSession?.latestActivityUnixMs + ?? usage.mtimeUnixMs + guard unixMs > 0 else { continue } + let dayKey = CostUsageDayRange.dayKey( + from: Date(timeIntervalSince1970: TimeInterval(unixMs) / 1000), + calendar: range.calendar) + guard CostUsageDayRange.isInRange(dayKey: dayKey, since: range.sinceKey, until: range.untilKey) + else { continue } + counts[dayKey, default: 0] += 1 + } + return counts + } + + static func isUnresolvedMissingParentFork(_ usage: CostUsageFileUsage) -> Bool { + guard usage.forkedFromId != nil else { return false } + if let key = usage.forkBaselineDependencyKey { + return key.hasPrefix("missing|") + } + return true + } + + static func codexFileHasBilledTokens(_ usage: CostUsageFileUsage) -> Bool { + if (usage.codexRows ?? []).contains(where: { $0.input > 0 || $0.cached > 0 || $0.output > 0 }) { + return true + } + return usage.days.values.contains { models in + models.values.contains { packed in packed.contains { $0 > 0 } } + } + } + + struct CodexReportDayPricingContext { + var rowsByDayModel: [String: [String: [CodexUsageRow]]] + var unresolvedRowGroups: Set + var modeOwnershipMismatchGroups: Set + var priorityEvidenceGroups: Set + var incompletePricingEvidenceGroups: Set + var authoritativeCostEvidenceGroups: Set + var priorityTurns: [String: CodexPriorityTurnMetadata] + var modelsDevCatalog: ModelsDevCatalog + var modelsDevCacheRoot: URL? + var customPricing: CostUsageCustomPricing + } + + static func unmeteredForkReportEntry(day: String, unmetered: Int) -> CostUsageDailyReport.Entry? { + guard unmetered > 0 else { return nil } + return CostUsageDailyReport.Entry( + date: day, + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + costUSD: nil, + modelsUsed: nil, + modelBreakdowns: nil, + unmeteredRequestCount: unmetered) + } + + static func makeCodexBilledDayEntry( + day: String, + models: [String: [Int]], + unmetered: Int, + pricing: CodexReportDayPricingContext) -> CostUsageDailyReport.Entry + { + let modelNames = models.keys.sorted() + var dayInput = 0 + var dayCacheRead = 0 + var dayOutput = 0 + var dayReasoning = 0 + var breakdown: [CostUsageDailyReport.ModelBreakdown] = [] + var dayCost: Double = 0 + var dayCostSeen = false + + for model in modelNames { + let packed = models[model] ?? [0, 0, 0] + let input = packed[safe: 0] ?? 0 + let cached = packed[safe: 1] ?? 0 + let output = packed[safe: 2] ?? 0 + let totalTokens = input + output + let rows = pricing.rowsByDayModel[day]?[model] ?? [] + let reasoning = rows.compactMap(\.reasoning).reduce(0, +) + + dayInput += input + dayCacheRead += cached + dayOutput += output + if reasoning > 0 { + dayReasoning += reasoning + } + + let rowCost = rows.isEmpty ? nil : Self.codexRowCostBreakdown( + rows: rows, + priorityTurns: pricing.priorityTurns, + modelsDevCatalog: pricing.modelsDevCatalog, + modelsDevCacheRoot: pricing.modelsDevCacheRoot, + customPricing: pricing.customPricing) + let group = CodexDayModelKey(day: day, model: model) + let rowCostIsTrusted = !pricing.unresolvedRowGroups.contains(group) + && !pricing.modeOwnershipMismatchGroups.contains(group) + && rowCost?.isTrusted(canonicalTotalTokens: totalTokens) == true + let aggregateCost = pricing.priorityEvidenceGroups.contains(group) + || pricing.incompletePricingEvidenceGroups.contains(group) + || (pricing.unresolvedRowGroups.contains(group) + && pricing.authoritativeCostEvidenceGroups.contains(group)) + || rowCost?.hasIncompletePricing == true + ? nil + : CostUsagePricing.codexAggregateCostUSD( + model: model, + inputTokens: input, + cachedInputTokens: cached, + outputTokens: output, + modelsDevCatalog: pricing.modelsDevCatalog, + modelsDevCacheRoot: pricing.modelsDevCacheRoot, + customPricing: pricing.customPricing) + let cost = rowCostIsTrusted + ? rowCost?.totalCostUSD ?? aggregateCost + : aggregateCost + let hasModeSplit = rowCostIsTrusted && rowCost?.hasModeSplit == true + breakdown.append( + CostUsageDailyReport.ModelBreakdown( + modelName: model, + costUSD: cost, + totalTokens: totalTokens, + inputTokens: input, + outputTokens: output, + cacheReadTokens: cached > 0 ? cached : nil, + reasoningTokens: reasoning > 0 ? reasoning : nil, + standardCostUSD: hasModeSplit ? rowCost?.optionalStandardCostUSD : nil, + priorityCostUSD: hasModeSplit ? rowCost?.optionalPriorityCostUSD : nil, + standardTokens: hasModeSplit ? rowCost?.optionalStandardTokens : nil, + priorityTokens: hasModeSplit ? rowCost?.optionalPriorityTokens : nil)) + if let cost { + dayCost += cost + dayCostSeen = true + } + } + + let dayTotal = dayInput + dayOutput + let entryCost = dayCostSeen ? dayCost : nil + return CostUsageDailyReport.Entry( + date: day, + inputTokens: dayInput, + outputTokens: dayOutput, + cacheReadTokens: dayCacheRead > 0 ? dayCacheRead : nil, + reasoningTokens: dayReasoning > 0 ? dayReasoning : nil, + totalTokens: dayTotal, + costUSD: entryCost, + modelsUsed: modelNames, + modelBreakdowns: Self.sortedModelBreakdowns(breakdown), + unpricedRequestCount: entryCost == nil && dayTotal > 0 ? 1 : nil, + unmeteredRequestCount: unmetered > 0 ? unmetered : nil) + } +} + +extension CostUsageFileUsage { + func touchesCodexScanWindow( + sinceKey: String, + untilKey: String, + calendar: Calendar = CostUsageScanner.CostUsageDayRange.localGregorianCalendar()) -> Bool + { + if self.days.keys.contains(where: { + CostUsageScanner.CostUsageDayRange.isInRange(dayKey: $0, since: sinceKey, until: untilKey) + }) { + return true + } + + // Missing-parent forks keep empty billed days on purpose. Session timestamps still + // place them in the scan window so force-rescan prune cannot drop the unmetered gap. + let isIncompleteFork = self.codexBufferedUnresolvedForkLines != nil + || CostUsageScanner.isUnresolvedMissingParentFork(self) + guard isIncompleteFork else { return false } + + if let unixMs = self.codexSession?.startedAtUnixMs ?? self.codexSession?.latestActivityUnixMs { + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey( + from: Date(timeIntervalSince1970: TimeInterval(unixMs) / 1000), + calendar: calendar) + return CostUsageScanner.CostUsageDayRange.isInRange( + dayKey: dayKey, + since: sinceKey, + until: untilKey) + } + return true + } +} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+PricingRows.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+PricingRows.swift index 3cd0c80980..fd83dcc51c 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+PricingRows.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+PricingRows.swift @@ -5,7 +5,8 @@ extension CostUsageScanner { for row: CodexUsageRow, priorityTurns: [String: CodexPriorityTurnMetadata] = [:], modelsDevCatalog: ModelsDevCatalog?, - modelsDevCacheRoot: URL?) -> Double? + modelsDevCacheRoot: URL?, + customPricing: CostUsageCustomPricing? = nil) -> Double? { if let authoritativeCostNanos = row.knownCostNanos { return Double(authoritativeCostNanos) / self.costScale @@ -15,13 +16,15 @@ extension CostUsageScanner { let pricedModel = priorityMetadata.map { self.codexPriorityPricingModel(for: row, priorityMetadata: $0) } ?? row.pricingModel ?? row.model + let overlay = customPricing ?? .empty let baseCost = CostUsagePricing.codexCostUSD( model: pricedModel, inputTokens: row.input, cachedInputTokens: row.cached, outputTokens: row.output, modelsDevCatalog: modelsDevCatalog, - modelsDevCacheRoot: modelsDevCacheRoot) + modelsDevCacheRoot: modelsDevCacheRoot, + customPricing: overlay) guard isPriority else { return baseCost } guard let priorityCost = CostUsagePricing.codexPriorityCostUSD( model: pricedModel, @@ -29,7 +32,8 @@ extension CostUsageScanner { cachedInputTokens: row.cached, outputTokens: row.output, modelsDevCatalog: modelsDevCatalog, - modelsDevCacheRoot: modelsDevCacheRoot) + modelsDevCacheRoot: modelsDevCacheRoot, + customPricing: overlay) else { return baseCost } return max(priorityCost, baseCost ?? priorityCost) } @@ -38,13 +42,15 @@ extension CostUsageScanner { for row: CodexUsageRow, priorityTurns: [String: CodexPriorityTurnMetadata] = [:], modelsDevCatalog: ModelsDevCatalog?, - modelsDevCacheRoot: URL?) -> Int64? + modelsDevCacheRoot: URL?, + customPricing: CostUsageCustomPricing? = nil) -> Int64? { guard let cost = self.codexResolvedCostUSD( for: row, priorityTurns: priorityTurns, modelsDevCatalog: modelsDevCatalog, - modelsDevCacheRoot: modelsDevCacheRoot) + modelsDevCacheRoot: modelsDevCacheRoot, + customPricing: customPricing) else { return nil } let nanos = cost * self.costScale guard nanos.isFinite, nanos >= Double(Int64.min), nanos <= Double(Int64.max) else { return nil } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Projects.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Projects.swift index 485785ede5..f144ca147b 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Projects.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Projects.swift @@ -35,7 +35,11 @@ extension CostUsageScanner { { continue } - guard usage.touchesCodexScanWindow(sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) else { + guard usage.touchesCodexScanWindow( + sinceKey: range.scanSinceKey, + untilKey: range.scanUntilKey, + calendar: range.calendar) + else { continue } let sessionID = usage.sessionId ?? URL(fileURLWithPath: filePath).deletingPathExtension().lastPathComponent @@ -96,7 +100,11 @@ extension CostUsageScanner { let projectPathResolver = CodexCanonicalProjectPathResolver() var accumulatorsByProjectPath: [String: CodexProjectBreakdownAccumulator] = [:] for (filePath, usage) in cache.files { - guard usage.touchesCodexScanWindow(sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) else { + guard usage.touchesCodexScanWindow( + sinceKey: range.scanSinceKey, + untilKey: range.scanUntilKey, + calendar: range.calendar) + else { continue } var fileCache = CostUsageCache() diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+ReportReconciliation.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+ReportReconciliation.swift index a55e91667c..8614b0019d 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+ReportReconciliation.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+ReportReconciliation.swift @@ -103,7 +103,8 @@ extension CostUsageScanner { range: CostUsageDayRange, priorityTurns: [String: CodexPriorityTurnMetadata], modelsDevCatalog: ModelsDevCatalog?, - modelsDevCacheRoot: URL?) -> Set + modelsDevCacheRoot: URL?, + customPricing: CostUsageCustomPricing? = nil) -> Set { let rowsByGroup = Dictionary(grouping: usage.codexRows ?? []) { CodexDayModelKey(day: $0.day, model: $0.model) @@ -118,7 +119,8 @@ extension CostUsageScanner { rows: rows, priorityTurns: priorityTurns, modelsDevCatalog: modelsDevCatalog, - modelsDevCacheRoot: modelsDevCacheRoot) + modelsDevCacheRoot: modelsDevCacheRoot, + customPricing: customPricing) return breakdown.hasIncompletePricing ? group : nil }) } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 744f2b7288..465c43cb06 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -5068,7 +5068,8 @@ enum CostUsageScanner { let turnIDCacheMigrationPathKeys = hasPriorityMetadata ? Set(cache.files.compactMap { path, usage in usage.codexTurnIDs == nil && usage.touchesCodexScanWindow( sinceKey: range.scanSinceKey, - untilKey: range.scanUntilKey) + untilKey: range.scanUntilKey, + calendar: range.calendar) ? Self.codexPathKey(URL(fileURLWithPath: path)) : nil }) : [] @@ -5531,7 +5532,10 @@ enum CostUsageScanner { { guard let old = cache.files[key] else { continue } let shouldDrop = shouldDropAllUnscannedFiles || - old.touchesCodexScanWindow(sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) + old.touchesCodexScanWindow( + sinceKey: range.scanSinceKey, + untilKey: range.scanUntilKey, + calendar: range.calendar) guard shouldDrop else { continue } Self.applyFileDays(cache: &cache, fileDays: old.days, sign: -1) cache.files.removeValue(forKey: key) @@ -5540,7 +5544,10 @@ enum CostUsageScanner { for key in cache.files.keys { guard !shouldDropAllUnscannedFiles else { break } guard let old = cache.files[key] else { continue } - guard old.touchesCodexScanWindow(sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) + guard old.touchesCodexScanWindow( + sinceKey: range.scanSinceKey, + untilKey: range.scanUntilKey, + calendar: range.calendar) else { continue } guard FileManager.default.fileExists(atPath: key) else { Self.applyFileDays(cache: &cache, fileDays: old.days, sign: -1) diff --git a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageAggregator.swift b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageAggregator.swift new file mode 100644 index 0000000000..308d18a2c2 --- /dev/null +++ b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageAggregator.swift @@ -0,0 +1,316 @@ +import Foundation + +enum OpenCodexUsageAggregator { + struct DayAccumulator { + var input = 0 + var output = 0 + var cacheRead = 0 + var cacheCreation = 0 + var reasoning = 0 + var tokens = 0 + var cost: Double = 0 + var sawInput = false + var sawOutput = false + var sawCacheRead = false + var sawCacheCreation = false + var sawReasoning = false + var sawTokens = false + var sawCost = false + var priced = 0 + var unpriced = 0 + var unmetered = 0 + var estimated = 0 + var models: [String: ModelAccumulator] = [:] + } + + struct ModelAccumulator { + var tokens = 0 + var cost: Double = 0 + var sawTokens = false + var sawCost = false + var input: Int? + var output: Int? + var cacheRead: Int? + var cacheCreation: Int? + var reasoning: Int? + } + + struct SessionAccumulator { + var lastActivity = Date.distantPast + var input: Int? + var output: Int? + var cacheRead: Int? + var reasoning: Int? + var tokens: Int? + var requests = 0 + var cost: Double? + var models: [String: ModelAccumulator] = [:] + } + + static func snapshot( + entries: [OpenCodexUsageEntry], + now: Date, + historyDays: Int, + calendar: Calendar, + customPricing: CostUsageCustomPricing = .empty) -> CostUsageTokenSnapshot + { + let days = max(1, min(365, historyDays)) + let today = calendar.startOfDay(for: now) + let windowStart = calendar.date(byAdding: .day, value: -(days - 1), to: today) ?? today + var unique: [String: OpenCodexUsageEntry] = [:] + for entry in entries { + unique[entry.requestID] = entry + } + let windowed = unique.values.filter { $0.timestamp >= windowStart && $0.timestamp <= now } + .sorted { lhs, rhs in + if lhs.timestamp != rhs.timestamp { + return lhs.timestamp < rhs.timestamp + } + return lhs.requestID < rhs.requestID + } + + var daysByKey: [String: DayAccumulator] = [:] + var sessions: [String: SessionAccumulator] = [:] + for entry in windowed { + let dayKey = CostUsageLocalDay.key(from: entry.timestamp, calendar: calendar) + var day = daysByKey[dayKey] ?? DayAccumulator() + Self.merge(entry, into: &day, customPricing: customPricing) + daysByKey[dayKey] = day + + let sessionID = entry.conversationID ?? entry.requestID + var session = sessions[sessionID] ?? SessionAccumulator() + session.lastActivity = max(session.lastActivity, entry.timestamp) + session.requests += 1 + Self.merge(entry, into: &session, customPricing: customPricing) + sessions[sessionID] = session + } + + let daily = daysByKey.keys.sorted().compactMap { key -> CostUsageDailyReport.Entry? in + guard let day = daysByKey[key] else { return nil } + return Self.entry(dayKey: key, day: day) + } + let sessionRows = sessions.keys.sorted().compactMap { key -> CostUsageSessionBreakdown? in + guard let session = sessions[key] else { return nil } + return CostUsageSessionBreakdown( + sessionID: key, + lastActivity: session.lastActivity, + inputTokens: session.input, + cachedInputTokens: session.cacheRead, + outputTokens: session.output, + reasoningTokens: session.reasoning, + totalTokens: session.tokens, + requestCount: session.requests, + costUSD: session.cost, + modelBreakdowns: Self.modelBreakdowns(session.models)) + } + .sorted { lhs, rhs in + if lhs.lastActivity != rhs.lastActivity { + return lhs.lastActivity > rhs.lastActivity + } + return lhs.sessionID < rhs.sessionID + } + + let todayEntry = CostUsageTokenSnapshot.entry( + in: daily, + forLocalDayContaining: now, + calendar: calendar) + let windowSummary = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: nil, + last30DaysCostUSD: nil, + historyDays: days, + daily: daily, + sessions: Array(sessionRows.prefix(64)), + updatedAt: now) + .summary(forLastDays: min(30, days), calendar: calendar) + + return CostUsageTokenSnapshot( + sessionTokens: todayEntry?.totalTokens ?? (daily.isEmpty ? nil : 0), + sessionCostUSD: todayEntry?.costUSD ?? (daily.isEmpty ? nil : 0), + sessionRequests: todayEntry?.requestCount ?? (daily.isEmpty ? nil : 0), + last30DaysTokens: windowSummary.totalTokens, + last30DaysCostUSD: windowSummary.totalCostUSD, + last30DaysRequests: windowSummary.totalRequests, + historyDays: days, + historyLabel: "OpenCodex usage.jsonl", + costProvenance: .listPriceEstimate, + daily: daily, + sessions: Array(sessionRows.prefix(64)), + updatedAt: now) + } + + private static func merge( + _ entry: OpenCodexUsageEntry, + into day: inout DayAccumulator, + customPricing: CostUsageCustomPricing) + { + let usage = entry.usage + if let input = usage?.inputTokens { + day.input += input + day.sawInput = true + } + if let output = usage?.outputTokens { + day.output += output + day.sawOutput = true + } + if let cacheRead = usage?.cacheReadTokens { + day.cacheRead += cacheRead + day.sawCacheRead = true + } + if let cacheCreation = usage?.cacheCreationInputTokens { + day.cacheCreation += cacheCreation + day.sawCacheCreation = true + } + if let reasoning = usage?.reasoningOutputTokens { + day.reasoning += reasoning + day.sawReasoning = true + } + if let tokens = entry.resolvedTotalTokens { + day.tokens += tokens + day.sawTokens = true + } + day.priced += entry.usageStatus == .reported ? 1 : 0 + day.estimated += entry.usageStatus == .estimated ? 1 : 0 + day.unmetered += entry.usageStatus == .unsupported ? 1 : 0 + day.unpriced += entry.usageStatus == .unreported ? 1 : 0 + + let cost = Self.listPriceUSD(entry: entry, customPricing: customPricing) + if let cost { + day.cost += cost + day.sawCost = true + } else if entry.usageStatus == .reported { + day.unpriced += 1 + if day.priced > 0 { day.priced -= 1 } + } else if entry.usageStatus == .estimated { + day.unpriced += 1 + if day.estimated > 0 { day.estimated -= 1 } + } + + var model = day.models[entry.model] ?? ModelAccumulator() + Self.merge(entry, cost: cost, into: &model) + day.models[entry.model] = model + } + + private static func merge( + _ entry: OpenCodexUsageEntry, + into session: inout SessionAccumulator, + customPricing: CostUsageCustomPricing) + { + session.input = self.add(session.input, entry.usage?.inputTokens) + session.output = self.add(session.output, entry.usage?.outputTokens) + session.cacheRead = self.add(session.cacheRead, entry.usage?.cacheReadTokens) + session.reasoning = self.add(session.reasoning, entry.usage?.reasoningOutputTokens) + session.tokens = self.add(session.tokens, entry.resolvedTotalTokens) + let cost = self.listPriceUSD(entry: entry, customPricing: customPricing) + session.cost = self.add(session.cost, cost) + var model = session.models[entry.model] ?? ModelAccumulator() + self.merge(entry, cost: cost, into: &model) + session.models[entry.model] = model + } + + private static func merge( + _ entry: OpenCodexUsageEntry, + cost: Double?, + into model: inout ModelAccumulator) + { + model.input = self.add(model.input, entry.usage?.inputTokens) + model.output = self.add(model.output, entry.usage?.outputTokens) + model.cacheRead = self.add(model.cacheRead, entry.usage?.cacheReadTokens) + model.cacheCreation = self.add(model.cacheCreation, entry.usage?.cacheCreationInputTokens) + model.reasoning = self.add(model.reasoning, entry.usage?.reasoningOutputTokens) + if let tokens = entry.resolvedTotalTokens { + model.tokens += tokens + model.sawTokens = true + } + if let cost { + model.cost += cost + model.sawCost = true + } + } + + private static func entry(dayKey: String, day: DayAccumulator) -> CostUsageDailyReport.Entry { + CostUsageDailyReport.Entry( + date: dayKey, + inputTokens: day.sawInput ? day.input : nil, + outputTokens: day.sawOutput ? day.output : nil, + cacheReadTokens: day.sawCacheRead ? day.cacheRead : nil, + cacheCreationTokens: day.sawCacheCreation ? day.cacheCreation : nil, + reasoningTokens: day.sawReasoning ? day.reasoning : nil, + totalTokens: day.sawTokens ? day.tokens : nil, + requestCount: day.priced + day.unpriced + day.unmetered + day.estimated, + costUSD: day.sawCost ? day.cost : nil, + modelsUsed: day.models.keys.sorted(), + modelBreakdowns: self.modelBreakdowns(day.models), + unpricedRequestCount: day.unpriced, + unmeteredRequestCount: day.unmetered, + estimatedRequestCount: day.estimated) + } + + private static func modelBreakdowns(_ models: [String: ModelAccumulator]) -> [CostUsageDailyReport.ModelBreakdown] { + models.keys.sorted().map { name in + let model = models[name] ?? ModelAccumulator() + return CostUsageDailyReport.ModelBreakdown( + modelName: name, + costUSD: model.sawCost ? model.cost : nil, + totalTokens: model.sawTokens ? model.tokens : nil, + inputTokens: model.input, + outputTokens: model.output, + cacheReadTokens: model.cacheRead, + cacheCreationTokens: model.cacheCreation, + reasoningTokens: model.reasoning) + } + } + + private static func listPriceUSD( + entry: OpenCodexUsageEntry, + customPricing: CostUsageCustomPricing) -> Double? + { + guard entry.usageStatus == .reported || entry.usageStatus == .estimated else { return nil } + let usage = entry.usage + let hasTokenData = entry.resolvedTotalTokens != nil + || usage?.inputTokens != nil + || usage?.outputTokens != nil + || usage?.cacheReadTokens != nil + || usage?.cacheCreationInputTokens != nil + guard hasTokenData else { return nil } + let input = usage?.inputTokens ?? 0 + let output = usage?.outputTokens ?? 0 + let cacheRead = usage?.cacheReadTokens ?? 0 + let cacheWrite = usage?.cacheCreationInputTokens ?? 0 + if let overlay = customPricing.costUSD( + providerID: entry.provider, + model: entry.model, + inputTokens: input, + outputTokens: output, + cacheReadTokens: cacheRead, + cacheWriteTokens: cacheWrite) + { + return overlay + } + return CostUsagePricing.codexCostUSD( + model: entry.model, + inputTokens: input, + cachedInputTokens: cacheRead, + outputTokens: output, + cacheWriteInputTokens: cacheWrite) + } + + private static func add(_ lhs: Int?, _ rhs: Int?) -> Int? { + switch (lhs, rhs) { + case let (left?, right?): left + right + case let (left?, nil): left + case let (nil, right?): right + case (nil, nil): nil + } + } + + private static func add(_ lhs: Double?, _ rhs: Double?) -> Double? { + switch (lhs, rhs) { + case let (left?, right?): left + right + case let (left?, nil): left + case let (nil, right?): right + case (nil, nil): nil + } + } +} diff --git a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageModels.swift b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageModels.swift new file mode 100644 index 0000000000..4bfcab7bd8 --- /dev/null +++ b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageModels.swift @@ -0,0 +1,172 @@ +import Foundation + +public enum OpenCodexUsageStatus: String, Sendable, Equatable, Codable { + case reported + case estimated + case unreported + case unsupported +} + +public struct OpenCodexTokenUsage: Sendable, Equatable { + public var inputTokens: Int? + public var outputTokens: Int? + public var cachedInputTokens: Int? + public var cacheReadInputTokens: Int? + public var cacheCreationInputTokens: Int? + public var reasoningOutputTokens: Int? + public var totalTokens: Int? + + public init( + inputTokens: Int? = nil, + outputTokens: Int? = nil, + cachedInputTokens: Int? = nil, + cacheReadInputTokens: Int? = nil, + cacheCreationInputTokens: Int? = nil, + reasoningOutputTokens: Int? = nil, + totalTokens: Int? = nil) + { + self.inputTokens = Self.nonnegative(inputTokens) + self.outputTokens = Self.nonnegative(outputTokens) + self.cachedInputTokens = Self.nonnegative(cachedInputTokens) + self.cacheReadInputTokens = Self.nonnegative(cacheReadInputTokens) + self.cacheCreationInputTokens = Self.nonnegative(cacheCreationInputTokens) + self.reasoningOutputTokens = Self.nonnegative(reasoningOutputTokens) + self.totalTokens = Self.nonnegative(totalTokens) + } + + public var cacheReadTokens: Int? { + self.cacheReadInputTokens ?? self.cachedInputTokens + } + + public var resolvedTotalTokens: Int? { + if let totalTokens { + return totalTokens + } + let parts = [ + self.inputTokens, + self.outputTokens, + self.cacheReadTokens, + self.cacheCreationInputTokens, + ].compactMap(\.self) + guard !parts.isEmpty else { return nil } + return parts.reduce(0, +) + } + + private static func nonnegative(_ value: Int?) -> Int? { + guard let value, value >= 0 else { return nil } + return value + } +} + +public struct OpenCodexUsageEntry: Sendable, Equatable { + public let requestID: String + public let timestamp: Date + public let provider: String + public let model: String + public let usageStatus: OpenCodexUsageStatus + public let accountLogLabel: String? + public let surface: String? + public let conversationID: String? + public let usage: OpenCodexTokenUsage? + public let totalTokens: Int? + + public init( + requestID: String, + timestamp: Date, + provider: String, + model: String, + usageStatus: OpenCodexUsageStatus, + accountLogLabel: String? = nil, + surface: String? = nil, + conversationID: String? = nil, + usage: OpenCodexTokenUsage? = nil, + totalTokens: Int? = nil) + { + self.requestID = requestID + self.timestamp = timestamp + self.provider = provider + self.model = model + self.usageStatus = usageStatus + self.accountLogLabel = Self.normalizedAccountLogLabel(accountLogLabel) + self.surface = surface + self.conversationID = conversationID + self.usage = usage + self.totalTokens = totalTokens + } + + public var resolvedTotalTokens: Int? { + self.totalTokens ?? self.usage?.resolvedTotalTokens + } + + public var displayAccountLabel: String { + self.accountLogLabel ?? "main" + } + + static func normalizedAccountLogLabel(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed == "main" { return "main" } + guard trimmed.count >= 2, trimmed.first == "p" else { return nil } + let digits = trimmed.dropFirst() + guard !digits.isEmpty, digits.allSatisfy(\.isNumber) else { return nil } + return trimmed + } +} + +public enum OpenCodexUsageLog { + public static let sourceID = "opencodex" + public static let displayName = "OpenCodex" + + public static func usageLogURL( + environment: [String: String] = ProcessInfo.processInfo.environment, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) -> URL? + { + if let override = environment["OPENCODEX_HOME"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !override.isEmpty + { + return URL(fileURLWithPath: override, isDirectory: true) + .appendingPathComponent("usage.jsonl", isDirectory: false) + } + if Self.isRunningTests(environment) || Self.isRunningTests(ProcessInfo.processInfo.environment) { + return nil + } + return homeDirectory + .appendingPathComponent(".opencodex", isDirectory: true) + .appendingPathComponent("usage.jsonl", isDirectory: false) + } + + public static func cacheRoot( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default) -> URL + { + if let override = environment["OPENCODEX_HOME"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !override.isEmpty + { + return URL(fileURLWithPath: override, isDirectory: true) + .appendingPathComponent("codexbar-cache", isDirectory: true) + } + return AppGroupSupport.localFallbackDirectory(fileManager: fileManager) + .appendingPathComponent("OpenCodexUsage", isDirectory: true) + } + + private static func isRunningTests(_ environment: [String: String]) -> Bool { + let keys = [ + "XCTestConfigurationFilePath", + "XCTestBundlePath", + "XCTestSessionIdentifier", + "SWIFT_TESTING_ENABLED", + "TESTING_LIBRARY_VERSION", + "SWIFT_TESTING", + ] + if keys.contains(where: { environment[$0] != nil }) { + return true + } + if keys.contains(where: { ProcessInfo.processInfo.environment[$0] != nil }) { + return true + } + if NSClassFromString("XCTestCase") != nil { + return true + } + return Bundle.allBundles.contains { $0.bundlePath.hasSuffix(".xctest") } + } +} diff --git a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageParser.swift b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageParser.swift new file mode 100644 index 0000000000..cff9b00742 --- /dev/null +++ b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageParser.swift @@ -0,0 +1,128 @@ +import Foundation + +public enum OpenCodexUsageParser { + public static func parseLine(_ line: String) -> OpenCodexUsageEntry? { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, let data = trimmed.data(using: .utf8) else { return nil } + return self.parse(data) + } + + public static func parse(_ data: Data) -> OpenCodexUsageEntry? { + guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + return self.parse(object) + } + + public static func parseLines(_ text: String) -> [OpenCodexUsageEntry] { + text.split(whereSeparator: \.isNewline).compactMap { self.parseLine(String($0)) } + } + + public static func parse(fileURL: URL, fileManager: FileManager = .default) throws -> [OpenCodexUsageEntry] { + guard fileManager.fileExists(atPath: fileURL.path) else { return [] } + let data = try Data(contentsOf: fileURL) + guard let text = String(data: data, encoding: .utf8) else { return [] } + return self.parseLines(text) + } + + private static func parse(_ object: [String: Any]) -> OpenCodexUsageEntry? { + guard let requestID = self.nonEmptyString(object["requestId"]), + let timestamp = self.timestamp(object["timestamp"]), + let provider = self.nonEmptyString(object["provider"]), + let model = self.nonEmptyString(object["model"]) + else { return nil } + let status = self.usageStatus(object["usageStatus"]) + let usage = self.usage(object["usage"]) + return OpenCodexUsageEntry( + requestID: requestID, + timestamp: timestamp, + provider: provider, + model: model, + usageStatus: status, + accountLogLabel: self.nonEmptyString(object["accountLogLabel"]), + surface: self.nonEmptyString(object["surface"]), + conversationID: self.nonEmptyString(object["conversationId"]), + usage: usage, + totalTokens: self.nonnegativeInt(object["totalTokens"])) + } + + private static func usageStatus(_ value: Any?) -> OpenCodexUsageStatus { + guard let raw = self.nonEmptyString(value), + let status = OpenCodexUsageStatus(rawValue: raw) + else { return .unreported } + return status + } + + private static func usage(_ value: Any?) -> OpenCodexTokenUsage? { + guard let object = value as? [String: Any] else { return nil } + let parsed = OpenCodexTokenUsage( + inputTokens: self.nonnegativeInt(object["inputTokens"]), + outputTokens: self.nonnegativeInt(object["outputTokens"]), + cachedInputTokens: self.nonnegativeInt(object["cachedInputTokens"]), + cacheReadInputTokens: self.nonnegativeInt(object["cacheReadInputTokens"]), + cacheCreationInputTokens: self.nonnegativeInt(object["cacheCreationInputTokens"]), + reasoningOutputTokens: self.nonnegativeInt(object["reasoningOutputTokens"]), + totalTokens: self.nonnegativeInt(object["totalTokens"])) + if parsed.inputTokens == nil, + parsed.outputTokens == nil, + parsed.cachedInputTokens == nil, + parsed.cacheReadInputTokens == nil, + parsed.cacheCreationInputTokens == nil, + parsed.reasoningOutputTokens == nil, + parsed.totalTokens == nil + { + return nil + } + return parsed + } + + private static func timestamp(_ value: Any?) -> Date? { + if let number = value as? Double { + return self.date(fromEpoch: number) + } + if let number = value as? Int { + return self.date(fromEpoch: Double(number)) + } + if let number = value as? NSNumber { + return self.date(fromEpoch: number.doubleValue) + } + if let raw = value as? String { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if let number = Double(trimmed) { + return self.date(fromEpoch: number) + } + return CostUsageDateParser.parse(trimmed) + } + return nil + } + + private static func date(fromEpoch value: Double) -> Date? { + guard value.isFinite, value > 0 else { return nil } + let seconds = value >= 1_000_000_000_000 ? value / 1000 : value + return Date(timeIntervalSince1970: seconds) + } + + private static func nonEmptyString(_ value: Any?) -> String? { + guard let value else { return nil } + if let string = value as? String { + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + return nil + } + + private static func nonnegativeInt(_ value: Any?) -> Int? { + guard let value else { return nil } + if let number = value as? Int { + return number >= 0 ? number : nil + } + if let number = value as? Double, number.isFinite, number >= 0, number <= Double(Int.max) { + return Int(number) + } + if let number = value as? NSNumber { + let intValue = number.intValue + return intValue >= 0 ? intValue : nil + } + return nil + } +} diff --git a/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageStore.swift b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageStore.swift new file mode 100644 index 0000000000..fac7a895ac --- /dev/null +++ b/Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageStore.swift @@ -0,0 +1,263 @@ +#if canImport(SQLite3) +import SQLite3 +#elseif canImport(CSQLite3) +import CSQLite3 +#endif +import Foundation + +/// Independent OpenCodex usage cache. Never writes Codex `cost-usage.sqlite`. +public struct OpenCodexUsageStore: Sendable { + public static let databaseFilename = "opencodex-usage.sqlite" + private static let schemaVersion = 1 + + private let databaseURL: URL + + public init(cacheRoot: URL) { + self.databaseURL = cacheRoot.appendingPathComponent(Self.databaseFilename, isDirectory: false) + } + + public func loadSnapshot( + logURL: URL, + now: Date, + historyDays: Int, + calendar: Calendar, + customPricing: CostUsageCustomPricing = .empty, + fileManager: FileManager = .default) throws -> CostUsageTokenSnapshot + { + let entries = try self.loadEntries(logURL: logURL, fileManager: fileManager) + return OpenCodexUsageAggregator.snapshot( + entries: entries, + now: now, + historyDays: historyDays, + calendar: calendar, + customPricing: customPricing) + } + + func loadEntries(logURL: URL, fileManager: FileManager) throws -> [OpenCodexUsageEntry] { + guard fileManager.fileExists(atPath: logURL.path) else { return [] } + let attributes = try fileManager.attributesOfItem(atPath: logURL.path) + let size = (attributes[.size] as? NSNumber)?.int64Value ?? 0 + let mtime = (attributes[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0 + let identity = "\(logURL.path)|\(size)|\(mtime)" + + if let cached = self.readCachedEntries(identity: identity), !cached.isEmpty { + return cached + } + + let parsed = try OpenCodexUsageParser.parse(fileURL: logURL, fileManager: fileManager) + var unique: [String: OpenCodexUsageEntry] = [:] + for entry in parsed { + unique[entry.requestID] = entry + } + let deduped = unique.values.sorted { + if $0.timestamp != $1.timestamp { return $0.timestamp < $1.timestamp } + return $0.requestID < $1.requestID + } + self.replaceCachedEntries(deduped, identity: identity) + return deduped + } + + private func readCachedEntries(identity: String) -> [OpenCodexUsageEntry]? { + guard let db = self.open(readOnly: true) else { return nil } + defer { sqlite3_close(db) } + guard Self.userVersion(db) == Self.schemaVersion, + Self.meta(db, key: "identity") == identity + else { return nil } + var statement: OpaquePointer? + let sql = """ + SELECT request_id, timestamp, provider, model, usage_status, account_label, surface, conversation_id, payload + FROM entries + """ + guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { return nil } + defer { sqlite3_finalize(statement) } + var entries: [OpenCodexUsageEntry] = [] + while sqlite3_step(statement) == SQLITE_ROW { + guard let payload = Self.text(statement, 8), + let data = payload.data(using: .utf8), + let entry = OpenCodexUsageParser.parse(data) + else { continue } + entries.append(entry) + } + return entries + } + + private func replaceCachedEntries(_ entries: [OpenCodexUsageEntry], identity: String) { + guard let db = self.open(readOnly: false) else { return } + defer { sqlite3_close(db) } + _ = sqlite3_exec(db, "BEGIN IMMEDIATE", nil, nil, nil) + _ = sqlite3_exec(db, "DELETE FROM entries", nil, nil, nil) + Self.setMeta(db, key: "identity", value: identity) + var statement: OpaquePointer? + let sql = """ + INSERT OR REPLACE INTO entries( + request_id, timestamp, provider, model, usage_status, account_label, surface, conversation_id, payload + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """ + guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { + _ = sqlite3_exec(db, "ROLLBACK", nil, nil, nil) + return + } + defer { sqlite3_finalize(statement) } + for entry in entries { + sqlite3_reset(statement) + sqlite3_clear_bindings(statement) + Self.bind(statement, 1, entry.requestID) + sqlite3_bind_double(statement, 2, entry.timestamp.timeIntervalSince1970) + Self.bind(statement, 3, entry.provider) + Self.bind(statement, 4, entry.model) + Self.bind(statement, 5, entry.usageStatus.rawValue) + Self.bind(statement, 6, entry.accountLogLabel) + Self.bind(statement, 7, entry.surface) + Self.bind(statement, 8, entry.conversationID) + let payload = Self.payloadJSON(entry) + Self.bind(statement, 9, payload) + guard sqlite3_step(statement) == SQLITE_DONE else { + _ = sqlite3_exec(db, "ROLLBACK", nil, nil, nil) + return + } + } + _ = sqlite3_exec(db, "COMMIT", nil, nil, nil) + } + + private func open(readOnly: Bool) -> OpaquePointer? { + if !readOnly { + try? FileManager.default.createDirectory( + at: self.databaseURL.deletingLastPathComponent(), + withIntermediateDirectories: true) + } + var db: OpaquePointer? + let flags = readOnly + ? SQLITE_OPEN_READONLY + : SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE + guard sqlite3_open_v2(self.databaseURL.path, &db, flags, nil) == SQLITE_OK else { + sqlite3_close(db) + return nil + } + sqlite3_busy_timeout(db, 250) + if !readOnly { + _ = sqlite3_exec(db, "PRAGMA journal_mode = WAL", nil, nil, nil) + _ = sqlite3_exec(db, "PRAGMA synchronous = NORMAL", nil, nil, nil) + Self.ensureSchema(db) + } + return db + } + + private static func ensureSchema(_ db: OpaquePointer?) { + guard self.userVersion(db) == 0 else { return } + let sql = """ + CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS entries ( + request_id TEXT PRIMARY KEY, + timestamp REAL NOT NULL, + provider TEXT NOT NULL, + model TEXT NOT NULL, + usage_status TEXT NOT NULL, + account_label TEXT, + surface TEXT, + conversation_id TEXT, + payload TEXT NOT NULL + ); + """ + guard sqlite3_exec(db, sql, nil, nil, nil) == SQLITE_OK else { return } + Self.setUserVersion(db, Self.schemaVersion) + } + + private static func userVersion(_ db: OpaquePointer?) -> Int { + var statement: OpaquePointer? + guard sqlite3_prepare_v2(db, "PRAGMA user_version", -1, &statement, nil) == SQLITE_OK else { return 0 } + defer { sqlite3_finalize(statement) } + guard sqlite3_step(statement) == SQLITE_ROW else { return 0 } + return Int(sqlite3_column_int(statement, 0)) + } + + private static func setUserVersion(_ db: OpaquePointer?, _ version: Int) { + _ = sqlite3_exec(db, "PRAGMA user_version = \(version)", nil, nil, nil) + } + + private static func meta(_ db: OpaquePointer?, key: String) -> String? { + var statement: OpaquePointer? + guard sqlite3_prepare_v2(db, "SELECT value FROM meta WHERE key = ?", -1, &statement, nil) == SQLITE_OK else { + return nil + } + defer { sqlite3_finalize(statement) } + Self.bind(statement, 1, key) + guard sqlite3_step(statement) == SQLITE_ROW else { return nil } + return Self.text(statement, 0) + } + + private static func setMeta(_ db: OpaquePointer?, key: String, value: String) { + var statement: OpaquePointer? + guard sqlite3_prepare_v2( + db, + "INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", + -1, + &statement, + nil) == SQLITE_OK + else { return } + defer { sqlite3_finalize(statement) } + Self.bind(statement, 1, key) + Self.bind(statement, 2, value) + _ = sqlite3_step(statement) + } + + private static func bind(_ statement: OpaquePointer?, _ index: Int32, _ value: String?) { + guard let value else { + sqlite3_bind_null(statement, index) + return + } + sqlite3_bind_text(statement, index, value, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self)) + } + + private static func text(_ statement: OpaquePointer?, _ index: Int32) -> String? { + guard let pointer = sqlite3_column_text(statement, index) else { return nil } + return String(cString: pointer) + } + + private static func payloadJSON(_ entry: OpenCodexUsageEntry) -> String { + var object: [String: Any] = [ + "requestId": entry.requestID, + "timestamp": entry.timestamp.timeIntervalSince1970 * 1000, + "provider": entry.provider, + "model": entry.model, + "usageStatus": entry.usageStatus.rawValue, + ] + if let accountLogLabel = entry.accountLogLabel { + object["accountLogLabel"] = accountLogLabel + } + if let surface = entry.surface { + object["surface"] = surface + } + if let conversationID = entry.conversationID { + object["conversationId"] = conversationID + } + if let totalTokens = entry.totalTokens { + object["totalTokens"] = totalTokens + } + if let usage = entry.usage { + var usageObject: [String: Any] = [:] + if let inputTokens = usage.inputTokens { usageObject["inputTokens"] = inputTokens } + if let outputTokens = usage.outputTokens { usageObject["outputTokens"] = outputTokens } + if let cachedInputTokens = usage.cachedInputTokens { + usageObject["cachedInputTokens"] = cachedInputTokens + } + if let cacheReadInputTokens = usage.cacheReadInputTokens { + usageObject["cacheReadInputTokens"] = cacheReadInputTokens + } + if let cacheCreationInputTokens = usage.cacheCreationInputTokens { + usageObject["cacheCreationInputTokens"] = cacheCreationInputTokens + } + if let reasoningOutputTokens = usage.reasoningOutputTokens { + usageObject["reasoningOutputTokens"] = reasoningOutputTokens + } + if let totalTokens = usage.totalTokens { usageObject["totalTokens"] = totalTokens } + object["usage"] = usageObject + } + guard let data = try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]), + let text = String(data: data, encoding: .utf8) + else { return "{}" } + return text + } +} diff --git a/Tests/CodexBarTests/CLICostTests.swift b/Tests/CodexBarTests/CLICostTests.swift index 7e2c6df386..3614f4505c 100644 --- a/Tests/CodexBarTests/CLICostTests.swift +++ b/Tests/CodexBarTests/CLICostTests.swift @@ -749,6 +749,39 @@ struct CLICostTests { settings: nil, resolutionError: resolutionError) == nil) } + + @Test + func `openCodex JSON payload stays on a separate source and omits invented projects`() { + let now = Date(timeIntervalSince1970: 1_784_179_200) + let snapshot = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: 12, + last30DaysCostUSD: 1.5, + currencyCode: "USD", + historyDays: 7, + costProvenance: .listPriceEstimate, + daily: [ + CostUsageDailyReport.Entry( + date: "2026-07-16", + inputTokens: 10, + outputTokens: 2, + reasoningTokens: 3, + totalTokens: 12, + costUSD: 1.5, + modelsUsed: ["gpt-5.4"], + modelBreakdowns: nil, + estimatedRequestCount: 1), + ], + updatedAt: now) + let payload = CodexBarCLI.makeOpenCodexCostPayload(snapshot: snapshot) + #expect(payload.provider == "opencodex") + #expect(payload.source == "opencodex") + #expect(payload.projects.isEmpty) + #expect(payload.daily.first?.reasoningTokens == 3) + #expect(payload.provenance == CostProvenance.listPriceEstimate.rawValue) + #expect(payload.coverage?.estimated == 1) + } } private func sessionTimestamp(_ date: Date) -> String { diff --git a/Tests/CodexBarTests/CostProvenanceTests.swift b/Tests/CodexBarTests/CostProvenanceTests.swift new file mode 100644 index 0000000000..fed5ffc810 --- /dev/null +++ b/Tests/CodexBarTests/CostProvenanceTests.swift @@ -0,0 +1,180 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CostProvenanceTests { + @Test + func `cost figures are never billing receipts`() { + for provenance in [CostProvenance.listPriceEstimate, .vendorMetered, .mixed, .unknown] { + #expect(!provenance.isBillingReceipt) + } + } + + @Test + func `coverage ratio ignores missing categories instead of collapsing them`() { + let empty = CostUsageCoverageCounts() + #expect(empty.coverageRatio == nil) + + let mixed = CostUsageCoverageCounts(priced: 2, unpriced: 1, unmetered: 1, estimated: 2) + #expect(mixed.total == 6) + #expect(mixed.coverageRatio == 4.0 / 6.0) + } + + @Test + func `token mix keeps nil distinct from zero`() { + var mix = CostUsageTokenMix(inputTokens: 10, outputTokens: nil) + #expect(mix.inputTokens == 10) + #expect(mix.outputTokens == nil) + mix.merge(CostUsageTokenMix(outputTokens: 4, reasoningTokens: 0)) + #expect(mix.outputTokens == 4) + #expect(mix.reasoningTokens == 0) + mix.merge(CostUsageTokenMix(inputTokens: 5)) + #expect(mix.inputTokens == 15) + #expect(mix.cacheReadTokens == nil) + } + + @Test + func `day rows without request counts still expose priced or unpriced coverage`() { + let priced = CostUsageDailyReport.Entry( + date: "2026-07-16", + inputTokens: 10, + outputTokens: 2, + totalTokens: 12, + costUSD: 1.25, + modelsUsed: nil, + modelBreakdowns: nil) + #expect(priced.coverageCounts == CostUsageCoverageCounts(priced: 1)) + + let unpriced = CostUsageDailyReport.Entry( + date: "2026-07-16", + inputTokens: 10, + outputTokens: 2, + totalTokens: 12, + costUSD: nil, + modelsUsed: nil, + modelBreakdowns: nil) + #expect(unpriced.coverageCounts == CostUsageCoverageCounts(unpriced: 1)) + + let unmetered = CostUsageDailyReport.Entry( + date: "2026-07-16", + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + costUSD: nil, + modelsUsed: nil, + modelBreakdowns: nil, + unmeteredRequestCount: 2) + #expect(unmetered.coverageCounts == CostUsageCoverageCounts(unmetered: 2)) + } + + @Test + func `vendor reported snapshots stay vendor metered without meteredCostUSD`() throws { + let snapshot = CostUsageTokenSnapshot( + sessionTokens: 10, + sessionCostUSD: 1.25, + last30DaysTokens: 10, + last30DaysCostUSD: 1.25, + historyDays: 30, + costProvenance: .vendorMetered, + daily: [ + CostUsageDailyReport.Entry( + date: "2026-07-01", + inputTokens: 8, + outputTokens: 2, + totalTokens: 10, + costUSD: 1.25, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: Date(timeIntervalSince1970: 1_782_864_000)) + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let summary = snapshot.summary(forLastDays: 7, calendar: calendar) + #expect(summary.provenance == .vendorMetered) + #expect(summary.totalCostUSD == 1.25) + #expect(summary.meteredCostUSD == nil) + } + + @Test + func `shorter summaries omit snapshot-wide metered spend`() throws { + let snapshot = CostUsageTokenSnapshot( + sessionTokens: 10, + sessionCostUSD: 1, + last30DaysTokens: 100, + last30DaysCostUSD: 10, + historyDays: 30, + meteredCostUSD: 4.5, + costProvenance: .mixed, + daily: [ + CostUsageDailyReport.Entry( + date: "2026-07-01", + inputTokens: 8, + outputTokens: 2, + totalTokens: 10, + costUSD: 1, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: Date(timeIntervalSince1970: 1_782_864_000)) + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let week = snapshot.summary(forLastDays: 7, calendar: calendar) + let month = snapshot.summary(forLastDays: 30, calendar: calendar) + #expect(week.meteredCostUSD == nil) + #expect(week.provenance == .listPriceEstimate) + #expect(month.meteredCostUSD == 4.5) + #expect(month.provenance == .mixed) + } + + @Test + func `cached previous reports round-trip coverage counters`() throws { + let entry = CostUsageDailyReport.Entry( + date: "2026-07-16", + inputTokens: 10, + outputTokens: 2, + totalTokens: 12, + costUSD: nil, + modelsUsed: nil, + modelBreakdowns: nil, + unpricedRequestCount: 1, + unmeteredRequestCount: 2, + estimatedRequestCount: 3) + let cached = CostUsageCodexPreviousReport.Entry(entry) + let restored = cached.dailyReportValue + #expect(restored.unpricedRequestCount == 1) + #expect(restored.unmeteredRequestCount == 2) + #expect(restored.estimatedRequestCount == 3) + let data = try JSONEncoder().encode(cached) + let decoded = try JSONDecoder().decode(CostUsageCodexPreviousReport.Entry.self, from: data) + #expect(decoded.dailyReportValue.unmeteredRequestCount == 2) + #expect(decoded.dailyReportValue.estimatedRequestCount == 3) + } +} + +struct CostUsageBucketTimeZoneTests { + @Test + func `pins a valid IANA identifier and rejects junk`() { + #expect(CostUsageBucketTimeZone.isValidIdentifier("America/Los_Angeles")) + #expect(!CostUsageBucketTimeZone.isValidIdentifier("Not/AZone")) + let calendar = CostUsageBucketTimeZone.calendar(identifier: "America/Los_Angeles") + #expect(calendar.timeZone.identifier == "America/Los_Angeles") + #expect(calendar.identifier == .gregorian) + } + + @Test + func `a pinned zone keeps midnight-adjacent events on the same local day`() throws { + let timestamp = "2026-07-16T06:30:00Z" + var losAngeles = Calendar(identifier: .gregorian) + losAngeles.timeZone = try #require(TimeZone(identifier: "America/Los_Angeles")) + var shanghai = Calendar(identifier: .gregorian) + shanghai.timeZone = try #require(TimeZone(identifier: "Asia/Shanghai")) + let westKey = try #require(CostUsageScanner.dayKeyFromTimestamp(timestamp, calendar: losAngeles)) + let eastKey = try #require(CostUsageScanner.dayKeyFromTimestamp(timestamp, calendar: shanghai)) + #expect(westKey == "2026-07-15") + #expect(eastKey == "2026-07-16") + + let pinned = CostUsageBucketTimeZone.calendar(identifier: "America/Los_Angeles") + #expect(CostUsageScanner.dayKeyFromTimestamp(timestamp, calendar: pinned) == westKey) + #expect(CostUsageScanner.dayKeyFromTimestamp(timestamp, calendar: pinned) != eastKey) + } +} diff --git a/Tests/CodexBarTests/CostUsageCustomPricingTests.swift b/Tests/CodexBarTests/CostUsageCustomPricingTests.swift new file mode 100644 index 0000000000..b492ebcbfe --- /dev/null +++ b/Tests/CodexBarTests/CostUsageCustomPricingTests.swift @@ -0,0 +1,68 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CostUsageCustomPricingTests { + @Test + func `overlay exact match uses per-million rates and treats zero as free`() throws { + let pricing = CostUsageCustomPricing.parse(Data(""" + { + "openai/gpt-5.4": { "input": 2.5, "output": 15, "cacheRead": 0, "cacheWrite": 3.125 } + } + """.utf8)) + let cost = try #require(pricing.costUSD( + providerID: "openai", + model: "gpt-5.4", + inputTokens: 1_000_000, + outputTokens: 1_000_000, + cacheReadTokens: 1_000_000, + cacheWriteTokens: 1_000_000)) + #expect(abs(cost - (2.5 + 15 + 0 + 3.125)) < 0.000_001) + } + + @Test + func `missing overlay fields stay unknown instead of falling through`() { + let pricing = CostUsageCustomPricing.parse(Data(""" + { "gpt-5.4": { "input": 2.5 } } + """.utf8)) + #expect(pricing.costUSD(model: "gpt-5.4", inputTokens: 100, outputTokens: 10) == nil) + #expect(pricing.costUSD(model: "gpt-5.4", inputTokens: 100, outputTokens: 0) == 100 * 2.5 / 1_000_000) + #expect(pricing.rates(model: "other-model") == nil) + } + + @Test + func `codex cost prefers overlay over bundled list prices`() { + let overlay = CostUsageCustomPricing.parse(Data(""" + { "gpt-5.4": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 } } + """.utf8)) + let cost = CostUsagePricing.codexCostUSD( + model: "gpt-5.4", + inputTokens: 1000, + cachedInputTokens: 0, + outputTokens: 100, + customPricing: overlay) + #expect(cost == 0) + } + + @Test + func `aggregate fallback consults the overlay before bundled rates`() { + let overlay = CostUsageCustomPricing.parse(Data(""" + { "gpt-5.4": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 } } + """.utf8)) + let cost = CostUsagePricing.codexAggregateCostUSD( + model: "gpt-5.4", + inputTokens: 1000, + cachedInputTokens: 0, + outputTokens: 100, + customPricing: overlay) + #expect(cost == 0) + let bundled = CostUsagePricing.codexAggregateCostUSD( + model: "gpt-5.4", + inputTokens: 1000, + cachedInputTokens: 0, + outputTokens: 100, + customPricing: .empty) + #expect(bundled != 0) + #expect(bundled != nil) + } +} diff --git a/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift b/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift index 68487759b4..e12cf0d19e 100644 --- a/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift @@ -653,6 +653,55 @@ struct CostUsageFetcherCacheSnapshotTests { #expect(cached?.last30DaysTokens == 165) } + @Test + func `cached snapshot reads keep the pinned timezone instead of the current zone`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + var losAngeles = Calendar(identifier: .gregorian) + losAngeles.timeZone = try #require(TimeZone(identifier: "America/Los_Angeles")) + var shanghai = Calendar(identifier: .gregorian) + shanghai.timeZone = try #require(TimeZone(identifier: "Asia/Shanghai")) + let day = try #require(losAngeles.date(from: DateComponents( + timeZone: losAngeles.timeZone, + year: 2026, + month: 4, + day: 8, + hour: 12))) + try Self.writeCodexSessionFile( + homeRoot: env.codexHomeRoot, + env: env, + day: day, + filename: "cached.jsonl", + tokens: 42) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + options.calendar = losAngeles + options.refreshMinIntervalSeconds = 0 + _ = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + historyDays: 1, + refreshPricingInBackground: false, + scannerOptions: options) + + let fetcher = CostUsageFetcher(scannerOptions: options) + let pinned = await fetcher.loadCachedCodexTokenSnapshotResult( + now: day, + historyDays: 1, + calendar: losAngeles) + let travelled = await fetcher.loadCachedCodexTokenSnapshotResult( + now: day, + historyDays: 1, + calendar: shanghai) + + #expect(pinned?.snapshot.sessionTokens == 42) + #expect(pinned?.snapshot.costProvenance == .listPriceEstimate) + #expect(travelled == nil) + } + private static func writeCodexSessionFile( homeRoot: URL, env: CostUsageTestEnvironment, diff --git a/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift b/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift index 53b0395557..7fba070778 100644 --- a/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift @@ -204,7 +204,10 @@ struct CostUsageScannerBreakdownTests { CostUsageDailyReport.ModelBreakdown( modelName: "gpt-5.2-codex", costUSD: first.data[0].costUSD, - totalTokens: 110), + totalTokens: 110, + inputTokens: 100, + outputTokens: 10, + cacheReadTokens: 20), ]) #expect(first.data[0].totalTokens == 110) #expect((first.data[0].costUSD ?? 0) > 0) diff --git a/Tests/CodexBarTests/Issue2037ScannerIntegrationTests.swift b/Tests/CodexBarTests/Issue2037ScannerIntegrationTests.swift index 55cf0870be..8f56001cc0 100644 --- a/Tests/CodexBarTests/Issue2037ScannerIntegrationTests.swift +++ b/Tests/CodexBarTests/Issue2037ScannerIntegrationTests.swift @@ -178,5 +178,48 @@ struct Issue2037ScannerIntegrationTests { // sufficient cross-file identity, so both children stay uncounted until the parent // snapshot is available from its file or the persistent token index. #expect(scannedUnits == 0) + let unmetered = report.data.reduce(0) { $0 + ($1.unmeteredRequestCount ?? 0) } + #expect(unmetered == 2) + #expect(report.data.allSatisfy { $0.costUSD == nil }) + } + + @Test + func `missing parent forks stay in the scan window without billed days`() { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .gmt + let noon = Date(timeIntervalSince1970: 1_893_456_000) // 2030-01-01 12:00 UTC + let range = CostUsageScanner.CostUsageDayRange(since: noon, until: noon, calendar: calendar) + let unixMs = Int64(noon.timeIntervalSince1970 * 1000) + var siblingA = CostUsageFileUsage( + mtimeUnixMs: unixMs, + size: 1, + days: [:]) + siblingA.forkedFromId = "missing-parent" + siblingA.forkBaselineDependencyKey = "missing|missing-parent|discovery|1" + siblingA.codexSession = CostUsageCodexSessionMetadata( + sessionId: "sibling-a", + forkedFromId: "missing-parent", + cwd: nil, + title: nil, + startedAtUnixMs: unixMs, + latestActivityUnixMs: unixMs) + + #expect(CostUsageScanner.isUnresolvedMissingParentFork(siblingA)) + #expect(!CostUsageScanner.codexFileHasBilledTokens(siblingA)) + #expect(siblingA.touchesCodexScanWindow( + sinceKey: range.scanSinceKey, + untilKey: range.scanUntilKey, + calendar: calendar)) + + var siblingB = siblingA + siblingB.codexSession?.sessionId = "sibling-b" + var cache = CostUsageCache() + cache.files["sibling-a.jsonl"] = siblingA + cache.files["sibling-b.jsonl"] = siblingB + let counts = CostUsageScanner.unresolvedForkUnmeteredCounts(cache: cache, range: range) + #expect(counts.values.reduce(0, +) == 2) + let report = CostUsageScanner.buildCodexReportFromCache(cache: cache, range: range) + #expect(report.data.reduce(0) { $0 + ($1.unmeteredRequestCount ?? 0) } == 2) + #expect(report.data.allSatisfy { ($0.inputTokens ?? 0) == 0 }) } } diff --git a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift index 01cde4fed1..b6074ff224 100644 --- a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift +++ b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift @@ -586,6 +586,7 @@ struct LocalizationLanguageCatalogTests { "section_privacy", "session_quota_estimate_value_format", "tab_menu", + "OpenCodex", ] let unchanged = Set(english.keys.filter { italian[$0] == english[$0] }) #expect(unchanged == intentionallyUnchanged) diff --git a/Tests/CodexBarTests/OpenCodexUsageParserTests.swift b/Tests/CodexBarTests/OpenCodexUsageParserTests.swift new file mode 100644 index 0000000000..f95f6782c0 --- /dev/null +++ b/Tests/CodexBarTests/OpenCodexUsageParserTests.swift @@ -0,0 +1,188 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct OpenCodexUsageParserTests { + @Test + func `parses persisted usage rows without reading the developer home`() throws { + let line = """ + {"requestId":"req-1","timestamp":1784179200000,"provider":"openai","model":"gpt-5.4",\ + "usageStatus":"reported","accountLogLabel":"p2","surface":"claude",\ + "usage":{"inputTokens":100,"outputTokens":20,"cacheReadInputTokens":10,\ + "reasoningOutputTokens":5,"totalTokens":135},"totalTokens":135} + """ + let entry = try #require(OpenCodexUsageParser.parseLine(line)) + #expect(entry.requestID == "req-1") + #expect(entry.provider == "openai") + #expect(entry.model == "gpt-5.4") + #expect(entry.usageStatus == .reported) + #expect(entry.accountLogLabel == "p2") + #expect(entry.surface == "claude") + #expect(entry.usage?.inputTokens == 100) + #expect(entry.usage?.reasoningOutputTokens == 5) + #expect(entry.resolvedTotalTokens == 135) + #expect(entry.timestamp == Date(timeIntervalSince1970: 1_784_179_200)) + } + + @Test + func `skips malformed lines and keeps nil usage classes unset`() { + let text = """ + not-json + {"requestId":"req-2","timestamp":1784179200,"provider":"anthropic",\ + "model":"claude-sonnet-4","usageStatus":"unreported"} + """ + let entries = OpenCodexUsageParser.parseLines(text) + #expect(entries.count == 1) + #expect(entries[0].usage == nil) + #expect(entries[0].usageStatus == .unreported) + #expect(entries[0].accountLogLabel == nil) + } + + @Test + func `does not resolve a default home while tests are running`() { + #expect(OpenCodexUsageLog.usageLogURL(environment: ["TESTING_LIBRARY_VERSION": "1"]) == nil) + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("OpenCodexUsageParserTests-\(UUID().uuidString)", isDirectory: true) + let url = OpenCodexUsageLog.usageLogURL(environment: ["OPENCODEX_HOME": home.path]) + #expect(url == home.appendingPathComponent("usage.jsonl")) + } + + @Test + func `aggregates a fixture log into an independent snapshot`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("OpenCodexUsageAggregatorTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let log = root.appendingPathComponent("usage.jsonl") + let now = Date(timeIntervalSince1970: 1_784_179_200) + let millis = Int(now.timeIntervalSince1970 * 1000) + try """ + {"requestId":"a","timestamp":\(millis),"provider":"openai","model":"gpt-5.4","usageStatus":"reported",\ + "accountLogLabel":"main","conversationId":"chat-1",\ + "usage":{"inputTokens":10,"outputTokens":2,"totalTokens":12},"totalTokens":12} + {"requestId":"b","timestamp":\(millis),"provider":"openai","model":"gpt-5.4","usageStatus":"estimated",\ + "accountLogLabel":"p1","conversationId":"chat-1",\ + "usage":{"inputTokens":5,"outputTokens":1,"reasoningOutputTokens":3,"totalTokens":9},"totalTokens":9} + """.write(to: log, atomically: true, encoding: .utf8) + + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let snapshot = try OpenCodexUsageStore(cacheRoot: root).loadSnapshot( + logURL: log, + now: now, + historyDays: 7, + calendar: calendar) + #expect(snapshot.historyLabel == "OpenCodex usage.jsonl") + #expect(snapshot.daily.count == 1) + #expect(snapshot.daily[0].inputTokens == 15) + #expect(snapshot.daily[0].reasoningTokens == 3) + #expect(snapshot.daily[0].estimatedRequestCount == 1) + #expect(snapshot.sessions.count == 1) + #expect(snapshot.sessions[0].sessionID == "chat-1") + #expect(snapshot.sessions[0].reasoningTokens == 3) + #expect(snapshot.costProvenance == .listPriceEstimate) + #expect(OpenCodexUsageStore.databaseFilename == "opencodex-usage.sqlite") + #expect(FileManager.default.fileExists(atPath: root.appendingPathComponent("opencodex-usage.sqlite").path)) + } + + @Test + func `unreported rows without usage stay unpriced instead of zero spend`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let now = Date(timeIntervalSince1970: 1_784_179_200) + let snapshot = OpenCodexUsageAggregator.snapshot( + entries: [ + OpenCodexUsageEntry( + requestID: "empty", + timestamp: now, + provider: "openai", + model: "gpt-5.4", + usageStatus: .unreported), + ], + now: now, + historyDays: 7, + calendar: calendar) + #expect(snapshot.daily.count == 1) + #expect(snapshot.daily[0].costUSD == nil) + #expect(snapshot.daily[0].unpricedRequestCount == 1) + #expect(snapshot.daily[0].requestCount == 1) + #expect(snapshot.costProvenance == .listPriceEstimate) + } + + @Test + func `session totals use the current day instead of the latest historical day`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let now = Date(timeIntervalSince1970: 1_784_179_200) + let yesterday = now.addingTimeInterval(-86400) + let snapshot = OpenCodexUsageAggregator.snapshot( + entries: [ + OpenCodexUsageEntry( + requestID: "old", + timestamp: yesterday, + provider: "openai", + model: "gpt-5.4", + usageStatus: .reported, + usage: OpenCodexTokenUsage(inputTokens: 10, outputTokens: 2, totalTokens: 12), + totalTokens: 12), + ], + now: now, + historyDays: 7, + calendar: calendar) + #expect(snapshot.daily.count == 1) + #expect(snapshot.sessionTokens == 0) + #expect(snapshot.sessionCostUSD == 0) + #expect(snapshot.last30DaysTokens == 12) + } + + @Test + func `unpriced estimated requests count once`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let now = Date(timeIntervalSince1970: 1_784_179_200) + let snapshot = OpenCodexUsageAggregator.snapshot( + entries: [ + OpenCodexUsageEntry( + requestID: "est", + timestamp: now, + provider: "openai", + model: "not-a-priced-model-xyz", + usageStatus: .estimated, + usage: OpenCodexTokenUsage(inputTokens: 10, outputTokens: 2, totalTokens: 12), + totalTokens: 12), + ], + now: now, + historyDays: 7, + calendar: calendar) + #expect(snapshot.daily[0].requestCount == 1) + #expect(snapshot.daily[0].estimatedRequestCount == 0) + #expect(snapshot.daily[0].unpricedRequestCount == 1) + #expect(snapshot.daily[0].costUSD == nil) + } + + @Test + func `duplicate request ids replace instead of aborting the cache write`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("OpenCodexUsageStoreDedupe-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let log = root.appendingPathComponent("usage.jsonl") + let now = Date(timeIntervalSince1970: 1_784_179_200) + let millis = Int(now.timeIntervalSince1970 * 1000) + try """ + {"requestId":"dup","timestamp":\(millis),"provider":"openai","model":"gpt-5.4","usageStatus":"reported",\ + "usage":{"inputTokens":1,"outputTokens":1,"totalTokens":2},"totalTokens":2} + {"requestId":"dup","timestamp":\(millis),"provider":"openai","model":"gpt-5.4","usageStatus":"reported",\ + "usage":{"inputTokens":9,"outputTokens":1,"totalTokens":10},"totalTokens":10} + """.write(to: log, atomically: true, encoding: .utf8) + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let snapshot = try OpenCodexUsageStore(cacheRoot: root).loadSnapshot( + logURL: log, + now: now, + historyDays: 7, + calendar: calendar) + #expect(snapshot.daily[0].inputTokens == 9) + #expect(snapshot.daily[0].requestCount == 1) + } +} diff --git a/Tests/CodexBarTests/SpendActivityHeatmapTests.swift b/Tests/CodexBarTests/SpendActivityHeatmapTests.swift index 56d72ee745..51a4e106be 100644 --- a/Tests/CodexBarTests/SpendActivityHeatmapTests.swift +++ b/Tests/CodexBarTests/SpendActivityHeatmapTests.swift @@ -475,6 +475,22 @@ struct SpendActivityHeatmapTests { locale: locale) == "Aug 1, 2026: Unavailable") } + @Test + func `uncovered heatmap days do not produce a drill-down selection`() throws { + let calendar = Self.calendar + let start = try #require(calendar.date(from: DateComponents(year: 2026, month: 8, day: 2))) + let series = SpendActivitySeries( + daily: [10, 0], + isCovered: [true, false], + start: start, + rangeStart: start, + today: start, + calendar: calendar) + #expect(SpendActivityDaySelection.day(from: series, at: 0, selectedDay: nil) == start) + #expect(SpendActivityDaySelection.day(from: series, at: 1, selectedDay: nil) == nil) + #expect(SpendActivityDaySelection.day(from: series, at: 0, selectedDay: start) == nil) + } + private static var calendar: Calendar { var calendar = Calendar(identifier: .gregorian) calendar.timeZone = TimeZone(secondsFromGMT: 0)! diff --git a/Tests/CodexBarTests/SpendDashboardControllerTests.swift b/Tests/CodexBarTests/SpendDashboardControllerTests.swift index 5b36480fd2..1ed3c78a67 100644 --- a/Tests/CodexBarTests/SpendDashboardControllerTests.swift +++ b/Tests/CodexBarTests/SpendDashboardControllerTests.swift @@ -754,6 +754,9 @@ struct SpendDashboardControllerTests { #expect(defaults.integer(forKey: "settingsSpendDashboardDays") == SpendDashboardSource.scanDays) controller.selectDays(9) #expect(controller.selectedDays == 30) + controller.selectDays(90) + #expect(controller.selectedDays == 90) + #expect(defaults.integer(forKey: "settingsSpendDashboardDays") == 90) } private nonisolated static let fixtureNow = Date(timeIntervalSince1970: 1_784_179_200) @@ -774,7 +777,7 @@ struct SpendDashboardControllerTests { nowProvider: { Self.fixtureNow }) } - private static func controller(gate: SpendDashboardLoaderGate) -> SpendDashboardController { + static func controller(gate: SpendDashboardLoaderGate) -> SpendDashboardController { let controllerBox = SpendDashboardControllerBox() let captureStore = SpendDashboardCapturedInputStore() let controller = SpendDashboardController( @@ -852,7 +855,7 @@ struct SpendDashboardControllerTests { snapshot: snapshot) } - private static func waitForPendingCount(_ count: Int, gate: SpendDashboardLoaderGate) async { + static func waitForPendingCount(_ count: Int, gate: SpendDashboardLoaderGate) async { for _ in 0..<1000 { if await gate.pendingCount == count { return @@ -872,7 +875,7 @@ struct SpendDashboardControllerTests { Issue.record("Timed out waiting for \(count) pending Codex loads") } - private static func waitUntil(_ condition: @MainActor () -> Bool) async { + static func waitUntil(_ condition: @MainActor () -> Bool) async { for _ in 0..<1000 { if condition() { return @@ -1248,7 +1251,7 @@ private actor SpendDashboardCodexSnapshotGate { } } -private actor SpendDashboardLoaderGate { +actor SpendDashboardLoaderGate { private var continuations: [CheckedContinuation] = [] var pendingCount: Int { diff --git a/Tests/CodexBarTests/SpendDashboardModelTests.swift b/Tests/CodexBarTests/SpendDashboardModelTests.swift index 620c1902ac..81e0702dee 100644 --- a/Tests/CodexBarTests/SpendDashboardModelTests.swift +++ b/Tests/CodexBarTests/SpendDashboardModelTests.swift @@ -1,7 +1,7 @@ -import CodexBarCore import Foundation import Testing @testable import CodexBar +@testable import CodexBarCore struct SpendDashboardModelTests { @Test @@ -1032,6 +1032,164 @@ extension SpendDashboardModelTests { #expect(knownZeroGroup.models.isEmpty) #expect(spendDashboardModelHistoryPresentation(knownZeroGroup) == .empty) } + + @Test + func `token mix keeps missing classes unset and supports a 90 day window`() throws { + let now = Date(timeIntervalSince1970: 1_784_179_200) + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let input = SpendDashboardModel.ProviderInput( + id: "codex", + provider: .codex, + displayName: "Codex", + snapshot: CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: 12, + last30DaysCostUSD: 1, + currencyCode: "USD", + historyDays: 90, + daily: [ + CostUsageDailyReport.Entry( + date: "2026-07-16", + inputTokens: 10, + outputTokens: 2, + cacheReadTokens: nil, + reasoningTokens: 3, + totalTokens: 12, + costUSD: 1, + modelsUsed: ["gpt-5.4"], + modelBreakdowns: [ + .init(modelName: "gpt-5.4", costUSD: 1, totalTokens: 12, reasoningTokens: 3), + ]), + ], + sessions: [ + CostUsageSessionBreakdown( + sessionID: "s1", + lastActivity: now, + inputTokens: 10, + cachedInputTokens: nil, + outputTokens: 2, + reasoningTokens: 3, + totalTokens: 12, + requestCount: 1, + costUSD: 1, + modelBreakdowns: []), + ], + updatedAt: now)) + let model = SpendDashboardModel.build( + inputs: [input], + requestedDays: 90, + now: now, + calendar: calendar) + #expect(model.requestedDays == 90) + let group = model.groups[0] + #expect(group.tokenMix.inputTokens == 10) + #expect(group.tokenMix.outputTokens == 2) + #expect(group.tokenMix.cacheReadTokens == nil) + #expect(group.tokenMix.reasoningTokens == 3) + #expect(group.displayedModels.count == 1) + #expect(group.sessions.count == 1) + #expect(group.provenance == .listPriceEstimate) + } + + @Test + func `stored day keys stay put when the display timezone changes`() throws { + let now = Date(timeIntervalSince1970: 1_784_222_400) // 2026-07-16 12:00:00 UTC + let input = SpendDashboardModel.ProviderInput( + id: "codex", + provider: .codex, + displayName: "Codex", + snapshot: Self.snapshot( + currency: "USD", + entries: [Self.entry(day: "2026-07-16", cost: 4, tokens: 12)], + updatedAt: now)) + var losAngeles = Calendar(identifier: .gregorian) + losAngeles.timeZone = try #require(TimeZone(identifier: "America/Los_Angeles")) + var shanghai = Calendar(identifier: .gregorian) + shanghai.timeZone = try #require(TimeZone(identifier: "Asia/Shanghai")) + let west = SpendDashboardModel.build( + inputs: [input], + requestedDays: 7, + now: now, + calendar: losAngeles) + let east = SpendDashboardModel.build( + inputs: [input], + requestedDays: 7, + now: now, + calendar: shanghai) + let westDays = west.groups[0].dailyPoints.map { + CostUsageLocalDay.key(from: $0.day, calendar: losAngeles) + } + let eastDays = east.groups[0].dailyPoints.map { + CostUsageLocalDay.key(from: $0.day, calendar: shanghai) + } + #expect(westDays == ["2026-07-16"]) + #expect(eastDays == ["2026-07-16"]) + #expect(west.groups[0].totalCost == east.groups[0].totalCost) + } + + @Test + func `openCodex stays on a separate ledger from native Codex`() { + let now = Date(timeIntervalSince1970: 1_784_179_200) + let native = SpendDashboardModel.ProviderInput( + id: "codex:main", + provider: .codex, + displayName: "Codex", + snapshot: Self.snapshot(currency: "USD", entries: [Self.entry(day: "2026-07-16", cost: 4)]), + sourceKind: .native) + let openCodex = SpendDashboardModel.ProviderInput( + id: SpendDashboardModel.openCodexSourceID, + provider: .codex, + displayName: "OpenCodex", + snapshot: Self.snapshot(currency: "USD", entries: [Self.entry(day: "2026-07-16", cost: 9)]), + sourceKind: .openCodex) + let sideBySide = SpendDashboardModel.build( + inputs: [native, openCodex], + requestedDays: 7, + now: now) + #expect(Set(sideBySide.groups[0].providers.map(\.id)) == ["opencodex", "codex:main"]) + #expect(sideBySide.groups[0].providers.count == 2) + + let hiddenNative = SpendDashboardModel.build( + inputs: [native, openCodex], + requestedDays: 7, + now: now, + hideNativeCodexWhenOpenCodexPresent: true) + #expect(hiddenNative.groups[0].providers.map(\.id) == [SpendDashboardModel.openCodexSourceID]) + + let filtered = SpendDashboardModel.build( + inputs: [native, openCodex], + requestedDays: 7, + now: now, + hiddenSourceIDs: [SpendDashboardModel.openCodexSourceID]) + #expect(filtered.groups[0].providers.map(\.id) == ["codex:main"]) + #expect(Set(filtered.availableSources.map(\.id)) == ["codex:main", SpendDashboardModel.openCodexSourceID]) + } + + @Test + func `metered spend stays on the snapshot window instead of a shorter range`() { + let now = Date(timeIntervalSince1970: 1_784_179_200) + let snapshot = CostUsageTokenSnapshot( + sessionTokens: 10, + sessionCostUSD: 1, + last30DaysTokens: 100, + last30DaysCostUSD: 10, + historyDays: 30, + meteredCostUSD: 4.5, + costProvenance: .mixed, + daily: [Self.entry(day: "2026-07-16", cost: 1)], + updatedAt: now) + let input = SpendDashboardModel.ProviderInput( + id: "cursor", + provider: .cursor, + displayName: "Cursor", + snapshot: snapshot) + let week = SpendDashboardModel.build(inputs: [input], requestedDays: 7, now: now) + let month = SpendDashboardModel.build(inputs: [input], requestedDays: 30, now: now) + #expect(week.groups[0].meteredCost == nil) + #expect(month.groups[0].meteredCost == 4.5) + } } extension SpendDashboardModelTests { diff --git a/Tests/CodexBarTests/SpendDashboardOpenCodexSourceTests.swift b/Tests/CodexBarTests/SpendDashboardOpenCodexSourceTests.swift new file mode 100644 index 0000000000..3114879380 --- /dev/null +++ b/Tests/CodexBarTests/SpendDashboardOpenCodexSourceTests.swift @@ -0,0 +1,75 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct SpendDashboardOpenCodexSourceTests { + @Test + func `OpenCodex-only configuration still starts a dashboard load`() async { + let gate = SpendDashboardLoaderGate() + let controller = SpendDashboardControllerTests.controller(gate: gate) + controller.update(configuration: SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [], + codexAccountIdentities: [], + openCodexUsageLogsEnabled: true)) + await SpendDashboardControllerTests.waitForPendingCount(1, gate: gate) + #expect(controller.isRefreshing) + await gate.resume(at: 0, result: .init(inputs: [ + SpendDashboardModel.ProviderInput( + id: SpendDashboardModel.openCodexSourceID, + provider: .codex, + displayName: "OpenCodex", + snapshot: CostUsageTokenSnapshot( + sessionTokens: 0, + sessionCostUSD: 0, + last30DaysTokens: 12, + last30DaysCostUSD: 1, + daily: [ + CostUsageDailyReport.Entry( + date: "2026-07-16", + inputTokens: 10, + outputTokens: 2, + totalTokens: 12, + costUSD: 1, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: Date(timeIntervalSince1970: 1_784_179_200)), + sourceKind: .openCodex), + ], failedSourceIDs: [])) + await SpendDashboardControllerTests.waitUntil { !controller.isRefreshing } + #expect(controller.model.groups.first?.providers.first?.id == SpendDashboardModel.openCodexSourceID) + } + + @Test + func `empty OpenCodex snapshots are not treated as a present source`() { + let empty = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: nil, + last30DaysCostUSD: nil, + daily: [], + updatedAt: Date(timeIntervalSince1970: 1_784_179_200)) + #expect(!SpendDashboardSource.shouldPublishOpenCodexSnapshot(empty)) + let populated = CostUsageTokenSnapshot( + sessionTokens: 12, + sessionCostUSD: 1, + last30DaysTokens: 12, + last30DaysCostUSD: 1, + daily: [ + CostUsageDailyReport.Entry( + date: "2026-07-16", + inputTokens: 10, + outputTokens: 2, + totalTokens: 12, + costUSD: 1, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: Date(timeIntervalSince1970: 1_784_179_200)) + #expect(SpendDashboardSource.shouldPublishOpenCodexSnapshot(populated)) + } +}