From 73219498dc79a451f5ca2b68ab850cb1dccdb9af Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sun, 19 Jul 2026 17:57:29 +0800 Subject: [PATCH 01/14] feat: add model-centric usage & spend view across providers Implements Scope B from steipete/CodexBar#2257. - Replace per-currency "model breakdown unavailable" panel with an embedded Models card showing daily stacked usage bars and a full model ranking. - Merge models across providers by trimmed, case-insensitive exact name matching; keep token aggregation separate from cost to avoid cross-currency sums. - Add independent 7d / 30d / All range control for the model card without affecting the existing Overview 30d spend semantics. - Add Kimi Code session scanner so Kimi token-only history can contribute to the cross-provider model analysis. - Localize new UI strings across all supported languages. - Adopt upstream completeModelSummaries filtering for models list while keeping modelAnalysis built from all summaries for partial coverage. - Regenerate CodexParserHash for merged Vendored/CostUsage sources. Tests: SpendModelsPresentationTests, SpendDashboardModelTests, SpendDashboardKimiModelTests, KimiCodeSessionScannerTests, CostUsageDailyReportMergeTests, ShareStatsTests, UserFacingLocalizationCoverageTests (63 tests). Co-authored-by: Cursor --- .../PreferencesSpendDashboardPane.swift | 85 +-- .../CodexBar/PreferencesSpendModelsView.swift | 553 ++++++++++++++++++ .../Resources/ar.lproj/Localizable.strings | 9 + .../Resources/ca.lproj/Localizable.strings | 9 + .../Resources/de.lproj/Localizable.strings | 9 + .../Resources/en.lproj/Localizable.strings | 9 + .../Resources/es.lproj/Localizable.strings | 9 + .../Resources/fa.lproj/Localizable.strings | 9 + .../Resources/fr.lproj/Localizable.strings | 9 + .../Resources/gl.lproj/Localizable.strings | 9 + .../Resources/id.lproj/Localizable.strings | 9 + .../Resources/it.lproj/Localizable.strings | 9 + .../Resources/ja.lproj/Localizable.strings | 9 + .../Resources/ko.lproj/Localizable.strings | 9 + .../Resources/nl.lproj/Localizable.strings | 9 + .../Resources/pl.lproj/Localizable.strings | 9 + .../Resources/pt-BR.lproj/Localizable.strings | 9 + .../Resources/ru.lproj/Localizable.strings | 9 + .../Resources/sv.lproj/Localizable.strings | 9 + .../Resources/th.lproj/Localizable.strings | 9 + .../Resources/tr.lproj/Localizable.strings | 9 + .../Resources/uk.lproj/Localizable.strings | 9 + .../Resources/vi.lproj/Localizable.strings | 9 + .../zh-Hans.lproj/Localizable.strings | 9 + .../zh-Hant.lproj/Localizable.strings | 9 + .../CodexBar/SpendDashboardController.swift | 80 ++- Sources/CodexBar/SpendDashboardModel.swift | 479 ++++++++++++++- Sources/CodexBarCore/CostUsageModels.swift | 68 +++ .../Generated/CodexParserHash.generated.swift | 2 +- .../CodexBarCore/PiSessionCostScanner.swift | 6 +- .../Kimi/KimiCodeSessionScanner.swift | 211 +++++++ .../Providers/Kimi/KimiSettingsReader.swift | 4 +- .../CostUsageScanner+CacheHelpers.swift | 3 + .../CostUsage/CostUsageScanner+Claude.swift | 6 +- .../CostUsageDailyReportMergeTests.swift | 10 + .../CodexBarTests/CostUsageFetcherTests.swift | 6 +- .../CostUsageScannerBreakdownTests.swift | 11 +- .../KimiCodeSessionScannerTests.swift | 123 ++++ .../LocalizationLanguageCatalogTests.swift | 10 + Tests/CodexBarTests/ShareStatsTests.swift | 4 + .../SpendDashboardControllerTests.swift | 5 +- .../SpendDashboardKimiModelTests.swift | 121 ++++ .../SpendDashboardModelTests.swift | 166 +++++- ...SpendDashboardSourceConcurrencyTests.swift | 51 ++ .../SpendModelsPresentationTests.swift | 190 ++++++ .../UserFacingLocalizationCoverageTests.swift | 10 +- 46 files changed, 2328 insertions(+), 83 deletions(-) create mode 100644 Sources/CodexBar/PreferencesSpendModelsView.swift create mode 100644 Sources/CodexBarCore/Providers/Kimi/KimiCodeSessionScanner.swift create mode 100644 Tests/CodexBarTests/KimiCodeSessionScannerTests.swift create mode 100644 Tests/CodexBarTests/SpendDashboardKimiModelTests.swift create mode 100644 Tests/CodexBarTests/SpendModelsPresentationTests.swift diff --git a/Sources/CodexBar/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift index 83e7f31038..b8d603efd3 100644 --- a/Sources/CodexBar/PreferencesSpendDashboardPane.swift +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -8,6 +8,7 @@ func spendDashboardDayRangeText(_ days: Int) -> String { switch days { case 7: template = L("7d") case 30: template = L("30d") + case 365: return L("All") default: return codexBarLocalizedInteger(days) } return template.replacingOccurrences( @@ -48,6 +49,7 @@ struct SpendDashboardPane: View { @Bindable var settings: SettingsStore @Bindable var store: UsageStore @State private var controller: SpendDashboardController + @State private var selectedModelDays = 365 init(settings: SettingsStore, store: UsageStore) { self.settings = settings @@ -145,8 +147,18 @@ struct SpendDashboardPane: View { .frame(maxWidth: .infinity, minHeight: 220) } } else { + let modelHostGroupID = self.controller.model.groups.first?.id ForEach(self.controller.model.groups) { group in - SpendCurrencySection(group: group, requestedDays: self.controller.model.requestedDays) + SpendCurrencySection( + group: group, + requestedDays: self.controller.model.requestedDays, + modelAnalysis: group.id == modelHostGroupID + ? self.controller.model.modelAnalysis(for: self.selectedModelDays) + : nil, + modelChartDomain: group.id == modelHostGroupID + ? self.controller.model.modelChartDomain(for: self.selectedModelDays) + : nil, + selectedModelDays: self.$selectedModelDays) } } @@ -229,6 +241,9 @@ struct SpendDashboardPane: View { private struct SpendCurrencySection: View { let group: SpendDashboardModel.CurrencyGroup let requestedDays: Int + let modelAnalysis: SpendDashboardModel.ModelAnalysis? + let modelChartDomain: ClosedRange? + @Binding var selectedModelDays: Int var body: some View { VStack(alignment: .leading, spacing: 12) { @@ -269,7 +284,12 @@ private struct SpendCurrencySection: View { } SpendProviderPanel(group: self.group) - SpendModelPanel(group: self.group) + if let modelAnalysis { + SpendModelsSection( + analysis: modelAnalysis, + chartDomain: self.modelChartDomain, + selectedDays: self.$selectedModelDays) + } SpendDailyChart(group: self.group) } } @@ -323,65 +343,6 @@ private struct SpendProviderPanel: View { } } -private struct SpendModelPanel: View { - let group: SpendDashboardModel.CurrencyGroup - - var body: some View { - SpendDashboardPanel { - VStack(alignment: .leading, spacing: 0) { - Text(L("Models")).font(.headline).padding(.bottom, 8) - let presentation = spendDashboardModelHistoryPresentation(self.group) - switch presentation { - case .unavailable: - Text(L("Model breakdown unavailable")) - .foregroundStyle(.secondary) - .padding(.vertical, 10) - case .empty: - Text(L("No model-level history")) - .foregroundStyle(.secondary) - .padding(.vertical, 10) - case .partial, .complete: - if presentation == .partial { - Label(L("Model breakdown unavailable"), systemImage: "exclamationmark.triangle") - .font(.caption) - .foregroundStyle(.secondary) - .padding(.bottom, 6) - } - ForEach(self.group.models.prefix(8)) { row in - if row.rank > 1 { - Divider() - } - HStack(spacing: 10) { - if presentation == .complete { - Text(spendDashboardRankText(row.rank)) - .font(.caption.monospacedDigit()) - .foregroundStyle(.tertiary) - .frame(width: 26, alignment: .leading) - } else { - Image(systemName: "circle.dashed") - .font(.caption) - .foregroundStyle(.tertiary) - .frame(width: 26, alignment: .leading) - } - SpendProviderIcon(provider: row.provider) - VStack(alignment: .leading, spacing: 2) { - Text(row.modelName).lineLimit(1) - Text(row.providerName).font(.caption).foregroundStyle(.secondary) - } - Spacer() - Text(row.totalCost.map { - UsageFormatter.currencyString($0, currencyCode: self.group.currencyCode) - } ?? "—") - .monospacedDigit() - } - .padding(.vertical, 9) - } - } - } - } - } -} - struct SpendDailyChartPresentation: Equatable { enum Content: Equatable { case chart @@ -492,7 +453,7 @@ private struct SpendProviderIcon: View { } } -private struct SpendDashboardPanel: View { +struct SpendDashboardPanel: View { @ViewBuilder let content: Content var body: some View { diff --git a/Sources/CodexBar/PreferencesSpendModelsView.swift b/Sources/CodexBar/PreferencesSpendModelsView.swift new file mode 100644 index 0000000000..171bf4901e --- /dev/null +++ b/Sources/CodexBar/PreferencesSpendModelsView.swift @@ -0,0 +1,553 @@ +import Charts +import CodexBarCore +import SwiftUI + +enum SpendModelMetric: String, CaseIterable, Identifiable { + case tokens + case estimatedSpend + + var id: Self { + self + } + + var title: String { + switch self { + case .tokens: "Tokens" + case .estimatedSpend: "Estimated spend" + } + } +} + +func spendModelsDayRangeText(_ days: Int) -> String { + switch days { + case 7: "7d" + case 30: "30d" + case 365: "All" + default: "\(days)d" + } +} + +enum SpendModelsEnglishFormatter { + static func dayText(_ day: Date) -> String { + day.formatted(.dateTime.month(.abbreviated).day().locale(self.locale)) + } + + private static let locale = Locale(identifier: "en_US_POSIX") +} + +func spendModelsRowDetailText(_ row: SpendModelsPresentation.Row) -> String { + guard let value = row.value else { return "—" } + let providers = row.source.providerNames.joined(separator: " · ") + let metric: String = if row.source.inputTokens != nil, + row.source.outputTokens != nil, + let inputTokens = row.source.inputTokens, + let outputTokens = row.source.outputTokens + { + "\(UsageFormatter.tokenCountString(inputTokens)) in · \(UsageFormatter.tokenCountString(outputTokens)) out" + } else { + UsageFormatter.tokenCountString(Int(value.rounded())) + } + return providers.isEmpty ? metric : "\(metric) · \(providers)" +} + +struct SpendModelsPresentation: Equatable { + struct Row: Identifiable, Equatable { + let source: SpendDashboardModel.ModelAnalysisRow + let rank: Int + let value: Double? + let share: Double? + + var id: String { + self.source.id + } + } + + struct Series: Identifiable, Equatable { + let id: String + let name: String + let value: Double + } + + struct Point: Identifiable, Equatable { + let day: Date + let seriesID: String + let seriesName: String + let value: Double + let stackStart: Double + let stackEnd: Double + + var id: String { + "\(self.seriesID):\(Int(self.day.timeIntervalSince1970))" + } + } + + let metric: SpendModelMetric + let rows: [Row] + let series: [Series] + let points: [Point] + let coverage: SpendDashboardModel.ModelMetricCoverage + let metricTotal: Double? + + init( + analysis: SpendDashboardModel.ModelAnalysis, + metric: SpendModelMetric) + { + self.metric = metric + self.coverage = switch metric { + case .tokens: analysis.tokenCoverage + case .estimatedSpend: analysis.costCoverage + } + + let sortedSources = analysis.rows.sorted { lhs, rhs in + Self.compare(lhs, rhs, metric: metric) + } + let metricValues = sortedSources.compactMap { Self.value($0, metric: metric) } + let metricTotal = Self.sum(metricValues) + self.metricTotal = metricTotal + self.rows = sortedSources.enumerated().map { offset, source in + let value = Self.value(source, metric: metric) + return Row( + source: source, + rank: offset + 1, + value: value, + share: value.flatMap { value in + guard let total = metricTotal, total > 0 else { return nil } + return value / total + }) + } + + let builtSeries = self.rows.compactMap { row -> Series? in + guard let value = row.value else { return nil } + guard value > 0 else { return nil } + return Series(id: row.id, name: row.source.displayName, value: value) + } + self.series = builtSeries + + let valuesByDay = Dictionary(grouping: analysis.dailyValues, by: \.day) + self.points = valuesByDay.keys.sorted().flatMap { day in + let dailyValues = valuesByDay[day] ?? [] + var seriesValues: [String: Double] = [:] + for dailyValue in dailyValues { + guard let value = Self.value(dailyValue, metric: metric), value > 0 else { continue } + seriesValues[dailyValue.modelID, default: 0] += value + } + var cursor = 0.0 + return builtSeries.compactMap { series -> Point? in + guard let value = seriesValues[series.id], value > 0 else { return nil } + let start = cursor + cursor += value + return Point( + day: day, + seriesID: series.id, + seriesName: series.name, + value: value, + stackStart: start, + stackEnd: cursor) + } + } + } + + private static func compare( + _ lhs: SpendDashboardModel.ModelAnalysisRow, + _ rhs: SpendDashboardModel.ModelAnalysisRow, + metric: SpendModelMetric) -> Bool + { + switch (self.value(lhs, metric: metric), self.value(rhs, metric: metric)) { + case let (left?, right?) where left != right: return left > right + case (_?, nil): return true + case (nil, _?): return false + default: + let otherMetric: SpendModelMetric = metric == .tokens ? .estimatedSpend : .tokens + switch (self.value(lhs, metric: otherMetric), self.value(rhs, metric: otherMetric)) { + case let (left?, right?) where left != right: return left > right + case (_?, nil): return true + case (nil, _?): return false + default: + let comparison = lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) + if comparison != .orderedSame { return comparison == .orderedAscending } + return lhs.id < rhs.id + } + } + } + + private static func value( + _ row: SpendDashboardModel.ModelAnalysisRow, + metric: SpendModelMetric) -> Double? + { + switch metric { + case .tokens: row.totalTokens.map(Double.init) + case .estimatedSpend: row.estimatedCost + } + } + + private static func value( + _ value: SpendDashboardModel.ModelDailyValue, + metric: SpendModelMetric) -> Double? + { + switch metric { + case .tokens: value.totalTokens.map(Double.init) + case .estimatedSpend: value.estimatedCost + } + } + + private static func sum(_ values: [Double]) -> Double? { + guard !values.isEmpty else { return nil } + let result = values.reduce(0, +) + return result.isFinite ? result : nil + } +} + +struct SpendModelsAxisDates { + static func make( + selectedDays: Int, + dataDays: [Date], + domain: ClosedRange, + calendar: Calendar = .current) -> [Date] + { + let normalizedDataDays = Array(Set(dataDays.map { calendar.startOfDay(for: $0) })).sorted() + if selectedDays == 7 { + return normalizedDataDays + } + + let domainStart = calendar.startOfDay(for: domain.lowerBound) + if selectedDays != 365 { + return self.strideDates( + from: domainStart, + whileBefore: domain.upperBound, + step: 7, + calendar: calendar) + } + + let dataEnd = normalizedDataDays.last + ?? calendar.date(byAdding: .day, value: -1, to: domain.upperBound) + ?? domainStart + let daySpan = max( + 0, + calendar.dateComponents([.day], from: domainStart, to: dataEnd).day ?? 0) + guard daySpan > 0 else { return [domainStart] } + + // Keep the complete daily series, but limit All to roughly six readable date labels. + let step = max(14, Int(ceil(Double(daySpan) / 5))) + var dates = self.strideDates( + from: domainStart, + through: dataEnd, + step: step, + calendar: calendar) + + guard let last = dates.last, + !calendar.isDate(last, inSameDayAs: dataEnd) + else { + return dates + } + + let trailingGap = calendar.dateComponents([.day], from: last, to: dataEnd).day ?? step + if trailingGap < max(7, step / 2), dates.count > 1 { + dates[dates.count - 1] = dataEnd + } else { + dates.append(dataEnd) + } + return dates + } + + private static func strideDates( + from start: Date, + whileBefore end: Date, + step: Int, + calendar: Calendar) -> [Date] + { + var dates: [Date] = [] + var cursor = start + while cursor < end { + dates.append(cursor) + guard let next = calendar.date(byAdding: .day, value: step, to: cursor) else { break } + cursor = next + } + return dates + } + + private static func strideDates( + from start: Date, + through end: Date, + step: Int, + calendar: Calendar) -> [Date] + { + var dates: [Date] = [] + var cursor = start + while cursor <= end { + dates.append(cursor) + guard let next = calendar.date(byAdding: .day, value: step, to: cursor) else { break } + cursor = next + } + return dates + } +} + +struct SpendModelsSection: View { + let analysis: SpendDashboardModel.ModelAnalysis + let chartDomain: ClosedRange? + @Binding var selectedDays: Int + @State private var selectedDay: Date? + + private var presentation: SpendModelsPresentation { + SpendModelsPresentation(analysis: self.analysis, metric: .tokens) + } + + var body: some View { + SpendDashboardPanel { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .firstTextBaseline, spacing: 12) { + Text(L("Models")) + .font(.headline) + Spacer() + ForEach([7, 30, 365], id: \.self) { days in + self.rangeButton(days) + } + } + if self.presentation.points.isEmpty { + Text(L("No model-level history")) + .foregroundStyle(.secondary) + .padding(.vertical, 10) + } else { + self.chart + self.ranking + } + if self.presentation.coverage == .partial { + Text(L("Partial model history: incomplete source-days are excluded.")) + .font(.caption) + .foregroundStyle(.tertiary) + } + } + } + } + + private var chart: some View { + Chart { + ForEach(self.presentation.points) { point in + BarMark( + x: .value("Day", point.day, unit: .day), + yStart: .value("Tokens", point.stackStart), + yEnd: .value("Tokens", point.stackEnd), + width: .ratio(0.68)) + .foregroundStyle(by: .value("Models", point.seriesName)) + .accessibilityLabel(Text("\(point.seriesName), \(self.dayText(point.day))")) + .accessibilityValue(Text(self.metricText(point.value))) + } + if let selectedDay { + RuleMark(x: .value("Day", selectedDay, unit: .day)) + .foregroundStyle(.clear) + .annotation(position: .top, overflowResolution: .init( + x: .fit(to: .chart), + y: .fit(to: .chart))) + { + self.tooltip(selectedDay) + } + } + } + .chartXScale( + domain: self.chartDomain ?? self.fallbackDomain, + range: .plotDimension(startPadding: 10, endPadding: 30)) + .chartForegroundStyleScale( + domain: self.presentation.series.map(\.name), + range: self.presentation.series.indices.map { index in + self.color(for: index) + }) + .chartLegend(.hidden) + .chartXAxis { + AxisMarks(values: self.xAxisDates) { value in + if let date = value.as(Date.self) { + AxisValueLabel(anchor: self.xAxisLabelAnchor(for: date)) { + Text(self.dayText(date)) + .foregroundStyle(.secondary) + } + } + } + } + .chartYAxis { + AxisMarks(position: .leading, values: .automatic(desiredCount: 4)) { value in + AxisValueLabel { + if let amount = value.as(Double.self) { + Text(self.metricText(amount)) + .foregroundStyle(.secondary) + } + } + } + } + .frame(height: 220) + .accessibilityLabel("Models") + .accessibilityValue(self.chartAccessibilityValue) + .chartOverlay { proxy in + GeometryReader { geo in + MouseLocationReader { location in + self.updateSelectedDay(location: location, proxy: proxy, geo: geo) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + } + + private var ranking: some View { + self.rankingContent + } + + private var rankingContent: some View { + VStack(alignment: .leading, spacing: 8) { + ForEach(self.presentation.rows) { row in + HStack(spacing: 9) { + RoundedRectangle(cornerRadius: 2, style: .continuous) + .fill(self.color(for: row)) + .frame(width: 10, height: 10) + .accessibilityHidden(true) + Text(row.source.displayName) + .font(.body) + .lineLimit(1) + Spacer() + Text(self.rowDetail(row)) + .font(.body) + .foregroundStyle(.secondary) + .monospacedDigit() + Text(self.shareText(row.value)) + .font(.body.weight(.medium)) + .monospacedDigit() + .frame(width: 58, alignment: .trailing) + } + } + } + } + + private func rowDetail(_ row: SpendModelsPresentation.Row) -> String { + guard self.presentation.metric == .tokens else { + guard let value = row.value else { return "—" } + let providers = row.source.providerNames.joined(separator: " · ") + let metric = self.metricText(value) + return providers.isEmpty ? metric : "\(metric) · \(providers)" + } + return spendModelsRowDetailText(row) + } + + private func shareText(_ value: Double?) -> String { + guard let value else { return "—" } + guard let total = self.presentation.metricTotal, total > 0 else { return "—" } + return UsageFormatter.percentString(value / total * 100) + } + + private func tooltip(_ day: Date) -> some View { + let points = self.presentation.points + .filter { Calendar.current.isDate($0.day, inSameDayAs: day) } + .sorted { $0.value > $1.value } + return VStack(alignment: .leading, spacing: 5) { + Text(self.dayText(day)) + .font(.body.weight(.semibold)) + ForEach(points) { point in + HStack(spacing: 7) { + Circle() + .fill(self.color(for: self.seriesIndex(point.seriesID))) + .frame(width: 8, height: 8) + Text(point.seriesName) + Spacer(minLength: 12) + Text(self.metricText(point.value)) + .monospacedDigit() + } + .font(.body) + } + } + .padding(10) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 9, style: .continuous)) + .shadow(color: .black.opacity(0.09), radius: 10, y: 3) + } + + private var chartAccessibilityValue: String { + let days = Set(self.presentation.points.map(\.day)).count + return "\(days) days · \(self.presentation.series.count) models" + } + + private var fallbackDomain: ClosedRange { + let days = self.presentation.points.map(\.day) + let start = days.min() ?? Date() + let end = days.max() ?? start + return start...Calendar.current.date(byAdding: .day, value: 1, to: end)! + } + + private var xAxisDates: [Date] { + SpendModelsAxisDates.make( + selectedDays: self.selectedDays, + dataDays: self.presentation.points.map(\.day), + domain: self.chartDomain ?? self.fallbackDomain) + } + + private func rangeButton(_ days: Int) -> some View { + Button { + self.selectedDays = days + self.selectedDay = nil + } label: { + Text(spendModelsDayRangeText(days)) + .font(.body) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background { + if self.selectedDays == days { + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(Color.secondary.opacity(0.12)) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityAddTraits(self.selectedDays == days ? .isSelected : []) + } + + private func xAxisLabelAnchor(for date: Date) -> UnitPoint { + if let first = self.xAxisDates.first, Calendar.current.isDate(date, inSameDayAs: first) { + return .topLeading + } + if let last = self.xAxisDates.last, Calendar.current.isDate(date, inSameDayAs: last) { + return .topTrailing + } + return .top + } + + private func metricText(_ value: Double) -> String { + UsageFormatter.tokenCountString(Int(value.rounded())) + } + + private func dayText(_ day: Date) -> String { + SpendModelsEnglishFormatter.dayText(day) + } + + private func color(for index: Int) -> Color { + let accentOpacities = [0.95, 0.76, 0.58, 0.42, 0.30] + if index < accentOpacities.count { + return Color.accentColor.opacity(accentOpacities[index]) + } + let neutralOpacities = [0.30, 0.40, 0.50, 0.60, 0.70] + return Color(nsColor: .secondaryLabelColor) + .opacity(neutralOpacities[(index - accentOpacities.count) % neutralOpacities.count]) + } + + private func color(for row: SpendModelsPresentation.Row) -> Color { + guard let index = self.presentation.series.firstIndex(where: { $0.id == row.id }) else { + return Color(nsColor: .tertiaryLabelColor).opacity(0.55) + } + return self.color(for: index) + } + + private func seriesIndex(_ id: String) -> Int { + self.presentation.series.firstIndex(where: { $0.id == id }) ?? 0 + } + + private func updateSelectedDay(location: CGPoint?, proxy: ChartProxy, geo: GeometryProxy) { + guard let location, let plotAnchor = proxy.plotFrame else { + self.selectedDay = nil + return + } + let plotFrame = geo[plotAnchor] + guard plotFrame.contains(location) else { + self.selectedDay = nil + return + } + let xInPlot = location.x - plotFrame.origin.x + guard let date: Date = proxy.value(atX: xInPlot) else { return } + self.selectedDay = Set(self.presentation.points.map(\.day)).min { + abs($0.timeIntervalSince(date)) < abs($1.timeIntervalSince(date)) + } + } +} diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index be1b7a75dc..64371c707f 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -1271,6 +1271,15 @@ "Subscriptions" = "الاشتراكات"; "By subscription" = "حسب الاشتراك"; "No model-level history" = "لا يوجد سجل على مستوى النموذج"; +"Tokens" = "Tokens"; +"Tracked model tokens" = "Tracked model tokens"; +"Priced model spend" = "Priced model spend"; +"Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; +"No priced model history" = "No priced model history"; +"Other" = "Other"; +"Show all %d models" = "Show all %d models"; +"Show top 20" = "Show top 20"; +"Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; "Daily estimated spend" = "الإنفاق اليومي التقديري"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d نافذة كاملة مدتها 5 ساعات متبقية من الأسبوعي · %d نافذة حتى إعادة التعيين"; "Weekly cannot run out before reset at this pace" = "لا يمكن أن ينفد الحد الأسبوعي قبل إعادة التعيين بهذه الوتيرة"; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index 55985b0af6..82a14c2a27 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -1270,6 +1270,15 @@ "Subscriptions" = "Subscripcions"; "By subscription" = "Per subscripció"; "No model-level history" = "Sense historial per model"; +"Tokens" = "Tokens"; +"Tracked model tokens" = "Tracked model tokens"; +"Priced model spend" = "Priced model spend"; +"Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; +"No priced model history" = "No priced model history"; +"Other" = "Other"; +"Show all %d models" = "Show all %d models"; +"Show top 20" = "Show top 20"; +"Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; "Daily estimated spend" = "Despesa diària estimada"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d finestres completes de 5 h de quota setmanal · %d finestres fins al reinici"; "Weekly cannot run out before reset at this pace" = "La quota setmanal no es pot esgotar abans del reinici a aquest ritme"; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index 37d2b62165..a4ac8e0aea 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -1268,6 +1268,15 @@ "Subscriptions" = "Abonnements"; "By subscription" = "Nach Abonnement"; "No model-level history" = "Kein Verlauf auf Modellebene"; +"Tokens" = "Tokens"; +"Tracked model tokens" = "Tracked model tokens"; +"Priced model spend" = "Priced model spend"; +"Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; +"No priced model history" = "No priced model history"; +"Other" = "Other"; +"Show all %d models" = "Show all %d models"; +"Show top 20" = "Show top 20"; +"Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; "Daily estimated spend" = "Geschätzte tägliche Ausgaben"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d volle 5-Std.-Fenster des Wochenlimits übrig · %d Fenster bis zum Reset"; "Weekly cannot run out before reset at this pace" = "Das Wochenlimit kann bei diesem Tempo nicht vor dem Reset aufgebraucht sein"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 5cc6b2defb..61492e275f 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -1272,6 +1272,15 @@ "Subscriptions" = "Subscriptions"; "By subscription" = "By subscription"; "No model-level history" = "No model-level history"; +"Tokens" = "Tokens"; +"Tracked model tokens" = "Tracked model tokens"; +"Priced model spend" = "Priced model spend"; +"Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; +"No priced model history" = "No priced model history"; +"Other" = "Other"; +"Show all %d models" = "Show all %d models"; +"Show top 20" = "Show top 20"; +"Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; "Daily estimated spend" = "Daily estimated spend"; "≈%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"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 90159b2bc2..806d491124 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -1266,6 +1266,15 @@ "Subscriptions" = "Suscripciones"; "By subscription" = "Por suscripción"; "No model-level history" = "No hay historial por modelo"; +"Tokens" = "Tokens"; +"Tracked model tokens" = "Tracked model tokens"; +"Priced model spend" = "Priced model spend"; +"Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; +"No priced model history" = "No priced model history"; +"Other" = "Other"; +"Show all %d models" = "Show all %d models"; +"Show top 20" = "Show top 20"; +"Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; "Daily estimated spend" = "Gasto diario estimado"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d ventanas completas de 5 h de cuota semanal · %d ventanas hasta el reinicio"; "Weekly cannot run out before reset at this pace" = "La cuota semanal no puede agotarse antes del reinicio a este ritmo"; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index 789090531a..a86d9cbc73 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -1271,6 +1271,15 @@ "Subscriptions" = "اشتراک‌ها"; "By subscription" = "بر اساس اشتراک"; "No model-level history" = "تاریخچه‌ای در سطح مدل وجود ندارد"; +"Tokens" = "Tokens"; +"Tracked model tokens" = "Tracked model tokens"; +"Priced model spend" = "Priced model spend"; +"Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; +"No priced model history" = "No priced model history"; +"Other" = "Other"; +"Show all %d models" = "Show all %d models"; +"Show top 20" = "Show top 20"; +"Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; "Daily estimated spend" = "برآورد هزینه روزانه"; "≈%d full 5h windows of weekly left · %d windows until reset" = "حدود %d بازه کامل ۵ ساعته از سهم هفتگی مانده · %d بازه تا بازنشانی"; "Weekly cannot run out before reset at this pace" = "با این روند، سهم هفتگی پیش از بازنشانی تمام نمی‌شود"; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index fafb4abed0..ff0c82c299 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -1267,6 +1267,15 @@ "Subscriptions" = "Abonnements"; "By subscription" = "Par abonnement"; "No model-level history" = "Aucun historique au niveau des modèles"; +"Tokens" = "Tokens"; +"Tracked model tokens" = "Tracked model tokens"; +"Priced model spend" = "Priced model spend"; +"Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; +"No priced model history" = "No priced model history"; +"Other" = "Other"; +"Show all %d models" = "Show all %d models"; +"Show top 20" = "Show top 20"; +"Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; "Daily estimated spend" = "Dépenses quotidiennes estimées"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d fenêtres complètes de 5 h de quota hebdomadaire · %d fenêtres avant réinitialisation"; "Weekly cannot run out before reset at this pace" = "Le quota hebdomadaire ne peut pas être épuisé avant la réinitialisation à ce rythme"; diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings index 82cc28af19..6a939ec512 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -1267,6 +1267,15 @@ "Subscriptions" = "Subscricións"; "By subscription" = "Por subscrición"; "No model-level history" = "Sen historial por modelo"; +"Tokens" = "Tokens"; +"Tracked model tokens" = "Tracked model tokens"; +"Priced model spend" = "Priced model spend"; +"Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; +"No priced model history" = "No priced model history"; +"Other" = "Other"; +"Show all %d models" = "Show all %d models"; +"Show top 20" = "Show top 20"; +"Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; "Daily estimated spend" = "Gasto diario estimado"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d xanelas completas de 5 h de cota semanal · %d xanelas ata o restablecemento"; "Weekly cannot run out before reset at this pace" = "A cota semanal non pode esgotarse antes do restablecemento a este ritmo"; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index abebb2f911..5295c524dc 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -1271,6 +1271,15 @@ "Subscriptions" = "Langganan"; "By subscription" = "Berdasarkan langganan"; "No model-level history" = "Tidak ada riwayat tingkat model"; +"Tokens" = "Tokens"; +"Tracked model tokens" = "Tracked model tokens"; +"Priced model spend" = "Priced model spend"; +"Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; +"No priced model history" = "No priced model history"; +"Other" = "Other"; +"Show all %d models" = "Show all %d models"; +"Show top 20" = "Show top 20"; +"Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; "Daily estimated spend" = "Perkiraan pengeluaran harian"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d jendela 5 jam penuh dari kuota mingguan tersisa · %d jendela hingga reset"; "Weekly cannot run out before reset at this pace" = "Kuota mingguan tidak dapat habis sebelum reset dengan laju ini"; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index 402383c328..f12e98e3c3 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -1271,6 +1271,15 @@ "Subscriptions" = "Abbonamenti"; "By subscription" = "Per abbonamento"; "No model-level history" = "Nessuna cronologia a livello di modello"; +"Tokens" = "Tokens"; +"Tracked model tokens" = "Tracked model tokens"; +"Priced model spend" = "Priced model spend"; +"Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; +"No priced model history" = "No priced model history"; +"Other" = "Other"; +"Show all %d models" = "Show all %d models"; +"Show top 20" = "Show top 20"; +"Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; "Daily estimated spend" = "Spesa giornaliera stimata"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d finestre complete da 5 h di quota settimanale · %d finestre al reset"; "Weekly cannot run out before reset at this pace" = "La quota settimanale non può esaurirsi prima del reset a questo ritmo"; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index 51f5987788..3ffae5aef7 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -1268,6 +1268,15 @@ "Subscriptions" = "サブスクリプション"; "By subscription" = "サブスクリプション別"; "No model-level history" = "モデル別の履歴はありません"; +"Tokens" = "Tokens"; +"Tracked model tokens" = "Tracked model tokens"; +"Priced model spend" = "Priced model spend"; +"Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; +"No priced model history" = "No priced model history"; +"Other" = "Other"; +"Show all %d models" = "Show all %d models"; +"Show top 20" = "Show top 20"; +"Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; "Daily estimated spend" = "日別推定支出"; "≈%d full 5h windows of weekly left · %d windows until reset" = "週間枠は約%d回分の完全な5時間ウィンドウ · リセットまで%d回"; "Weekly cannot run out before reset at this pace" = "このペースではリセット前に週間枠を使い切れません"; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index 57d7245382..2134d0b08a 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -1235,6 +1235,15 @@ "Subscriptions" = "구독"; "By subscription" = "구독별"; "No model-level history" = "모델별 내역이 없습니다"; +"Tokens" = "Tokens"; +"Tracked model tokens" = "Tracked model tokens"; +"Priced model spend" = "Priced model spend"; +"Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; +"No priced model history" = "No priced model history"; +"Other" = "Other"; +"Show all %d models" = "Show all %d models"; +"Show top 20" = "Show top 20"; +"Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; "Daily estimated spend" = "일별 예상 지출"; "≈%d full 5h windows of weekly left · %d windows until reset" = "주간 한도 약 %d개의 전체 5시간 창 남음 · 재설정까지 %d개 창"; "Weekly cannot run out before reset at this pace" = "이 속도라면 재설정 전에 주간 한도를 소진할 수 없습니다"; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index fbf7cbf4df..89b918540d 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -1267,6 +1267,15 @@ "Subscriptions" = "Abonnementen"; "By subscription" = "Per abonnement"; "No model-level history" = "Geen geschiedenis op modelniveau"; +"Tokens" = "Tokens"; +"Tracked model tokens" = "Tracked model tokens"; +"Priced model spend" = "Priced model spend"; +"Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; +"No priced model history" = "No priced model history"; +"Other" = "Other"; +"Show all %d models" = "Show all %d models"; +"Show top 20" = "Show top 20"; +"Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; "Daily estimated spend" = "Geschatte dagelijkse uitgaven"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d volledige vensters van 5 uur aan weeklimiet over · %d vensters tot reset"; "Weekly cannot run out before reset at this pace" = "Het weeklimiet kan bij dit tempo niet vóór de reset opraken"; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index 92351c1788..bdd6a4ff9d 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -1271,6 +1271,15 @@ "Subscriptions" = "Subskrypcje"; "By subscription" = "Według subskrypcji"; "No model-level history" = "Brak historii na poziomie modeli"; +"Tokens" = "Tokens"; +"Tracked model tokens" = "Tracked model tokens"; +"Priced model spend" = "Priced model spend"; +"Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; +"No priced model history" = "No priced model history"; +"Other" = "Other"; +"Show all %d models" = "Show all %d models"; +"Show top 20" = "Show top 20"; +"Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; "Daily estimated spend" = "Szacowane dzienne wydatki"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d pełnych 5-godz. okien limitu tygodniowego · %d okien do resetu"; "Weekly cannot run out before reset at this pace" = "Przy tym tempie limit tygodniowy nie może wyczerpać się przed resetem"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index 9fc56f647d..85783c7fe0 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -1268,6 +1268,15 @@ "Subscriptions" = "Assinaturas"; "By subscription" = "Por assinatura"; "No model-level history" = "Sem histórico por modelo"; +"Tokens" = "Tokens"; +"Tracked model tokens" = "Tracked model tokens"; +"Priced model spend" = "Priced model spend"; +"Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; +"No priced model history" = "No priced model history"; +"Other" = "Other"; +"Show all %d models" = "Show all %d models"; +"Show top 20" = "Show top 20"; +"Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; "Daily estimated spend" = "Gasto diário estimado"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d janelas completas de 5 h da cota semanal · %d janelas até a renovação"; "Weekly cannot run out before reset at this pace" = "A cota semanal não pode acabar antes da renovação nesse ritmo"; diff --git a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings index debe92c848..bb71e16a2c 100644 --- a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings @@ -1269,6 +1269,15 @@ "Subscriptions" = "Подписки"; "By subscription" = "По подпискам"; "No model-level history" = "Нет истории по моделям"; +"Tokens" = "Tokens"; +"Tracked model tokens" = "Tracked model tokens"; +"Priced model spend" = "Priced model spend"; +"Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; +"No priced model history" = "No priced model history"; +"Other" = "Other"; +"Show all %d models" = "Show all %d models"; +"Show top 20" = "Show top 20"; +"Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; "Daily estimated spend" = "Предполагаемые ежедневные расходы"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d полных 5-часовых окон недельного лимита · %d окон до сброса"; "Weekly cannot run out before reset at this pace" = "При таком темпе недельный лимит не может закончиться до сброса"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index 18e1d3c828..0cc76dbbae 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -1266,6 +1266,15 @@ "Subscriptions" = "Abonnemang"; "By subscription" = "Per abonnemang"; "No model-level history" = "Ingen historik på modellnivå"; +"Tokens" = "Tokens"; +"Tracked model tokens" = "Tracked model tokens"; +"Priced model spend" = "Priced model spend"; +"Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; +"No priced model history" = "No priced model history"; +"Other" = "Other"; +"Show all %d models" = "Show all %d models"; +"Show top 20" = "Show top 20"; +"Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; "Daily estimated spend" = "Uppskattade dagliga utgifter"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d fulla 5-timmarsfönster av veckokvoten kvar · %d fönster till återställning"; "Weekly cannot run out before reset at this pace" = "Veckokvoten kan inte ta slut före återställningen i den här takten"; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index 686d42e6ef..31c705655c 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -1271,6 +1271,15 @@ "Subscriptions" = "การสมัครสมาชิก"; "By subscription" = "แยกตามการสมัครสมาชิก"; "No model-level history" = "ไม่มีประวัติระดับโมเดล"; +"Tokens" = "Tokens"; +"Tracked model tokens" = "Tracked model tokens"; +"Priced model spend" = "Priced model spend"; +"Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; +"No priced model history" = "No priced model history"; +"Other" = "Other"; +"Show all %d models" = "Show all %d models"; +"Show top 20" = "Show top 20"; +"Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; "Daily estimated spend" = "ค่าใช้จ่ายรายวันโดยประมาณ"; "≈%d full 5h windows of weekly left · %d windows until reset" = "เหลือโควตารายสัปดาห์ ≈%d ช่วงเต็ม 5 ชม. · อีก %d ช่วงจนรีเซ็ต"; "Weekly cannot run out before reset at this pace" = "ด้วยอัตรานี้ โควตารายสัปดาห์จะไม่หมดก่อนรีเซ็ต"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index b8eb70a926..320c8c6825 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -1269,6 +1269,15 @@ "Subscriptions" = "Abonelikler"; "By subscription" = "Aboneliğe göre"; "No model-level history" = "Model düzeyinde geçmiş yok"; +"Tokens" = "Tokens"; +"Tracked model tokens" = "Tracked model tokens"; +"Priced model spend" = "Priced model spend"; +"Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; +"No priced model history" = "No priced model history"; +"Other" = "Other"; +"Show all %d models" = "Show all %d models"; +"Show top 20" = "Show top 20"; +"Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; "Daily estimated spend" = "Günlük tahmini harcama"; "≈%d full 5h windows of weekly left · %d windows until reset" = "Haftalık kotadan ≈%d tam 5 saatlik pencere kaldı · sıfırlamaya %d pencere"; "Weekly cannot run out before reset at this pace" = "Bu hızda haftalık kota sıfırlamadan önce tükenemez"; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index 359780b371..2d99f9662c 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -1267,6 +1267,15 @@ "Subscriptions" = "Підписки"; "By subscription" = "За підписками"; "No model-level history" = "Немає історії за моделями"; +"Tokens" = "Tokens"; +"Tracked model tokens" = "Tracked model tokens"; +"Priced model spend" = "Priced model spend"; +"Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; +"No priced model history" = "No priced model history"; +"Other" = "Other"; +"Show all %d models" = "Show all %d models"; +"Show top 20" = "Show top 20"; +"Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; "Daily estimated spend" = "Орієнтовні щоденні витрати"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d повних 5-годинних вікон тижневого ліміту · %d вікон до скидання"; "Weekly cannot run out before reset at this pace" = "За такого темпу тижневий ліміт не може вичерпатися до скидання"; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index 14dbaabac6..69a79fbeec 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -1268,6 +1268,15 @@ "Subscriptions" = "Gói đăng ký"; "By subscription" = "Theo gói đăng ký"; "No model-level history" = "Không có lịch sử theo mô hình"; +"Tokens" = "Tokens"; +"Tracked model tokens" = "Tracked model tokens"; +"Priced model spend" = "Priced model spend"; +"Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; +"No priced model history" = "No priced model history"; +"Other" = "Other"; +"Show all %d models" = "Show all %d models"; +"Show top 20" = "Show top 20"; +"Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; "Daily estimated spend" = "Chi tiêu ước tính hằng ngày"; "≈%d full 5h windows of weekly left · %d windows until reset" = "Còn ≈%d cửa sổ 5 giờ đầy đủ của hạn mức tuần · %d cửa sổ đến khi đặt lại"; "Weekly cannot run out before reset at this pace" = "Với tốc độ này, hạn mức tuần không thể hết trước khi đặt lại"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index ae94fdb4b8..36600abedb 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -1243,6 +1243,15 @@ "Subscriptions" = "订阅"; "By subscription" = "按订阅"; "No model-level history" = "暂无模型级历史"; +"Tokens" = "令牌"; +"Tracked model tokens" = "已跟踪模型令牌"; +"Priced model spend" = "已定价模型支出"; +"Partial model history: incomplete source-days are excluded." = "模型历史不完整:已排除明细不完整的来源日期。"; +"No priced model history" = "暂无已定价模型历史"; +"Other" = "其他"; +"Show all %d models" = "显示全部 %d 个模型"; +"Show top 20" = "仅显示前 20 个"; +"Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "模型名称会在去除首尾空白并忽略大小写后精确合并;不同提供商之间不会自动去重。"; "Daily estimated spend" = "每日估算支出"; "≈%d full 5h windows of weekly left · %d windows until reset" = "每周额度约剩 %d 个完整 5 小时窗口 · 距重置还有 %d 个窗口"; "Weekly cannot run out before reset at this pace" = "按此速度,每周额度无法在重置前用完"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index c5a328b8c9..e8e8478071 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -1298,6 +1298,15 @@ "Subscriptions" = "訂閱"; "By subscription" = "依訂閱"; "No model-level history" = "尚無模型層級歷史"; +"Tokens" = "Tokens"; +"Tracked model tokens" = "Tracked model tokens"; +"Priced model spend" = "Priced model spend"; +"Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; +"No priced model history" = "No priced model history"; +"Other" = "Other"; +"Show all %d models" = "Show all %d models"; +"Show top 20" = "Show top 20"; +"Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; "Daily estimated spend" = "每日預估支出"; "≈%d full 5h windows of weekly left · %d windows until reset" = "每週額度約剩 %d 個完整 5 小時視窗 · 距重置還有 %d 個視窗"; "Weekly cannot run out before reset at this pace" = "依此速度,每週額度無法在重置前用完"; diff --git a/Sources/CodexBar/SpendDashboardController.swift b/Sources/CodexBar/SpendDashboardController.swift index 6f9ec8c361..8457d433d4 100644 --- a/Sources/CodexBar/SpendDashboardController.swift +++ b/Sources/CodexBar/SpendDashboardController.swift @@ -62,6 +62,7 @@ struct SpendDashboardLoadRequest: Sendable { let unavailableSourceIDs: Set let confirmedEmptySourceIDs: Set let codexRequests: [CodexSpendScanRequest] + let kimiCodeHomePath: String? let now: Date let force: Bool @@ -71,6 +72,7 @@ struct SpendDashboardLoadRequest: Sendable { unavailableSourceIDs: Set, confirmedEmptySourceIDs: Set = [], codexRequests: [CodexSpendScanRequest], + kimiCodeHomePath: String? = nil, now: Date, force: Bool) { @@ -79,6 +81,7 @@ struct SpendDashboardLoadRequest: Sendable { self.unavailableSourceIDs = unavailableSourceIDs self.confirmedEmptySourceIDs = confirmedEmptySourceIDs self.codexRequests = codexRequests + self.kimiCodeHomePath = kimiCodeHomePath self.now = now self.force = force } @@ -114,11 +117,19 @@ struct CodexSpendSnapshotLoadContext: Sendable { let includePiSessions: Bool } +struct KimiCodeSpendSnapshotLoadContext: Sendable { + let homePath: String + let now: Date + let historyDays: Int +} + enum SpendDashboardSource { typealias CodexSnapshotLoader = @Sendable (CodexSpendSnapshotLoadContext) async throws -> CostUsageTokenSnapshot + typealias KimiCodeSnapshotLoader = @Sendable (KimiCodeSpendSnapshotLoadContext) async throws + -> CostUsageTokenSnapshot? - static let scanDays = 30 + static let scanDays = 365 @MainActor static func configuration(settings: SettingsStore, store: UsageStore) -> SpendDashboardConfiguration { @@ -142,7 +153,8 @@ enum SpendDashboardSource { { SpendDashboardConfiguration( costUsageEnabled: settings.costUsageEnabled, - providerIDs: providers.map(\.rawValue), + providerIDs: Array(Set( + (providers + self.localModelHistoryProviders(store: store)).map(\.rawValue))).sorted(), codexAccountIdentities: codexRequests.map { "\($0.id)|\($0.cacheIdentity)" }, codexAccountDisplayNames: self.codexDisplayNamesByID(codexRequests), sourceOwnershipFingerprints: self.sourceOwnershipFingerprints( @@ -241,19 +253,30 @@ enum SpendDashboardSource { unavailableSourceIDs: unavailableSourceIDs, confirmedEmptySourceIDs: confirmedEmptySourceIDs, codexRequests: codexRequests, + kimiCodeHomePath: self.localModelHistoryProviders(store: store).contains(.kimi) + ? KimiSettingsReader.kimiCodeHomeURL().path + : nil, now: captureNow, force: mode.forcesLoader) } static func load(_ request: SpendDashboardLoadRequest) async -> SpendDashboardLoadResult { - await self.load(request, codexSnapshotLoader: { context in - try await self.loadCodexSnapshot(context) - }) + await self.load( + request, + codexSnapshotLoader: { context in + try await self.loadCodexSnapshot(context) + }, + kimiCodeSnapshotLoader: { context in + try await self.loadKimiCodeSnapshot(context) + }) } static func load( _ request: SpendDashboardLoadRequest, - codexSnapshotLoader: CodexSnapshotLoader) async -> SpendDashboardLoadResult + codexSnapshotLoader: CodexSnapshotLoader, + kimiCodeSnapshotLoader: KimiCodeSnapshotLoader = { context in + try await Self.loadKimiCodeSnapshot(context) + }) async -> SpendDashboardLoadResult { var inputs = request.capturedInputs var failedSourceIDs = request.unavailableSourceIDs @@ -299,6 +322,30 @@ enum SpendDashboardSource { failedSourceIDs.insert(sourceID) } } + if let homePath = request.kimiCodeHomePath { + do { + if let snapshot = try await kimiCodeSnapshotLoader(KimiCodeSpendSnapshotLoadContext( + homePath: homePath, + now: request.now, + historyDays: Self.scanDays)) + { + inputs.append(SpendDashboardModel.ProviderInput( + id: "kimi:local", + provider: .kimi, + displayName: "Kimi Code CLI", + modelProviderName: ProviderDescriptorRegistry.descriptor(for: .kimi).metadata.displayName, + snapshot: snapshot)) + } + } catch is CancellationError { + failedSourceIDs.insert("kimi:local") + return SpendDashboardLoadResult( + inputs: [], + failedSourceIDs: failedSourceIDs, + invalidatedSourceIDs: invalidatedSourceIDs) + } catch { + failedSourceIDs.insert("kimi:local") + } + } let lateInvalidatedSourceIDs = Set(request.codexRequests.compactMap { account in self.currentAuthFingerprint(for: account) == account.authFingerprint ? nil @@ -327,6 +374,20 @@ enum SpendDashboardSource { includePiSessions: context.includePiSessions) } + private static func loadKimiCodeSnapshot( + _ context: KimiCodeSpendSnapshotLoadContext) async throws -> CostUsageTokenSnapshot? + { + try await Task.detached(priority: .utility) { + try Task.checkCancellation() + let snapshot = KimiCodeSessionScanner.scan( + environment: [KimiSettingsReader.codeHomeEnvironmentKey: context.homePath], + historyDays: context.historyDays, + now: context.now) + try Task.checkCancellation() + return snapshot + }.value + } + @MainActor static func costCapableProviders(store: UsageStore) -> [UsageProvider] { store.enabledProvidersForDisplay().filter { @@ -334,6 +395,11 @@ enum SpendDashboardSource { } } + @MainActor + static func localModelHistoryProviders(store: UsageStore) -> [UsageProvider] { + store.enabledProvidersForDisplay().filter { $0 == .kimi } + } + @MainActor static func codexRequests(settings: SettingsStore, store: UsageStore) -> [CodexSpendScanRequest] { let accounts = settings.codexVisibleAccountProjection.visibleAccounts @@ -1044,6 +1110,6 @@ final class SpendDashboardController { } private static func normalizedDays(_ value: Int) -> Int { - value == 7 ? 7 : 30 + [7, 30, 365].contains(value) ? value : 30 } } diff --git a/Sources/CodexBar/SpendDashboardModel.swift b/Sources/CodexBar/SpendDashboardModel.swift index c3ed207f96..7574858c1b 100644 --- a/Sources/CodexBar/SpendDashboardModel.swift +++ b/Sources/CodexBar/SpendDashboardModel.swift @@ -66,10 +66,77 @@ struct SpendDashboardModel: Equatable, Sendable { case incomplete } + enum ModelMetricCoverage: Equatable, Sendable { + case complete + case partial + case unavailable + } + + struct ModelSourceContribution: Identifiable, Equatable, Sendable { + let sourceID: String + let provider: UsageProvider + let sourceName: String + let providerName: String + let rawModelNames: [String] + let totalTokens: Int? + let estimatedCost: Double? + + var id: String { + "\(self.sourceID):\(self.rawModelNames.joined(separator: "\u{0}"))" + } + } + + struct ModelAnalysisRow: Identifiable, Equatable, Sendable { + let id: String + let displayName: String + let rawModelNames: [String] + let providers: [UsageProvider] + let providerNames: [String] + let contributions: [ModelSourceContribution] + let totalTokens: Int? + let inputTokens: Int? + let outputTokens: Int? + let estimatedCost: Double? + } + + struct ModelDailyValue: Identifiable, Equatable, Sendable { + let modelID: String + let modelName: String + let day: Date + let totalTokens: Int? + let inputTokens: Int? + let outputTokens: Int? + let estimatedCost: Double? + + var id: String { + "\(self.modelID):\(Int(self.day.timeIntervalSince1970))" + } + } + + struct ModelAnalysis: Equatable, Sendable { + let rows: [ModelAnalysisRow] + let dailyValues: [ModelDailyValue] + let trackedTokenTotal: Int? + let pricedCostTotal: Double? + let sourceCount: Int + let tokenCoverage: ModelMetricCoverage + let costCoverage: ModelMetricCoverage + + static let empty = Self( + rows: [], + dailyValues: [], + trackedTokenTotal: nil, + pricedCostTotal: nil, + sourceCount: 0, + tokenCoverage: .unavailable, + costCoverage: .unavailable) + } + struct CurrencyGroup: Identifiable, Equatable, Sendable { let currencyCode: String let providers: [ProviderRow] let models: [ModelRow] + var modelAnalysis: ModelAnalysis = .empty let dailyPoints: [DailyPoint] let totalTokens: Int? let totalCost: Double? @@ -84,6 +151,58 @@ struct SpendDashboardModel: Equatable, Sendable { let requestedDays: Int let groups: [CurrencyGroup] + private let globalModelAnalysis: ModelAnalysis? + private let globalModelChartDomain: ClosedRange? + private let globalModelRanges: [Int: ModelRange] + + private struct ModelRange: Equatable, Sendable { + let analysis: ModelAnalysis + let chartDomain: ClosedRange + } + + var modelAnalysis: ModelAnalysis { + self.globalModelAnalysis ?? self.groups.first?.modelAnalysis ?? .empty + } + + var modelChartDomain: ClosedRange? { + self.globalModelChartDomain ?? self.groups.first?.chartDomain + } + + func modelAnalysis(for requestedDays: Int) -> ModelAnalysis { + self.globalModelRanges[Self.normalizedModelDays(requestedDays)]?.analysis ?? self.modelAnalysis + } + + func modelChartDomain(for requestedDays: Int) -> ClosedRange? { + self.globalModelRanges[Self.normalizedModelDays(requestedDays)]?.chartDomain ?? self.modelChartDomain + } + + init( + requestedDays: Int, + groups: [CurrencyGroup], + globalModelAnalysis: ModelAnalysis? = nil, + globalModelChartDomain: ClosedRange? = nil) + { + self.init( + requestedDays: requestedDays, + groups: groups, + globalModelAnalysis: globalModelAnalysis, + globalModelChartDomain: globalModelChartDomain, + globalModelRanges: [:]) + } + + private init( + requestedDays: Int, + groups: [CurrencyGroup], + globalModelAnalysis: ModelAnalysis?, + globalModelChartDomain: ClosedRange?, + globalModelRanges: [Int: ModelRange]) + { + self.requestedDays = requestedDays + self.groups = groups + self.globalModelAnalysis = globalModelAnalysis + self.globalModelChartDomain = globalModelChartDomain + self.globalModelRanges = globalModelRanges + } static func build( inputs: [ProviderInput], @@ -91,8 +210,27 @@ struct SpendDashboardModel: Equatable, Sendable { now: Date, calendar: Calendar = .current) -> Self { - let days = max(1, min(30, requestedDays)) + let days = max(1, min(365, requestedDays)) let calculationCalendar = Self.gregorianCalendar(timeZone: calendar.timeZone) + let bounds = Self.bounds(days: days, now: now, calendar: calculationCalendar) + let globalSummaries = inputs.map { + Self.inputSummary(input: $0, bounds: bounds, calendar: calculationCalendar) + } + let globalModelAnalysis = Self.modelAnalysis(summaries: globalSummaries) + let globalModelRanges = Dictionary(uniqueKeysWithValues: Set([7, 30, 365, days]).map { rangeDays in + let rangeBounds = Self.bounds(days: rangeDays, now: now, calendar: calculationCalendar) + let summaries = inputs.map { + Self.inputSummary(input: $0, bounds: rangeBounds, calendar: calculationCalendar) + } + let analysis = Self.modelAnalysis(summaries: summaries) + let chartDomain = rangeDays == 365 + ? Self.allModelChartDomain( + analysis: analysis, + bounds: rangeBounds, + calendar: calculationCalendar) + : Self.chartDomain(bounds: rangeBounds, calendar: calculationCalendar) + return (rangeDays, ModelRange(analysis: analysis, chartDomain: chartDomain)) + }) let classifiedInputs = inputs.compactMap { input -> (currencyCode: String, input: ProviderInput)? in guard let currencyCode = Self.currencyCode(input.snapshot.currencyCode) else { return nil } return (currencyCode, input) @@ -107,7 +245,25 @@ struct SpendDashboardModel: Equatable, Sendable { calendar: calculationCalendar) } .sorted { $0.currencyCode < $1.currencyCode } - return Self(requestedDays: days, groups: groups) + return Self( + requestedDays: days, + groups: groups, + globalModelAnalysis: globalModelAnalysis, + globalModelChartDomain: days == 365 + ? Self.allModelChartDomain( + analysis: globalModelAnalysis, + bounds: bounds, + calendar: calculationCalendar) + : Self.chartDomain(bounds: bounds, calendar: calculationCalendar), + globalModelRanges: globalModelRanges) + } + + private static func normalizedModelDays(_ requestedDays: Int) -> Int { + switch requestedDays { + case 7: 7 + case 30: 30 + default: 365 + } } private struct InputSummary { @@ -147,6 +303,58 @@ struct SpendDashboardModel: Equatable, Sendable { let completeness: ModelHistoryCompleteness } + private struct ModelAnalysisAccumulator { + var rawNames: Set = [] + var displayNames: Set = [] + var providerNames: [UsageProvider: String] = [:] + var sourceContributions: [String: ModelAnalysisSourceAccumulator] = [:] + var tokens: Int? = 0 + var inputTokens: Int? = 0 + var outputTokens: Int? = 0 + var cost: Double? = 0 + var sawTokens = false + var sawTokenSplit = false + var sawCost = false + var invalidTokenSplit = false + var overflowedTokens = false + var overflowedInputTokens = false + var overflowedOutputTokens = false + var overflowedCost = false + } + + private struct ModelAnalysisSourceAccumulator { + let provider: UsageProvider + let sourceName: String + let providerName: String + var rawNames: Set = [] + var tokens: Int? = 0 + var cost: Double? = 0 + var sawTokens = false + var sawCost = false + var overflowedTokens = false + var overflowedCost = false + } + + private struct ModelAnalysisDailyKey: Hashable { + let modelID: String + let day: Date + } + + private struct ModelAnalysisDailyAccumulator { + var tokens: Int? = 0 + var inputTokens: Int? = 0 + var outputTokens: Int? = 0 + var cost: Double? = 0 + var sawTokens = false + var sawTokenSplit = false + var sawCost = false + var invalidTokenSplit = false + var overflowedTokens = false + var overflowedInputTokens = false + var overflowedOutputTokens = false + var overflowedCost = false + } + private struct DailyKey: Hashable { let day: Date let sourceID: String @@ -159,7 +367,9 @@ struct SpendDashboardModel: Equatable, Sendable { var invalid = false var overflowed = false } +} +extension SpendDashboardModel { private static func buildCurrencyGroup( currencyCode: String, inputs: [ProviderInput], @@ -177,6 +387,7 @@ struct SpendDashboardModel: Equatable, Sendable { return Self.modelSummary(summaries: [summary]).completeness == .complete } let modelSummary = Self.modelSummary(summaries: completeModelSummaries) + let modelAnalysis = Self.modelAnalysis(summaries: summaries) let modelHistoryCompleteness = completeModelSummaries.count == summaries.count ? ModelHistoryCompleteness.complete : ModelHistoryCompleteness.incomplete @@ -185,6 +396,7 @@ struct SpendDashboardModel: Equatable, Sendable { currencyCode: currencyCode, providers: providers, models: modelSummary.rows, + modelAnalysis: modelAnalysis, dailyPoints: dailyPoints, totalTokens: Self.completeIntSum(providers.map(\.totalTokens)), totalCost: Self.completeCostSum(providers.map(\.totalCost)), @@ -351,6 +563,259 @@ struct SpendDashboardModel: Equatable, Sendable { return ModelSummary(rows: rows, completeness: completeness) } + private static func modelAnalysis(summaries: [InputSummary]) -> ModelAnalysis { + var models: [String: ModelAnalysisAccumulator] = [:] + var daily: [ModelAnalysisDailyKey: ModelAnalysisDailyAccumulator] = [:] + var tokenCoverageIsPartial = false + var costCoverageIsPartial = false + + for summary in summaries { + let input = summary.input + tokenCoverageIsPartial = tokenCoverageIsPartial || summary.totalTokens == nil + costCoverageIsPartial = costCoverageIsPartial || summary.totalCost == nil + for windowEntry in summary.entries { + let entry = windowEntry.entry + let tokenBreakdownIsComplete = Self.hasCompleteModelTokenCoverage(entry) + let costBreakdownIsComplete = Self.hasCompleteModelCostCoverage(entry) + tokenCoverageIsPartial = tokenCoverageIsPartial || !tokenBreakdownIsComplete + costCoverageIsPartial = costCoverageIsPartial || !costBreakdownIsComplete + + for breakdown in entry.modelBreakdowns ?? [] { + let rawName = breakdown.modelName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !rawName.isEmpty else { continue } + let modelIdentity = Self.modelIdentity(rawName: rawName, provider: input.provider) + let identity = modelIdentity.id + var aggregate = models[identity] ?? ModelAnalysisAccumulator() + aggregate.rawNames.insert(rawName) + aggregate.displayNames.insert(modelIdentity.displayName) + aggregate.providerNames[input.provider] = input.modelProviderName + var source = aggregate.sourceContributions[input.id] ?? ModelAnalysisSourceAccumulator( + provider: input.provider, + sourceName: input.displayName, + providerName: input.modelProviderName) + source.rawNames.insert(rawName) + + let dailyKey = ModelAnalysisDailyKey(modelID: identity, day: windowEntry.day) + var dailyValue = daily[dailyKey] ?? ModelAnalysisDailyAccumulator() + if Self.addModelTokenBreakdown( + breakdown, + isComplete: tokenBreakdownIsComplete, + aggregate: &aggregate, + source: &source, + dailyValue: &dailyValue) + { + daily[dailyKey] = dailyValue + } + + if costBreakdownIsComplete, let cost = Self.validCost(breakdown.costUSD) { + aggregate.sawCost = true + aggregate.cost = Self.add(cost, to: aggregate.cost, overflowed: &aggregate.overflowedCost) + source.sawCost = true + source.cost = Self.add(cost, to: source.cost, overflowed: &source.overflowedCost) + let key = ModelAnalysisDailyKey(modelID: identity, day: windowEntry.day) + var value = daily[key] ?? ModelAnalysisDailyAccumulator() + value.sawCost = true + value.cost = Self.add(cost, to: value.cost, overflowed: &value.overflowedCost) + daily[key] = value + } + + aggregate.sourceContributions[input.id] = source + models[identity] = aggregate + } + } + } + + let rows = models.compactMap { identity, aggregate -> ModelAnalysisRow? in + let totalTokens = aggregate.sawTokens && !aggregate.overflowedTokens ? aggregate.tokens : nil + let hasCompleteTokenSplit = aggregate.sawTokenSplit + && !aggregate.invalidTokenSplit + && !aggregate.overflowedInputTokens + && !aggregate.overflowedOutputTokens + let inputTokens = hasCompleteTokenSplit ? aggregate.inputTokens : nil + let outputTokens = hasCompleteTokenSplit ? aggregate.outputTokens : nil + let estimatedCost = aggregate.sawCost && !aggregate.overflowedCost ? aggregate.cost : nil + guard totalTokens != nil || estimatedCost != nil else { return nil } + let rawNames = aggregate.rawNames.sorted(by: Self.modelNameOrder) + let displayNames = aggregate.displayNames.sorted(by: Self.modelNameOrder) + let contributions = aggregate.sourceContributions.map { sourceID, source in + ModelSourceContribution( + sourceID: sourceID, + provider: source.provider, + sourceName: source.sourceName, + providerName: source.providerName, + rawModelNames: source.rawNames.sorted(by: Self.modelNameOrder), + totalTokens: source.sawTokens && !source.overflowedTokens ? source.tokens : nil, + estimatedCost: source.sawCost && !source.overflowedCost ? source.cost : nil) + } + .sorted { lhs, rhs in + if lhs.providerName != rhs.providerName { return lhs.providerName < rhs.providerName } + if lhs.sourceName != rhs.sourceName { return lhs.sourceName < rhs.sourceName } + return lhs.sourceID < rhs.sourceID + } + let providers = aggregate.providerNames.keys.sorted { lhs, rhs in + let left = aggregate.providerNames[lhs] ?? lhs.rawValue + let right = aggregate.providerNames[rhs] ?? rhs.rawValue + if left != right { return left < right } + return lhs.rawValue < rhs.rawValue + } + return ModelAnalysisRow( + id: identity, + displayName: displayNames.first ?? rawNames.first ?? identity, + rawModelNames: rawNames, + providers: providers, + providerNames: providers.map { aggregate.providerNames[$0] ?? $0.rawValue }, + contributions: contributions, + totalTokens: totalTokens, + inputTokens: inputTokens, + outputTokens: outputTokens, + estimatedCost: estimatedCost) + } + .sorted(by: Self.modelAnalysisRowOrder) + + let namesByID: [String: String] = Dictionary(uniqueKeysWithValues: rows.map { ($0.id, $0.displayName) }) + let dailyValues = daily.compactMap { key, value -> ModelDailyValue? in + let tokens = value.sawTokens && !value.overflowedTokens ? value.tokens : nil + let hasCompleteTokenSplit = value.sawTokenSplit + && !value.invalidTokenSplit + && !value.overflowedInputTokens + && !value.overflowedOutputTokens + let inputTokens = hasCompleteTokenSplit ? value.inputTokens : nil + let outputTokens = hasCompleteTokenSplit ? value.outputTokens : nil + let cost = value.sawCost && !value.overflowedCost ? value.cost : nil + guard tokens != nil || cost != nil, let name = namesByID[key.modelID] else { return nil } + return ModelDailyValue( + modelID: key.modelID, + modelName: name, + day: key.day, + totalTokens: tokens, + inputTokens: inputTokens, + outputTokens: outputTokens, + estimatedCost: cost) + } + .sorted { lhs, rhs in + if lhs.day != rhs.day { return lhs.day < rhs.day } + return lhs.modelID < rhs.modelID + } + + let trackedTokenTotal = Self.safeIntSum(rows.compactMap(\.totalTokens)) + let pricedCostTotal = Self.safeCostSum(rows.compactMap(\.estimatedCost)) + return ModelAnalysis( + rows: rows, + dailyValues: dailyValues, + trackedTokenTotal: trackedTokenTotal, + pricedCostTotal: pricedCostTotal, + sourceCount: Set(rows.flatMap(\.contributions).map(\.sourceID)).count, + tokenCoverage: Self.modelMetricCoverage( + hasValue: trackedTokenTotal != nil, + isPartial: tokenCoverageIsPartial), + costCoverage: Self.modelMetricCoverage(hasValue: pricedCostTotal != nil, isPartial: costCoverageIsPartial)) + } + + private static func addModelTokenBreakdown( + _ breakdown: CostUsageDailyReport.ModelBreakdown, + isComplete: Bool, + aggregate: inout ModelAnalysisAccumulator, + source: inout ModelAnalysisSourceAccumulator, + dailyValue: inout ModelAnalysisDailyAccumulator) -> Bool + { + guard isComplete, let tokens = nonnegative(breakdown.totalTokens) else { + aggregate.invalidTokenSplit = true + return false + } + + aggregate.sawTokens = true + aggregate.tokens = Self.add(tokens, to: aggregate.tokens, overflowed: &aggregate.overflowedTokens) + source.sawTokens = true + source.tokens = Self.add(tokens, to: source.tokens, overflowed: &source.overflowedTokens) + + dailyValue.sawTokens = true + dailyValue.tokens = Self.add( + tokens, + to: dailyValue.tokens, + overflowed: &dailyValue.overflowedTokens) + if let split = Self.modelTokenSplit(breakdown) { + aggregate.sawTokenSplit = true + aggregate.inputTokens = Self.add( + split.input, + to: aggregate.inputTokens, + overflowed: &aggregate.overflowedInputTokens) + aggregate.outputTokens = Self.add( + split.output, + to: aggregate.outputTokens, + overflowed: &aggregate.overflowedOutputTokens) + dailyValue.sawTokenSplit = true + dailyValue.inputTokens = Self.add( + split.input, + to: dailyValue.inputTokens, + overflowed: &dailyValue.overflowedInputTokens) + dailyValue.outputTokens = Self.add( + split.output, + to: dailyValue.outputTokens, + overflowed: &dailyValue.overflowedOutputTokens) + } else { + aggregate.invalidTokenSplit = true + dailyValue.invalidTokenSplit = true + } + return true + } + + private static func modelMetricCoverage(hasValue: Bool, isPartial: Bool) -> ModelMetricCoverage { + guard hasValue else { return .unavailable } + return isPartial ? .partial : .complete + } + + private static func modelTokenSplit( + _ breakdown: CostUsageDailyReport.ModelBreakdown) -> (input: Int, output: Int)? + { + guard self.nonnegative(breakdown.inputTokens) != nil, + breakdown.cacheReadTokens.map({ $0 >= 0 }) ?? true, + breakdown.cacheCreationTokens.map({ $0 >= 0 }) ?? true, + let total = nonnegative(breakdown.totalTokens), + let output = nonnegative(breakdown.outputTokens), + output <= total + else { + return nil + } + return (total - output, output) + } + + private static func modelNameOrder(_ lhs: String, _ rhs: String) -> Bool { + if lhs.count != rhs.count { return lhs.count < rhs.count } + let comparison = lhs.localizedCaseInsensitiveCompare(rhs) + if comparison != .orderedSame { return comparison == .orderedAscending } + return lhs < rhs + } + + private static func modelIdentity(rawName: String, provider _: UsageProvider) -> (id: String, displayName: String) { + let normalizedName = rawName.lowercased().hasPrefix("kimi-code/") + ? String(rawName.dropFirst("kimi-code/".count)) + : rawName + let displayName = switch normalizedName.lowercased() { + case "k3", "kimi-k3": "Kimi K3" + case "k2.5", "kimi-k2.5": "Kimi K2.5" + case "k2", "kimi-k2": "Kimi K2" + case "kimi-for-coding": "Kimi for Coding" + case "kimi-for-coding-highspeed": "Kimi for Coding High-Speed" + default: normalizedName + } + return (displayName.lowercased(), displayName) + } + + private static func modelAnalysisRowOrder(_ lhs: ModelAnalysisRow, _ rhs: ModelAnalysisRow) -> Bool { + switch (lhs.totalTokens, rhs.totalTokens) { + case let (left?, right?) where left != right: left > right + case (_?, nil): true + case (nil, _?): false + default: + switch (lhs.estimatedCost, rhs.estimatedCost) { + case let (left?, right?) where left != right: left > right + case (_?, nil): true + case (nil, _?): false + default: self.modelNameOrder(lhs.displayName, rhs.displayName) + } + } + } + private static func hasProvenZeroCost(_ entry: CostUsageDailyReport.Entry) -> Bool { self.validCost(entry.costUSD) == 0 && (entry.modelBreakdowns?.allSatisfy(self.hasProvenZeroCost) ?? true) @@ -533,6 +998,16 @@ struct SpendDashboardModel: Equatable, Sendable { return bounds.lowerBound...end } + private static func allModelChartDomain( + analysis: ModelAnalysis, + bounds: ClosedRange, + calendar: Calendar) -> ClosedRange + { + let start = analysis.dailyValues.map(\.day).min() ?? bounds.lowerBound + let end = calendar.date(byAdding: .day, value: 1, to: bounds.upperBound) ?? bounds.upperBound + return min(start, bounds.upperBound)...end + } + private static func coverageInterval( input: ProviderInput, bounds: ClosedRange, diff --git a/Sources/CodexBarCore/CostUsageModels.swift b/Sources/CodexBarCore/CostUsageModels.swift index d5b7849b0b..2078fc24ba 100644 --- a/Sources/CodexBarCore/CostUsageModels.swift +++ b/Sources/CodexBarCore/CostUsageModels.swift @@ -277,6 +277,10 @@ public struct CostUsageDailyReport: Sendable, Decodable { public let modelName: String public let costUSD: Double? public let totalTokens: Int? + public let inputTokens: Int? + public let cacheReadTokens: Int? + public let cacheCreationTokens: Int? + public let outputTokens: Int? public let requestCount: Int? public let standardCostUSD: Double? public let priorityCostUSD: Double? @@ -288,6 +292,12 @@ public struct CostUsageDailyReport: Sendable, Decodable { case costUSD case cost case totalTokens + case inputTokens + case cacheReadTokens + case cacheCreationTokens + case cacheReadInputTokens + case cacheCreationInputTokens + case outputTokens case requestCount case requests case standardCostUSD @@ -303,6 +313,14 @@ public struct CostUsageDailyReport: Sendable, Decodable { try container.decodeIfPresent(Double.self, forKey: .costUSD) ?? container.decodeIfPresent(Double.self, forKey: .cost) self.totalTokens = try container.decodeIfPresent(Int.self, forKey: .totalTokens) + self.inputTokens = try container.decodeIfPresent(Int.self, forKey: .inputTokens) + self.cacheReadTokens = + try container.decodeIfPresent(Int.self, forKey: .cacheReadTokens) + ?? container.decodeIfPresent(Int.self, forKey: .cacheReadInputTokens) + self.cacheCreationTokens = + try container.decodeIfPresent(Int.self, forKey: .cacheCreationTokens) + ?? container.decodeIfPresent(Int.self, forKey: .cacheCreationInputTokens) + self.outputTokens = try container.decodeIfPresent(Int.self, forKey: .outputTokens) self.requestCount = try container.decodeIfPresent(Int.self, forKey: .requestCount) ?? container.decodeIfPresent(Int.self, forKey: .requests) @@ -316,6 +334,10 @@ public struct CostUsageDailyReport: Sendable, Decodable { modelName: String, costUSD: Double?, totalTokens: Int? = nil, + inputTokens: Int? = nil, + cacheReadTokens: Int? = nil, + cacheCreationTokens: Int? = nil, + outputTokens: Int? = nil, requestCount: Int? = nil, standardCostUSD: Double? = nil, priorityCostUSD: Double? = nil, @@ -325,6 +347,10 @@ public struct CostUsageDailyReport: Sendable, Decodable { self.modelName = modelName self.costUSD = costUSD self.totalTokens = totalTokens + self.inputTokens = inputTokens + self.cacheReadTokens = cacheReadTokens + self.cacheCreationTokens = cacheCreationTokens + self.outputTokens = outputTokens self.requestCount = requestCount self.standardCostUSD = standardCostUSD self.priorityCostUSD = priorityCostUSD @@ -530,6 +556,18 @@ extension CostUsageDailyReport { private struct BreakdownAccumulator { var totalTokens: Int = 0 var sawTotalTokens = false + var inputTokens: Int = 0 + var sawInputTokens = false + var missingInputTokens = false + var cacheReadTokens: Int = 0 + var sawCacheReadTokens = false + var missingCacheReadTokens = false + var cacheCreationTokens: Int = 0 + var sawCacheCreationTokens = false + var missingCacheCreationTokens = false + var outputTokens: Int = 0 + var sawOutputTokens = false + var missingOutputTokens = false var costUSD: Double = 0 var sawCost = false var standardCostUSD: Double = 0 @@ -546,6 +584,30 @@ extension CostUsageDailyReport { self.totalTokens += totalTokens self.sawTotalTokens = true } + if let inputTokens = breakdown.inputTokens { + self.inputTokens += inputTokens + self.sawInputTokens = true + } else { + self.missingInputTokens = true + } + if let cacheReadTokens = breakdown.cacheReadTokens { + self.cacheReadTokens += cacheReadTokens + self.sawCacheReadTokens = true + } else { + self.missingCacheReadTokens = true + } + if let cacheCreationTokens = breakdown.cacheCreationTokens { + self.cacheCreationTokens += cacheCreationTokens + self.sawCacheCreationTokens = true + } else { + self.missingCacheCreationTokens = true + } + if let outputTokens = breakdown.outputTokens { + self.outputTokens += outputTokens + self.sawOutputTokens = true + } else { + self.missingOutputTokens = true + } if let costUSD = breakdown.costUSD { self.costUSD += costUSD self.sawCost = true @@ -573,6 +635,12 @@ extension CostUsageDailyReport { modelName: modelName, costUSD: self.sawCost ? self.costUSD : nil, totalTokens: self.sawTotalTokens ? self.totalTokens : nil, + inputTokens: self.sawInputTokens && !self.missingInputTokens ? self.inputTokens : nil, + cacheReadTokens: self.sawCacheReadTokens && !self.missingCacheReadTokens ? self.cacheReadTokens : nil, + cacheCreationTokens: self.sawCacheCreationTokens && !self.missingCacheCreationTokens + ? self.cacheCreationTokens + : nil, + outputTokens: self.sawOutputTokens && !self.missingOutputTokens ? self.outputTokens : nil, standardCostUSD: self.sawStandardCost ? self.standardCostUSD : nil, priorityCostUSD: self.sawPriorityCost ? self.priorityCostUSD : nil, standardTokens: self.sawStandardTokens ? self.standardTokens : nil, diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index d46bf4b5b2..80749f652e 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 = "48ac20dad61e9a7f" + static let value = "0adc0e9717db47a8" } diff --git a/Sources/CodexBarCore/PiSessionCostScanner.swift b/Sources/CodexBarCore/PiSessionCostScanner.swift index 159eed0a52..5ad32b9d02 100644 --- a/Sources/CodexBarCore/PiSessionCostScanner.swift +++ b/Sources/CodexBarCore/PiSessionCostScanner.swift @@ -911,7 +911,11 @@ extension PiSessionCostScanner { breakdown.append(CostUsageDailyReport.ModelBreakdown( modelName: modelName, costUSD: costNanos.map { Double($0) / Self.costScale }, - totalTokens: modelTotalTokens > 0 ? modelTotalTokens : nil)) + totalTokens: modelTotalTokens > 0 ? modelTotalTokens : nil, + inputTokens: packed.inputTokens, + cacheReadTokens: packed.cacheReadTokens, + cacheCreationTokens: packed.cacheWriteTokens, + outputTokens: packed.outputTokens)) dayInput += packed.inputTokens dayOutput += packed.outputTokens dayCacheRead += packed.cacheReadTokens diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiCodeSessionScanner.swift b/Sources/CodexBarCore/Providers/Kimi/KimiCodeSessionScanner.swift new file mode 100644 index 0000000000..6cdc1a322a --- /dev/null +++ b/Sources/CodexBarCore/Providers/Kimi/KimiCodeSessionScanner.swift @@ -0,0 +1,211 @@ +import Foundation + +public enum KimiCodeSessionScanner { + public static let defaultHistoryDays = 30 + + private struct WireEvent: Decodable { + struct Usage: Decodable { + let inputOther: Int? + let inputCacheRead: Int? + let inputCacheCreation: Int? + let output: Int? + } + + let type: String + let time: Double? + let model: String? + let usage: Usage? + let usageScope: String? + } + + private struct DayModelKey: Hashable { + let day: String + let model: String + } + + private struct TokenAccumulator { + var input = 0 + var cacheRead = 0 + var cacheCreation = 0 + var output = 0 + var requests = 0 + + mutating func add(_ usage: WireEvent.Usage) -> Bool { + guard let input = Self.valid(usage.inputOther), + let cacheRead = Self.valid(usage.inputCacheRead), + let cacheCreation = Self.valid(usage.inputCacheCreation), + let output = Self.valid(usage.output), + let nextInput = Self.adding(self.input, input), + let nextCacheRead = Self.adding(self.cacheRead, cacheRead), + let nextCacheCreation = Self.adding(self.cacheCreation, cacheCreation), + let nextOutput = Self.adding(self.output, output), + let nextRequests = Self.adding(self.requests, 1) + else { + return false + } + self.input = nextInput + self.cacheRead = nextCacheRead + self.cacheCreation = nextCacheCreation + self.output = nextOutput + self.requests = nextRequests + return true + } + + mutating func merge(_ other: TokenAccumulator) -> Bool { + guard let nextInput = Self.adding(self.input, other.input), + let nextCacheRead = Self.adding(self.cacheRead, other.cacheRead), + let nextCacheCreation = Self.adding(self.cacheCreation, other.cacheCreation), + let nextOutput = Self.adding(self.output, other.output), + let nextRequests = Self.adding(self.requests, other.requests) + else { + return false + } + self.input = nextInput + self.cacheRead = nextCacheRead + self.cacheCreation = nextCacheCreation + self.output = nextOutput + self.requests = nextRequests + return true + } + + var total: Int? { + guard let inputAndCacheRead = Self.adding(self.input, self.cacheRead), + let withCacheCreation = Self.adding(inputAndCacheRead, self.cacheCreation) + else { + return nil + } + return Self.adding(withCacheCreation, self.output) + } + + private static func valid(_ value: Int?) -> Int? { + guard let value, value >= 0 else { return nil } + return value + } + + private static func adding(_ lhs: Int, _ rhs: Int) -> Int? { + let result = lhs.addingReportingOverflow(rhs) + return result.overflow ? nil : result.partialValue + } + } + + public static func scan( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default, + historyDays: Int = defaultHistoryDays, + now: Date = Date(), + calendar: Calendar = .current) -> CostUsageTokenSnapshot? + { + let days = max(1, historyDays) + let home = KimiSettingsReader.kimiCodeHomeURL(environment: environment) + let sessions = home.appendingPathComponent("sessions", isDirectory: true) + guard let enumerator = fileManager.enumerator( + at: sessions, + includingPropertiesForKeys: [.isRegularFileKey, .contentModificationDateKey], + options: [.skipsHiddenFiles]) + else { + return nil + } + + let end = calendar.startOfDay(for: now) + let start = calendar.date(byAdding: .day, value: -(days - 1), to: end) ?? end + var values: [DayModelKey: TokenAccumulator] = [:] + let decoder = JSONDecoder() + + while let url = enumerator.nextObject() as? URL { + guard url.lastPathComponent == "wire.jsonl", + url.deletingLastPathComponent().deletingLastPathComponent().lastPathComponent == "agents" + else { + continue + } + if let modificationDate = try? url.resourceValues(forKeys: [.contentModificationDateKey]) + .contentModificationDate, + modificationDate < start + { + continue + } + guard let data = try? Data(contentsOf: url) else { continue } + for line in data.split(separator: 0x0A, omittingEmptySubsequences: true) { + guard let event = try? decoder.decode(WireEvent.self, from: Data(line)), + event.type == "usage.record", + event.usageScope == "turn", + let time = event.time, + time.isFinite, + let rawModel = event.model?.trimmingCharacters(in: .whitespacesAndNewlines), + !rawModel.isEmpty, + let usage = event.usage + else { + continue + } + let date = Date(timeIntervalSince1970: time / 1000) + let day = calendar.startOfDay(for: date) + guard day >= start, day <= end else { continue } + let key = DayModelKey(day: CostUsageLocalDay.key(from: day, calendar: calendar), model: rawModel) + var value = values[key] ?? TokenAccumulator() + guard value.add(usage) else { continue } + values[key] = value + } + } + + guard !values.isEmpty else { return nil } + let byDay = Dictionary(grouping: values, by: \.key.day) + let daily = byDay.keys.sorted().compactMap { day -> CostUsageDailyReport.Entry? in + let models = (byDay[day] ?? []).sorted { lhs, rhs in + lhs.key.model.localizedCaseInsensitiveCompare(rhs.key.model) == .orderedAscending + } + var total = TokenAccumulator() + var modelBreakdowns: [CostUsageDailyReport.ModelBreakdown] = [] + for (key, value) in models { + guard let modelTotal = value.total else { return nil } + guard total.merge(value) else { return nil } + modelBreakdowns.append(CostUsageDailyReport.ModelBreakdown( + modelName: key.model, + costUSD: nil, + totalTokens: modelTotal, + inputTokens: value.input, + cacheReadTokens: value.cacheRead, + cacheCreationTokens: value.cacheCreation, + outputTokens: value.output, + requestCount: value.requests)) + } + guard let totalTokens = total.total else { return nil } + return CostUsageDailyReport.Entry( + date: day, + inputTokens: total.input, + outputTokens: total.output, + cacheReadTokens: total.cacheRead, + cacheCreationTokens: total.cacheCreation, + totalTokens: totalTokens, + requestCount: total.requests, + costUSD: nil, + modelsUsed: modelBreakdowns.map(\.modelName), + modelBreakdowns: modelBreakdowns) + } + let totalTokens = self.sum(daily.compactMap(\.totalTokens)) + let totalRequests = self.sum(daily.compactMap(\.requestCount)) + guard let totalTokens, let totalRequests else { return nil } + + return CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + sessionRequests: nil, + last30DaysTokens: totalTokens, + last30DaysCostUSD: nil, + last30DaysRequests: totalRequests, + currencyCode: "XXX", + historyDays: days, + historyCoverageIsEstablished: true, + historyLabel: "Kimi Code CLI", + daily: daily, + updatedAt: now) + } + + private static func sum(_ values: [Int]) -> Int? { + var result = 0 + for value in values { + let addition = result.addingReportingOverflow(value) + guard !addition.overflow else { return nil } + result = addition.partialValue + } + return result + } +} diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiSettingsReader.swift b/Sources/CodexBarCore/Providers/Kimi/KimiSettingsReader.swift index e4c676f16e..24014545a9 100644 --- a/Sources/CodexBarCore/Providers/Kimi/KimiSettingsReader.swift +++ b/Sources/CodexBarCore/Providers/Kimi/KimiSettingsReader.swift @@ -120,7 +120,9 @@ public enum KimiSettingsReader { return deviceID } - private static func kimiCodeHomeURL(environment: [String: String]) -> URL { + public static func kimiCodeHomeURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { if let override = self.cleaned(environment[self.codeHomeEnvironmentKey]) { return URL(fileURLWithPath: override, isDirectory: true) } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift index 8bdbeec25d..1cf784ff34 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift @@ -1448,6 +1448,9 @@ extension CostUsageScanner { modelName: model, costUSD: cost, totalTokens: totalTokens, + inputTokens: input, + cacheReadTokens: cached, + outputTokens: output, standardCostUSD: hasModeSplit ? standardCost : nil, priorityCostUSD: hasModeSplit ? priorityCost : nil, standardTokens: hasModeSplit ? cachedStandardTokens : nil, diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index 2f61dabada..8d57a87c8f 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -816,7 +816,11 @@ extension CostUsageScanner { CostUsageDailyReport.ModelBreakdown( modelName: model, costUSD: cost, - totalTokens: totalTokens)) + totalTokens: totalTokens, + inputTokens: input, + cacheReadTokens: cacheRead, + cacheCreationTokens: cacheCreate, + outputTokens: output)) if let cost { dayCost += cost dayCostSeen = true diff --git a/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift b/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift index e7a42293c9..23a34fc44d 100644 --- a/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift +++ b/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift @@ -21,6 +21,9 @@ struct CostUsageDailyReportMergeTests { modelName: "gpt-5.4", costUSD: 1.25, totalTokens: 130, + inputTokens: 100, + cacheReadTokens: 10, + outputTokens: 20, standardCostUSD: 0.75, priorityCostUSD: 0.50, standardTokens: 80, @@ -50,6 +53,10 @@ struct CostUsageDailyReportMergeTests { modelName: "gpt-5.4", costUSD: 0.75, totalTokens: 67, + inputTokens: 50, + cacheReadTokens: 5, + cacheCreationTokens: 2, + outputTokens: 10, standardCostUSD: 0.25, priorityCostUSD: 0.50, standardTokens: 20, @@ -77,6 +84,9 @@ struct CostUsageDailyReportMergeTests { modelName: "gpt-5.4", costUSD: 2.0, totalTokens: 197, + inputTokens: 150, + cacheReadTokens: 15, + outputTokens: 30, standardCostUSD: 1.0, priorityCostUSD: 1.0, standardTokens: 100, diff --git a/Tests/CodexBarTests/CostUsageFetcherTests.swift b/Tests/CodexBarTests/CostUsageFetcherTests.swift index 5db8108693..c8a878f073 100644 --- a/Tests/CodexBarTests/CostUsageFetcherTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherTests.swift @@ -705,7 +705,11 @@ extension CostUsageFetcherTests { CostUsageDailyReport.ModelBreakdown( modelName: "claude-sonnet-4-6", costUSD: nativeCost + piCost, - totalTokens: 205), + totalTokens: 205, + inputTokens: 150, + cacheReadTokens: 9, + cacheCreationTokens: 16, + outputTokens: 30), ]) } diff --git a/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift b/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift index dd40efcbf7..c33a31764d 100644 --- a/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift @@ -198,7 +198,10 @@ struct CostUsageScannerBreakdownTests { CostUsageDailyReport.ModelBreakdown( modelName: "gpt-5.2-codex", costUSD: first.data[0].costUSD, - totalTokens: 110), + totalTokens: 110, + inputTokens: 100, + cacheReadTokens: 20, + outputTokens: 10), ]) #expect(first.data[0].totalTokens == 110) #expect((first.data[0].costUSD ?? 0) > 0) @@ -6415,7 +6418,11 @@ struct CostUsageScannerBreakdownTests { CostUsageDailyReport.ModelBreakdown( modelName: "claude-sonnet-4-20250514", costUSD: report.data[0].costUSD, - totalTokens: 355), + totalTokens: 355, + inputTokens: 200, + cacheReadTokens: 25, + cacheCreationTokens: 50, + outputTokens: 80), ]) #expect((report.data[0].costUSD ?? 0) > 0) } diff --git a/Tests/CodexBarTests/KimiCodeSessionScannerTests.swift b/Tests/CodexBarTests/KimiCodeSessionScannerTests.swift new file mode 100644 index 0000000000..4fa04b4aea --- /dev/null +++ b/Tests/CodexBarTests/KimiCodeSessionScannerTests.swift @@ -0,0 +1,123 @@ +import CodexBarCore +import Foundation +import Testing + +struct KimiCodeSessionScannerTests { + @Test + func `scanner aggregates turn usage across main and subagents without reading other events`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let main = root + .appendingPathComponent("sessions/workspace/session-a/agents/main", isDirectory: true) + let child = root + .appendingPathComponent("sessions/workspace/session-a/agents/agent-0", isDirectory: true) + try FileManager.default.createDirectory(at: main, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: child, withIntermediateDirectories: true) + + let now = Date(timeIntervalSince1970: 1_784_347_200) + try Self.write([ + Self.usage(time: 1_784_257_200_000, model: "kimi-code/k3", input: 10, cacheRead: 20, output: 3), + #"{"type":"assistant.message","time":1784257200000,"content":"must not be parsed"}"#, + Self.usage( + time: 1_784_257_300_000, + model: "kimi-code/k3", + input: 4, + cacheRead: 5, + cacheCreation: 6, + output: 7), + Self.usage( + time: 1_784_257_400_000, + model: "kimi-code/k3", + scope: "session", + input: 999, + cacheRead: 999, + output: 999), + ], to: main.appendingPathComponent("wire.jsonl")) + try Self.write([ + Self.usage( + time: 1_784_343_600_000, + model: "kimi-code/kimi-for-coding", + input: 8, + cacheRead: 9, + output: 10), + ], to: child.appendingPathComponent("wire.jsonl")) + + let snapshot = try #require(KimiCodeSessionScanner.scan( + environment: [KimiSettingsReader.codeHomeEnvironmentKey: root.path], + historyDays: 30, + now: now, + calendar: Self.calendar)) + + #expect(snapshot.currencyCode == "XXX") + #expect(snapshot.last30DaysTokens == 82) + #expect(snapshot.last30DaysRequests == 3) + #expect(snapshot.last30DaysCostUSD == nil) + #expect(snapshot.daily.count == 2) + #expect(snapshot.daily.flatMap { $0.modelBreakdowns ?? [] }.map(\.modelName) == [ + "kimi-code/k3", + "kimi-code/kimi-for-coding", + ]) + #expect(snapshot.daily.flatMap { $0.modelBreakdowns ?? [] }.map(\.totalTokens) == [55, 27]) + #expect(snapshot.daily.flatMap { $0.modelBreakdowns ?? [] }.map(\.inputTokens) == [14, 8]) + #expect(snapshot.daily.flatMap { $0.modelBreakdowns ?? [] }.map(\.cacheReadTokens) == [25, 9]) + #expect(snapshot.daily.flatMap { $0.modelBreakdowns ?? [] }.map(\.cacheCreationTokens) == [6, 0]) + #expect(snapshot.daily.flatMap { $0.modelBreakdowns ?? [] }.map(\.outputTokens) == [10, 10]) + } + + @Test + func `scanner ignores malformed negative and out of range records`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let agent = root + .appendingPathComponent("sessions/workspace/session-a/agents/main", isDirectory: true) + try FileManager.default.createDirectory(at: agent, withIntermediateDirectories: true) + try Self.write([ + Self.usage( + time: 1_784_257_200_000, + model: "kimi-code/k3", + input: -1, + cacheRead: 2, + output: 3), + Self.usage( + time: 1_770_000_000_000, + model: "kimi-code/k3", + input: 10, + cacheRead: 20, + output: 30), + #"{"type":"usage.record","time":"bad","model":"kimi-code/k3","usageScope":"turn","usage":{}}"#, + ], to: agent.appendingPathComponent("wire.jsonl")) + + #expect(KimiCodeSessionScanner.scan( + environment: [KimiSettingsReader.codeHomeEnvironmentKey: root.path], + historyDays: 30, + now: Date(timeIntervalSince1970: 1_784_347_200), + calendar: Self.calendar) == nil) + } + + private static let calendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + }() + + private static func usage( + time: Int64, + model: String, + scope: String = "turn", + input: Int, + cacheRead: Int, + cacheCreation: Int = 0, + output: Int) -> String + { + """ + {"type":"usage.record","time":\(time),"model":"\(model)","usageScope":"\(scope)","usage":{"inputOther":\( + input),"inputCacheRead":\(cacheRead),"inputCacheCreation":\(cacheCreation),"output":\(output)}} + """ + } + + private static func write(_ lines: [String], to url: URL) throws { + try (lines.joined(separator: "\n") + "\n").write(to: url, atomically: true, encoding: .utf8) + } +} diff --git a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift index 3d423cede9..e06e5f038a 100644 --- a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift +++ b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift @@ -513,11 +513,21 @@ struct LocalizationLanguageCatalogTests { "Gemini Flash", "GitHub", "Google OAuth", + "Model names are grouped after trimming and case-insensitive exact matching. " + + "Sources are not deduplicated across providers.", "No", + "No priced model history", "Oasis-Token", + "Other", + "Partial model history: incomplete source-days are excluded.", "Password", + "Priced model spend", "Provider", + "Show all %d models", + "Show top 20", "Token", + "Tokens", + "Tracked model tokens", "%@ %@", "%@: %@", "byte_unit_byte", diff --git a/Tests/CodexBarTests/ShareStatsTests.swift b/Tests/CodexBarTests/ShareStatsTests.swift index adb0a24651..6db809b020 100644 --- a/Tests/CodexBarTests/ShareStatsTests.swift +++ b/Tests/CodexBarTests/ShareStatsTests.swift @@ -152,6 +152,7 @@ struct ShareStatsTests { coveredDayCount: 7), ], models: rows, + modelAnalysis: .empty, dailyPoints: [], totalTokens: 1, totalCost: nil, @@ -207,6 +208,7 @@ struct ShareStatsTests { totalTokens: nil, totalCost: nil), ], + modelAnalysis: .empty, dailyPoints: [], totalTokens: 10, totalCost: -.infinity, @@ -367,6 +369,7 @@ struct ShareStatsTests { totalTokens: 1000, totalCost: 1), ], + modelAnalysis: .empty, dailyPoints: [], totalTokens: 300, totalCost: 12, @@ -402,6 +405,7 @@ struct ShareStatsTests { totalTokens: 200, totalCost: 4) }, + modelAnalysis: .empty, dailyPoints: [], totalTokens: nil, totalCost: nil, diff --git a/Tests/CodexBarTests/SpendDashboardControllerTests.swift b/Tests/CodexBarTests/SpendDashboardControllerTests.swift index 17e398ce42..a8d451e79d 100644 --- a/Tests/CodexBarTests/SpendDashboardControllerTests.swift +++ b/Tests/CodexBarTests/SpendDashboardControllerTests.swift @@ -47,7 +47,7 @@ struct SpendDashboardControllerTests { #expect(contexts.first?.cacheRoot.lastPathComponent == "inactive-cache") #expect(contexts.first?.now == now) #expect(contexts.first?.force == false) - #expect(contexts.first?.historyDays == 30) + #expect(contexts.first?.historyDays == 365) #expect(contexts.first?.refreshPricingInBackground == false) #expect(contexts.first?.includePiSessions == false) } @@ -764,6 +764,9 @@ struct SpendDashboardControllerTests { controller.selectDays(7) #expect(controller.selectedDays == 7) #expect(defaults.integer(forKey: "settingsSpendDashboardDays") == 7) + controller.selectDays(365) + #expect(controller.selectedDays == 365) + #expect(defaults.integer(forKey: "settingsSpendDashboardDays") == 365) controller.selectDays(9) #expect(controller.selectedDays == 30) } diff --git a/Tests/CodexBarTests/SpendDashboardKimiModelTests.swift b/Tests/CodexBarTests/SpendDashboardKimiModelTests.swift new file mode 100644 index 0000000000..9046c81069 --- /dev/null +++ b/Tests/CodexBarTests/SpendDashboardKimiModelTests.swift @@ -0,0 +1,121 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct SpendDashboardKimiModelTests { + @Test + func `token-only Kimi history joins global models without creating a currency group`() { + let priced = SpendDashboardModel.ProviderInput( + provider: .codex, + displayName: "Codex", + snapshot: Self.snapshot(currency: "USD", cost: 2, tokens: 10, model: "gpt-test")) + let kimi = SpendDashboardModel.ProviderInput( + id: "kimi:local", + provider: .kimi, + displayName: "Kimi Code CLI", + modelProviderName: "Kimi", + snapshot: Self.snapshot(currency: "XXX", cost: nil, tokens: 90, model: "kimi-code/k3")) + let model = SpendDashboardModel.build( + inputs: [priced, kimi], + requestedDays: 30, + now: Self.now, + calendar: Self.calendar) + + #expect(model.groups.map(\.currencyCode) == ["USD"]) + #expect(model.modelAnalysis.rows.map(\.displayName) == ["Kimi K3", "gpt-test"]) + #expect(model.modelAnalysis.rows.map(\.totalTokens) == [90, 10]) + #expect(model.modelAnalysis.rows.first?.rawModelNames == ["kimi-code/k3"]) + #expect(model.modelAnalysis.rows.first?.providerNames == ["Kimi"]) + #expect(model.modelAnalysis.trackedTokenTotal == 100) + #expect(model.modelAnalysis.tokenCoverage == .complete) + } + + @Test + func `Kimi aliases use product names while preserving raw identifiers`() { + let kimi = SpendDashboardModel.ProviderInput( + id: "kimi:local", + provider: .kimi, + displayName: "Kimi Code CLI", + modelProviderName: "Kimi", + snapshot: Self.multiModelSnapshot(models: [ + ("kimi-code/k3", 50), + ("kimi-k2.5", 40), + ("kimi-code/kimi-for-coding", 30), + ("kimi-code/kimi-for-coding-highspeed", 20), + ])) + let model = SpendDashboardModel.build( + inputs: [kimi], + requestedDays: 30, + now: Self.now, + calendar: Self.calendar) + + #expect(model.modelAnalysis.rows.map(\.displayName) == [ + "Kimi K3", + "Kimi K2.5", + "Kimi for Coding", + "Kimi for Coding High-Speed", + ]) + #expect(model.modelAnalysis.rows.map(\.rawModelNames) == [ + ["kimi-code/k3"], + ["kimi-k2.5"], + ["kimi-code/kimi-for-coding"], + ["kimi-code/kimi-for-coding-highspeed"], + ]) + } + + private static func snapshot( + currency: String, + cost: Double?, + tokens: Int, + model: String) -> CostUsageTokenSnapshot + { + let entry = CostUsageDailyReport.Entry( + date: "2026-07-16", + inputTokens: nil, + outputTokens: nil, + totalTokens: tokens, + costUSD: cost, + modelsUsed: nil, + modelBreakdowns: [.init(modelName: model, costUSD: cost, totalTokens: tokens)]) + return CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: nil, + last30DaysCostUSD: nil, + currencyCode: currency, + historyDays: 30, + daily: [entry], + updatedAt: self.now) + } + + private static func multiModelSnapshot(models: [(name: String, tokens: Int)]) -> CostUsageTokenSnapshot { + let totalTokens = models.map(\.tokens).reduce(0, +) + let entry = CostUsageDailyReport.Entry( + date: "2026-07-16", + inputTokens: nil, + outputTokens: nil, + totalTokens: totalTokens, + costUSD: nil, + modelsUsed: nil, + modelBreakdowns: models.map { + .init(modelName: $0.name, costUSD: nil, totalTokens: $0.tokens) + }) + return CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: nil, + last30DaysCostUSD: nil, + currencyCode: "XXX", + historyDays: 30, + daily: [entry], + updatedAt: self.now) + } + + private static let now = Date(timeIntervalSince1970: 1_784_179_200) + private static var calendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + } +} diff --git a/Tests/CodexBarTests/SpendDashboardModelTests.swift b/Tests/CodexBarTests/SpendDashboardModelTests.swift index 9223e633ca..30b7887213 100644 --- a/Tests/CodexBarTests/SpendDashboardModelTests.swift +++ b/Tests/CodexBarTests/SpendDashboardModelTests.swift @@ -19,6 +19,7 @@ struct SpendDashboardModelTests { #expect(codexBarLocalizedInteger(12) == "۱۲") #expect(spendDashboardDayRangeText(7) == "۷ روز") #expect(spendDashboardDayRangeText(30) == "۳۰ روز") + #expect(spendDashboardDayRangeText(365) == "همه") #expect(spendDashboardRankText(1234) == "#۱٬۲۳۴") #expect(spendDashboardRefreshFailureText(2) == "\(L("Refresh failures")): ۲") #expect(spendDashboardCoverageText(covered: 3, requested: 30) == "پوشش: ۳ / ۳۰") @@ -180,6 +181,62 @@ struct SpendDashboardModelTests { #expect(thirtyDays.chartDomain == thirtyDayStart...end) } + @Test + func `all model chart starts at earliest available model day`() throws { + let earlier = try Self.calendar.startOfDay(for: #require( + Self.calendar.date(byAdding: .day, value: -74, to: Self.now))) + let input = SpendDashboardModel.ProviderInput( + provider: .claude, + displayName: "Claude", + snapshot: Self.snapshot( + currency: "USD", + entries: [ + Self.entry(day: "2026-05-03", cost: 1, model: "early"), + Self.entry(day: "2026-07-16", cost: 1, model: "current"), + ], + historyDays: 365)) + let model = SpendDashboardModel.build( + inputs: [input], + requestedDays: 365, + now: Self.now, + calendar: Self.calendar) + let domain = try #require(model.modelChartDomain) + let today = Self.calendar.startOfDay(for: Self.now) + let end = try #require(Self.calendar.date(byAdding: .day, value: 1, to: today)) + + #expect(domain == earlier...end) + #expect(model.modelAnalysis.rows.map(\.displayName) == ["early", "current"]) + } + + @Test + func `model range stays independent from overview range`() throws { + let earlier = try Self.calendar.startOfDay(for: #require( + Self.calendar.date(byAdding: .day, value: -74, to: Self.now))) + let input = SpendDashboardModel.ProviderInput( + provider: .claude, + displayName: "Claude", + snapshot: Self.snapshot( + currency: "USD", + entries: [ + Self.entry(day: "2026-05-03", cost: 1, model: "early"), + Self.entry(day: "2026-07-16", cost: 1, model: "current"), + ], + historyDays: 365)) + let model = SpendDashboardModel.build( + inputs: [input], + requestedDays: 30, + now: Self.now, + calendar: Self.calendar) + let allDomain = try #require(model.modelChartDomain(for: 365)) + let today = Self.calendar.startOfDay(for: Self.now) + let end = try #require(Self.calendar.date(byAdding: .day, value: 1, to: today)) + + #expect(model.groups.first?.modelAnalysis.rows.map(\.displayName) == ["current"]) + #expect(model.modelAnalysis(for: 30).rows.map(\.displayName) == ["current"]) + #expect(model.modelAnalysis(for: 365).rows.map(\.displayName) == ["early", "current"]) + #expect(allDomain == earlier...end) + } + @Test func `currency coverage intersects disjoint provider windows`() throws { let earlier = try SpendDashboardModel.ProviderInput( @@ -319,6 +376,107 @@ struct SpendDashboardModelTests { #expect(usd.models.map(\.totalCost) == [4]) } + @Test + func `model analysis merges exact normalized names without changing overview rows`() throws { + let first = SpendDashboardModel.ProviderInput( + id: "claude-source", + provider: .claude, + displayName: "Claude", + snapshot: Self.snapshot(currency: "USD", entries: [ + Self.entry(day: "2026-07-16", cost: 2, tokens: 20, model: " Model-A "), + ])) + let second = SpendDashboardModel.ProviderInput( + id: "codex-source", + provider: .codex, + displayName: "Codex", + snapshot: Self.snapshot(currency: "USD", entries: [ + Self.entry(day: "2026-07-16", cost: 3, tokens: 30, model: "model-a"), + ])) + let group = try #require(SpendDashboardModel.build( + inputs: [first, second], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + let row = try #require(group.modelAnalysis.rows.first) + + #expect(group.models.count == 2) + #expect(group.modelAnalysis.rows.count == 1) + #expect(row.id == "model-a") + #expect(row.rawModelNames == ["Model-A", "model-a"]) + #expect(row.totalTokens == 50) + #expect(row.estimatedCost == 5) + #expect(row.contributions.map(\.sourceID) == ["claude-source", "codex-source"]) + #expect(group.modelAnalysis.dailyValues.map(\.totalTokens) == [50]) + #expect(group.modelAnalysis.dailyValues.map(\.estimatedCost) == [5]) + #expect(group.modelAnalysis.tokenCoverage == .complete) + #expect(group.modelAnalysis.costCoverage == .complete) + } + + @Test + func `model analysis excludes incomplete source days and labels partial coverage`() throws { + let snapshot = Self.snapshot(currency: "USD", entries: [ + Self.entry(day: "2026-07-16", cost: 4, tokens: 40, model: "model-a"), + Self.entryWithBreakdowns( + day: "2026-07-15", + totalCost: 5, + totalTokens: 50, + breakdowns: [.init(modelName: "model-a", costUSD: 3, totalTokens: 30)]), + ]) + let group = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + let row = try #require(group.modelAnalysis.rows.first) + + #expect(group.models.isEmpty) + #expect(row.totalTokens == 40) + #expect(row.estimatedCost == 4) + #expect(group.modelAnalysis.tokenCoverage == .partial) + #expect(group.modelAnalysis.costCoverage == .partial) + #expect(group.modelAnalysis.dailyValues.count == 1) + } + + @Test + func `model analysis preserves complete token splits and leaves total-only models unchanged`() throws { + let snapshot = Self.snapshot(currency: "USD", entries: [ + Self.entryWithBreakdowns( + day: "2026-07-16", + totalCost: 0, + totalTokens: 196, + breakdowns: [ + .init( + modelName: "split-model", + costUSD: 0, + totalTokens: 100, + inputTokens: 60, + cacheReadTokens: 20, + outputTokens: 20), + .init( + modelName: "total-only-model", + costUSD: 0, + totalTokens: 96), + ]), + ]) + let analysis = SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).modelAnalysis + let split = try #require(analysis.rows.first(where: { $0.id == "split-model" })) + let totalOnly = try #require(analysis.rows.first(where: { $0.id == "total-only-model" })) + let daily = try #require(analysis.dailyValues.first(where: { $0.modelID == "split-model" })) + + #expect(split.totalTokens == 100) + #expect(split.inputTokens == 80) + #expect(split.outputTokens == 20) + #expect(totalOnly.totalTokens == 96) + #expect(totalOnly.inputTokens == nil) + #expect(totalOnly.outputTokens == nil) + #expect(daily.inputTokens == 80) + #expect(daily.outputTokens == 20) + } + @Test func `ISO history stays Gregorian while preserving the injected timezone`() throws { let timeZone = try #require(TimeZone(secondsFromGMT: 7 * 60 * 60)) @@ -418,6 +576,10 @@ struct SpendDashboardModelTests { #expect(group.modelHistoryCompleteness == .incomplete) #expect(group.models.isEmpty) #expect(group.dailyPoints.isEmpty) + #expect(group.modelAnalysis.rows.first?.totalTokens == 40) + #expect(group.modelAnalysis.rows.first?.estimatedCost == 4) + #expect(group.modelAnalysis.tokenCoverage == .partial) + #expect(group.modelAnalysis.costCoverage == .partial) } @Test @@ -595,7 +757,9 @@ struct SpendDashboardModelTests { #expect(group.modelHistoryCompleteness == .incomplete) #expect(group.models.isEmpty) } +} +extension SpendDashboardModelTests { @Test func `blank model names fail closed unless their usage is explicitly zero`() throws { let incomplete = Self.snapshot(currency: "USD", entries: [Self.entryWithBreakdowns( @@ -753,7 +917,7 @@ struct SpendDashboardModelTests { #expect(!request.authFileWasReadable) #expect(request.displayName == "Codex · #2") #expect(request.cacheIdentity.count == 64) - #expect(SpendDashboardSource.scanDays == 30) + #expect(SpendDashboardSource.scanDays == 365) #expect(SpendDashboardSource.codexRequest( account: account, homePath: "relative/path", diff --git a/Tests/CodexBarTests/SpendDashboardSourceConcurrencyTests.swift b/Tests/CodexBarTests/SpendDashboardSourceConcurrencyTests.swift index fda8fb8878..127754b80b 100644 --- a/Tests/CodexBarTests/SpendDashboardSourceConcurrencyTests.swift +++ b/Tests/CodexBarTests/SpendDashboardSourceConcurrencyTests.swift @@ -5,6 +5,57 @@ import Testing @MainActor struct SpendDashboardSourceConcurrencyTests { + @Test + func `local Kimi history is appended without entering the cost provider pipeline`() async throws { + let now = Date(timeIntervalSince1970: 1_784_179_200) + let entry = CostUsageDailyReport.Entry( + date: "2026-07-16", + inputTokens: 4, + outputTokens: 2, + totalTokens: 6, + requestCount: 1, + costUSD: nil, + modelsUsed: ["kimi-code/k3"], + modelBreakdowns: [.init(modelName: "kimi-code/k3", costUSD: nil, totalTokens: 6)]) + let snapshot = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: 6, + last30DaysCostUSD: nil, + currencyCode: "XXX", + daily: [entry], + updatedAt: now) + let request = SpendDashboardLoadRequest( + configuration: SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.kimi.rawValue], + codexAccountIdentities: []), + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [], + kimiCodeHomePath: "/synthetic/kimi-code", + now: now, + force: false) + + let result = await SpendDashboardSource.load( + request, + codexSnapshotLoader: { _ in + Issue.record("Codex loader should not run") + return snapshot + }, + kimiCodeSnapshotLoader: { context in + #expect(context.homePath == "/synthetic/kimi-code") + return snapshot + }) + + let input = try #require(result.inputs.first) + #expect(input.id == "kimi:local") + #expect(input.provider == .kimi) + #expect(input.displayName == "Kimi Code CLI") + #expect(input.snapshot.currencyCode == "XXX") + #expect(result.failedSourceIDs.isEmpty) + } + @Test func `Codex batch revalidates completed and failed accounts after later scans`() async throws { let root = FileManager.default.temporaryDirectory diff --git a/Tests/CodexBarTests/SpendModelsPresentationTests.swift b/Tests/CodexBarTests/SpendModelsPresentationTests.swift new file mode 100644 index 0000000000..99ac53cede --- /dev/null +++ b/Tests/CodexBarTests/SpendModelsPresentationTests.swift @@ -0,0 +1,190 @@ +import Foundation +import Testing +@testable import CodexBar + +struct SpendModelsPresentationTests { + @Test + func `model card range labels stay English and preserve compact order`() { + #expect([7, 30, 365].map(spendModelsDayRangeText) == ["7d", "30d", "All"]) + } + + @Test + func `model card date labels stay English`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let day = try #require(calendar.date(from: DateComponents(year: 2026, month: 5, day: 2))) + + #expect(SpendModelsEnglishFormatter.dayText(day) == "May 2") + } + + @Test + func `All axis keeps endpoints without crowded trailing labels`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let start = try #require(calendar.date(from: DateComponents(year: 2026, month: 5, day: 2))) + let last = try #require(calendar.date(byAdding: .day, value: 78, to: start)) + let domainEnd = try #require(calendar.date(byAdding: .day, value: 79, to: start)) + + let dates = SpendModelsAxisDates.make( + selectedDays: 365, + dataDays: [start, last], + domain: start...domainEnd, + calendar: calendar) + + #expect(dates.count == 6) + #expect(dates.first == start) + #expect(dates.last == last) + for pair in zip(dates, dates.dropFirst()) { + let gap = calendar.dateComponents([.day], from: pair.0, to: pair.1).day + #expect((gap ?? 0) >= 8) + } + } + + @Test + func `All axis replaces a near-duplicate trailing tick with the latest day`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let start = try #require(calendar.date(from: DateComponents(year: 2026, month: 1, day: 1))) + let last = try #require(calendar.date(byAdding: .day, value: 57, to: start)) + let domainEnd = try #require(calendar.date(byAdding: .day, value: 58, to: start)) + + let dates = SpendModelsAxisDates.make( + selectedDays: 365, + dataDays: [start, last], + domain: start...domainEnd, + calendar: calendar) + + #expect(dates.count == 5) + #expect(dates.last == last) + let trailingGap = try #require(calendar.dateComponents( + [.day], + from: dates[dates.count - 2], + to: dates[dates.count - 1]).day) + #expect(trailingGap >= 8) + } + + @Test + func `every model remains a named chart series`() { + let analysis = SpendDashboardModel.ModelAnalysis( + rows: (1...6).map { index in + Self.row(id: "model-\(index)", tokens: index * 10, cost: Double(index)) + }, + dailyValues: [ + .init( + modelID: "model-6", + modelName: "model-6", + day: Self.day, + totalTokens: 60, + inputTokens: 50, + outputTokens: 10, + estimatedCost: 6), + .init( + modelID: "model-1", + modelName: "model-1", + day: Self.day, + totalTokens: 10, + inputTokens: 8, + outputTokens: 2, + estimatedCost: 1), + ], + trackedTokenTotal: 210, + pricedCostTotal: 21, + sourceCount: 1, + tokenCoverage: .complete, + costCoverage: .complete) + let presentation = SpendModelsPresentation(analysis: analysis, metric: .tokens) + + #expect(presentation.rows.map(\.source.id) == [ + "model-6", + "model-5", + "model-4", + "model-3", + "model-2", + "model-1", + ]) + #expect(presentation.series.map(\.id) == [ + "model-6", + "model-5", + "model-4", + "model-3", + "model-2", + "model-1", + ]) + #expect(presentation.series.last?.name == "model-1") + #expect(presentation.series.last?.value == 10) + #expect(presentation.points.map(\.seriesID) == ["model-6", "model-1"]) + #expect(presentation.points.map(\.stackStart) == [0, 60]) + #expect(presentation.points.map(\.stackEnd) == [60, 70]) + } + + @Test + func `token rows show in and out only when the split is complete`() { + let complete = SpendModelsPresentation.Row( + source: Self.row( + id: "complete", + tokens: 100, + inputTokens: 80, + outputTokens: 20, + cost: nil, + providers: ["Codex"]), + rank: 1, + value: 100, + share: 1) + let totalOnly = SpendModelsPresentation.Row( + source: Self.row( + id: "total", + tokens: 96, + cost: nil, + providers: ["Kimi"]), + rank: 2, + value: 96, + share: 1) + + #expect(spendModelsRowDetailText(complete) == "80 in · 20 out · Codex") + #expect(spendModelsRowDetailText(totalOnly) == "96 · Kimi") + } + + @Test + func `spend metric ranks priced rows before rows with no price`() { + let analysis = SpendDashboardModel.ModelAnalysis( + rows: [ + Self.row(id: "unpriced", tokens: 1000, cost: nil), + Self.row(id: "priced", tokens: 10, cost: 2), + ], + dailyValues: [], + trackedTokenTotal: 1010, + pricedCostTotal: 2, + sourceCount: 1, + tokenCoverage: .complete, + costCoverage: .partial) + let presentation = SpendModelsPresentation(analysis: analysis, metric: .estimatedSpend) + + #expect(presentation.rows.map(\.source.id) == ["priced", "unpriced"]) + #expect(presentation.rows.first?.share == 1) + #expect(presentation.rows.last?.share == nil) + #expect(presentation.coverage == .partial) + } + + private static func row( + id: String, + tokens: Int?, + inputTokens: Int? = nil, + outputTokens: Int? = nil, + cost: Double?, + providers: [String] = []) -> SpendDashboardModel.ModelAnalysisRow + { + SpendDashboardModel.ModelAnalysisRow( + id: id, + displayName: id, + rawModelNames: [id], + providers: [], + providerNames: providers, + contributions: [], + totalTokens: tokens, + inputTokens: inputTokens, + outputTokens: outputTokens, + estimatedCost: cost) + } + + private static let day = Date(timeIntervalSince1970: 1_784_179_200) +} diff --git a/Tests/CodexBarTests/UserFacingLocalizationCoverageTests.swift b/Tests/CodexBarTests/UserFacingLocalizationCoverageTests.swift index e4c9e3e37f..bc8ac3f4df 100644 --- a/Tests/CodexBarTests/UserFacingLocalizationCoverageTests.swift +++ b/Tests/CodexBarTests/UserFacingLocalizationCoverageTests.swift @@ -116,17 +116,21 @@ struct UserFacingLocalizationCoverageTests { } @Test - func `spend dashboard model breakdown state stays precise and localized`() throws { + func `spend dashboard embedded model card is fully localized`() throws { let root = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() .deletingLastPathComponent() .deletingLastPathComponent() let source = try String( - contentsOf: root.appendingPathComponent("Sources/CodexBar/PreferencesSpendDashboardPane.swift"), + contentsOf: root.appendingPathComponent("Sources/CodexBar/PreferencesSpendModelsView.swift"), encoding: .utf8) - #expect(source.contains(#"Text(L("Model breakdown unavailable"))"#)) + #expect(source.contains(#"Text(L("Models"))"#)) #expect(source.contains(#"Text(L("No model-level history"))"#)) + #expect(source.contains(#"Text(L("Partial model history: incomplete source-days are excluded."))"#)) + #expect(!source.contains(#"Text("Models")"#)) + #expect(!source.contains(#"Text("No model-level history")"#)) + #expect(!source.contains(#"Text("Partial model history: incomplete source-days are excluded.")"#)) } @Test From 208945060f01453e5ab4af2b033d9d67f0c77203 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sun, 26 Jul 2026 10:33:15 +0800 Subject: [PATCH 02/14] =?UTF-8?q?feat:=20elevate=20usage=20&=20spend=20das?= =?UTF-8?q?hboard=20=E2=80=94=20cost=20coverage,=20tool=20scanners,=20acti?= =?UTF-8?q?vity=20heatmap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spend accuracy: - Expand Codex pricing fingerprint to all third-party providers routed through the Codex/Claude-compatible endpoints (deepseek, minimax, moonshotai, kimi-for-coding) so stale caches invalidate when models.dev pricing lands — fixes "Spend unavailable". - Daily-sum cost fallback: when a provider's aggregate cost figure diverges from the local per-day logs, sum per-day costs instead of voiding the whole provider. New tool coverage (by-tool / by-model): - OpenCode: read opencode.db (SQLite) in addition to JSONL messages, extracting model/tokens/cost via json_extract; cross-source dedup by id + fingerprint. - Add MiniMax, Gemini session scanners and provider registration. Token activity heatmap: - GitHub/ChatGPT-style 52-week grid with hover tooltip (date + token count). - Cache SpendActivitySeries; binary-search nearest day in models chart hover/click. Refactor & quality: - Extract LocalSnapshotSource to dedupe per-provider load blocks; ScanContext to thread shared scan state; split third-party Claude pricing into its own file. - Complete i18n: fill the new dashboard keys across all 22 locales. Co-authored-by: Cursor --- Sources/CodexBar/Localization.swift | 11 + .../PreferencesSpendDashboardPane.swift | 22 +- .../PreferencesSpendModelsDayDetailView.swift | 310 ++++++++++ .../CodexBar/PreferencesSpendModelsView.swift | 481 +++++++++++++-- .../Resources/ar.lproj/Localizable.strings | 46 +- .../Resources/ca.lproj/Localizable.strings | 46 +- .../Resources/de.lproj/Localizable.strings | 46 +- .../Resources/en.lproj/Localizable.strings | 37 ++ .../Resources/es.lproj/Localizable.strings | 46 +- .../Resources/fa.lproj/Localizable.strings | 46 +- .../Resources/fr.lproj/Localizable.strings | 46 +- .../Resources/gl.lproj/Localizable.strings | 46 +- .../Resources/id.lproj/Localizable.strings | 46 +- .../Resources/it.lproj/Localizable.strings | 46 +- .../Resources/ja.lproj/Localizable.strings | 46 +- .../Resources/ko.lproj/Localizable.strings | 46 +- .../Resources/nl.lproj/Localizable.strings | 46 +- .../Resources/pl.lproj/Localizable.strings | 46 +- .../Resources/pt-BR.lproj/Localizable.strings | 46 +- .../Resources/ru.lproj/Localizable.strings | 46 +- .../Resources/sv.lproj/Localizable.strings | 46 +- .../Resources/th.lproj/Localizable.strings | 46 +- .../Resources/tr.lproj/Localizable.strings | 46 +- .../Resources/uk.lproj/Localizable.strings | 46 +- .../Resources/vi.lproj/Localizable.strings | 46 +- .../zh-Hans.lproj/Localizable.strings | 37 ++ .../zh-Hant.lproj/Localizable.strings | 47 +- Sources/CodexBar/SpendActivityHeatmap.swift | 394 ++++++++++++ Sources/CodexBar/SpendClientsView.swift | 231 +++++++ .../CodexBar/SpendDashboardController.swift | 233 ++++++- Sources/CodexBar/SpendDashboardModel.swift | 374 ++++++++++-- Sources/CodexBar/SpendModelIdentity.swift | 211 +++++++ Sources/CodexBarCore/CostUsageFetcher.swift | 8 +- Sources/CodexBarCore/CostUsageModels.swift | 37 ++ .../Generated/CodexParserHash.generated.swift | 2 +- .../Gemini/GeminiSessionScanner.swift | 488 +++++++++++++++ .../MiniMax/MiniMaxSessionScanner.swift | 334 ++++++++++ .../OpenCode/OpenCodeSessionScanner.swift | 568 ++++++++++++++++++ .../CostUsage/CodexSubagentRolloutShape.swift | 4 +- .../Vendored/CostUsage/CostUsageCache.swift | 6 + .../CostUsagePricing+ThirdParty.swift | 72 +++ .../Vendored/CostUsage/CostUsagePricing.swift | 74 ++- .../CostUsage/CostUsagePricingKey.swift | 12 +- .../CostUsageScanner+CacheHelpers.swift | 61 +- .../CostUsage/CostUsageScanner+Claude.swift | 52 +- .../Vendored/CostUsage/CostUsageScanner.swift | 189 ++++-- .../CostUsageSourceFingerprint.swift | 179 ++++++ .../CostUsageDailyReportMergeTests.swift | 55 ++ .../CostUsagePerformanceGateTests.swift | 83 ++- .../CostUsageScannerBreakdownTests.swift | 95 +++ .../GeminiSessionScannerTests.swift | 330 ++++++++++ .../KimiCodeSessionScannerTests.swift | 2 + .../OpenCodeSessionScannerTests.swift | 399 ++++++++++++ .../SpendDashboardControllerTests.swift | 31 +- .../SpendDashboardDateTruthTests.swift | 10 +- .../SpendDashboardModelTests.swift | 347 ++++++++++- ...SpendDashboardSourceConcurrencyTests.swift | 210 +++++++ .../SpendDashboardTokenProvenanceTests.swift | 16 +- .../SpendModelIdentityTests.swift | 101 ++++ .../SpendModelsPresentationTests.swift | 273 ++++++++- 60 files changed, 6992 insertions(+), 354 deletions(-) create mode 100644 Sources/CodexBar/PreferencesSpendModelsDayDetailView.swift create mode 100644 Sources/CodexBar/SpendActivityHeatmap.swift create mode 100644 Sources/CodexBar/SpendClientsView.swift create mode 100644 Sources/CodexBar/SpendModelIdentity.swift create mode 100644 Sources/CodexBarCore/Providers/Gemini/GeminiSessionScanner.swift create mode 100644 Sources/CodexBarCore/Providers/MiniMax/MiniMaxSessionScanner.swift create mode 100644 Sources/CodexBarCore/Providers/OpenCode/OpenCodeSessionScanner.swift create mode 100644 Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+ThirdParty.swift create mode 100644 Sources/CodexBarCore/Vendored/CostUsage/CostUsageSourceFingerprint.swift create mode 100644 Tests/CodexBarTests/GeminiSessionScannerTests.swift create mode 100644 Tests/CodexBarTests/OpenCodeSessionScannerTests.swift create mode 100644 Tests/CodexBarTests/SpendModelIdentityTests.swift diff --git a/Sources/CodexBar/Localization.swift b/Sources/CodexBar/Localization.swift index 38a6918e8b..30a13c6402 100644 --- a/Sources/CodexBar/Localization.swift +++ b/Sources/CodexBar/Localization.swift @@ -213,6 +213,17 @@ func L(_ key: String, language: String) -> String { return codexBarLocalizedString(key, bundle: bundle, resourceBundle: resourceBundle) } +/// Force-English variant of `L`. The token-usage dashboard mixes many inline numeric/metric +/// labels that were laid out for English; rendering them in CJK breaks the row widths, so the +/// whole panel pins to English regardless of system language. +func LEn(_ key: String) -> String { + L(key, language: "en") +} + +func LEn(_ key: String, _ arguments: CVarArg...) -> String { + String(format: LEn(key), arguments: arguments) +} + func codexBarLocalizedLocale() -> Locale { let language = resolvedAppLanguage() guard !language.isEmpty else { return .current } diff --git a/Sources/CodexBar/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift index b8d603efd3..8ee5264084 100644 --- a/Sources/CodexBar/PreferencesSpendDashboardPane.swift +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -8,7 +8,7 @@ func spendDashboardDayRangeText(_ days: Int) -> String { switch days { case 7: template = L("7d") case 30: template = L("30d") - case 365: return L("All") + case 365: return L("Cumulative") default: return codexBarLocalizedInteger(days) } return template.replacingOccurrences( @@ -49,7 +49,9 @@ struct SpendDashboardPane: View { @Bindable var settings: SettingsStore @Bindable var store: UsageStore @State private var controller: SpendDashboardController - @State private var selectedModelDays = 365 + private var selectedModelDays: Int { + self.controller.selectedDays + } init(settings: SettingsStore, store: UsageStore) { self.settings = settings @@ -108,10 +110,11 @@ struct SpendDashboardPane: View { Picker(L("Time range"), selection: self.daysBinding) { Text(spendDashboardDayRangeText(7)).tag(7) Text(spendDashboardDayRangeText(30)).tag(30) + Text(spendDashboardDayRangeText(365)).tag(365) } .labelsHidden() .pickerStyle(.segmented) - .frame(width: 116) + .fixedSize() Button { self.controller.refresh() @@ -158,7 +161,9 @@ struct SpendDashboardPane: View { modelChartDomain: group.id == modelHostGroupID ? self.controller.model.modelChartDomain(for: self.selectedModelDays) : nil, - selectedModelDays: self.$selectedModelDays) + activityAnalysis: group.id == modelHostGroupID + ? self.controller.model.modelAnalysis(for: 365) + : nil) } } @@ -243,7 +248,7 @@ private struct SpendCurrencySection: View { let requestedDays: Int let modelAnalysis: SpendDashboardModel.ModelAnalysis? let modelChartDomain: ClosedRange? - @Binding var selectedModelDays: Int + let activityAnalysis: SpendDashboardModel.ModelAnalysis? var body: some View { VStack(alignment: .leading, spacing: 12) { @@ -288,9 +293,14 @@ private struct SpendCurrencySection: View { SpendModelsSection( analysis: modelAnalysis, chartDomain: self.modelChartDomain, - selectedDays: self.$selectedModelDays) + selectedDays: self.requestedDays) } SpendDailyChart(group: self.group) + if let activityAnalysis { + SpendDashboardPanel { + SpendActivityHeatmapView(analysis: activityAnalysis) + } + } } } } diff --git a/Sources/CodexBar/PreferencesSpendModelsDayDetailView.swift b/Sources/CodexBar/PreferencesSpendModelsDayDetailView.swift new file mode 100644 index 0000000000..404b81a883 --- /dev/null +++ b/Sources/CodexBar/PreferencesSpendModelsDayDetailView.swift @@ -0,0 +1,310 @@ +import CodexBarCore +import SwiftUI + +// MARK: - Token category colors + +extension Color { + /// Token-category colors for the spend-models day detail panel, tokens.ci style. + static let spendModelsInput = Color(red: 0.29, green: 0.56, blue: 0.95) // blue + static let spendModelsOutput = Color(red: 0.20, green: 0.72, blue: 0.51) // green + static let spendModelsCacheRead = Color(red: 0.61, green: 0.47, blue: 0.90) // purple + static let spendModelsCacheWrite = Color(red: 0.93, green: 0.58, blue: 0.25) // orange + static let spendModelsReasoning = Color(red: 0.90, green: 0.42, blue: 0.60) // pink +} + +// MARK: - Day detail presentation + +struct SpendModelsDayDetailPresentation: Equatable { + enum BucketKind: String, CaseIterable { + case input + case output + case cacheRead + case cacheWrite + case reasoning + + var title: String { + switch self { + case .input: L("Input") + case .output: L("Output") + case .cacheRead: L("Cache read") + case .cacheWrite: L("Cache write") + case .reasoning: L("Reasoning") + } + } + + var color: Color { + switch self { + case .input: .spendModelsInput + case .output: .spendModelsOutput + case .cacheRead: .spendModelsCacheRead + case .cacheWrite: .spendModelsCacheWrite + case .reasoning: .spendModelsReasoning + } + } + } + + struct Bucket: Identifiable, Equatable { + let kind: BucketKind + let tokens: Int + + var id: String { + self.kind.rawValue + } + } + + struct Model: Identifiable, Equatable { + let id: String + let name: String + let providerNames: [String] + let totalTokens: Int? + let cost: Double? + let costIsEstimated: Bool + let buckets: [Bucket] + } + + let day: Date + let metric: SpendModelMetric + let totalTokens: Int? + let totalCost: Double? + /// Non-zero bucket totals across all models that day; empty when no bucket data exists. + let buckets: [Bucket] + /// Per-model rows sorted by tokens descending. + let models: [Model] + + init?( + analysis: SpendDashboardModel.ModelAnalysis, + day: Date, + metric: SpendModelMetric, + calendar: Calendar = .current) + { + let dailyValues = analysis.dailyValues.filter { calendar.isDate($0.day, inSameDayAs: day) } + guard !dailyValues.isEmpty else { return nil } + + self.day = day + self.metric = metric + self.totalTokens = Self.sum(dailyValues.map(\.totalTokens)) + self.totalCost = Self.sum(dailyValues.map(\.estimatedCost)) + self.buckets = Self.aggregateBuckets(dailyValues.map(Self.buckets(of:))) + + let rowsByID = Dictionary(uniqueKeysWithValues: analysis.rows.map { ($0.id, $0) }) + self.models = dailyValues.map { value in + let row = rowsByID[value.modelID] + return Model( + id: value.modelID, + name: value.modelName, + providerNames: row?.providerNames ?? [], + totalTokens: value.totalTokens, + cost: value.estimatedCost, + costIsEstimated: row?.costIsEstimated ?? false, + buckets: Self.aggregateBuckets([Self.buckets(of: value)])) + } + .sorted { lhs, rhs in + switch (lhs.totalTokens, rhs.totalTokens) { + case let (left?, right?) where left != right: left > right + case (_?, nil): true + case (nil, _?): false + default: lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending + } + } + } + + private static func buckets( + of value: SpendDashboardModel.ModelDailyValue) -> [(kind: BucketKind, tokens: Int)] + { + [ + (.input, value.inputTokens), + (.output, value.outputTokens), + (.cacheRead, value.cacheReadTokens), + (.cacheWrite, value.cacheCreationTokens), + (.reasoning, value.reasoningTokens), + ].compactMap { kind, tokens in + tokens.map { (kind, $0) } + } + } + + /// Sums buckets across models. A bucket with only nil (unknown) values stays hidden, as do + /// zero totals. + private static func aggregateBuckets(_ bucketLists: [[(kind: BucketKind, tokens: Int)]]) -> [Bucket] { + var sums: [BucketKind: Int] = [:] + for list in bucketLists { + for (kind, tokens) in list { + sums[kind, default: 0] += tokens + } + } + return BucketKind.allCases.compactMap { kind in + guard let total = sums[kind], total > 0 else { return nil } + return Bucket(kind: kind, tokens: total) + } + } + + private static func sum(_ values: [Int?]) -> Int? { + let present = values.compactMap(\.self) + guard !present.isEmpty else { return nil } + return present.reduce(0, +) + } + + private static func sum(_ values: [Double?]) -> Double? { + let present = values.compactMap(\.self) + guard !present.isEmpty else { return nil } + return present.reduce(0, +) + } +} + +// MARK: - Day detail text helpers + +func spendModelsDayDetailBucketText(_ bucket: SpendModelsDayDetailPresentation.Bucket) -> String { + let count = UsageFormatter.tokenCountString(bucket.tokens) + switch bucket.kind { + case .input: return L("%@ in", count) + case .output: return L("%@ out", count) + case .cacheRead: return L("%@ cache read", count) + case .cacheWrite: return L("%@ cache write", count) + case .reasoning: return L("%@ reasoning", count) + } +} + +/// Compact per-model token split ("80 in · 20 out · 15 cache read"); falls back to the bare +/// total when no bucket data exists. +func spendModelsDayDetailModelSplitText(_ model: SpendModelsDayDetailPresentation.Model) -> String { + guard !model.buckets.isEmpty else { + return model.totalTokens.map(UsageFormatter.tokenCountString) ?? "—" + } + return model.buckets.map(spendModelsDayDetailBucketText).joined(separator: " · ") +} + +// MARK: - Day detail view + +struct SpendModelsDayDetailView: View { + let detail: SpendModelsDayDetailPresentation + let metric: SpendModelMetric + let colorForModel: (String) -> Color + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .firstTextBaseline) { + Text(self.dayText) + .font(.body.weight(.semibold)) + Spacer() + Text(self.totalText) + .font(.body.weight(.semibold)) + .monospacedDigit() + } + if !self.detail.buckets.isEmpty { + self.categoryBar + self.legend + } + VStack(alignment: .leading, spacing: 7) { + ForEach(self.detail.models) { model in + self.modelRow(model) + } + } + } + .padding(12) + .background(.quaternary.opacity(0.35), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .accessibilityElement(children: .contain) + .accessibilityLabel(Text(L("Usage details for %@", self.dayText))) + } + + // MARK: Category bar + + /// Thin rounded segmented bar. Reasoning is a subset of output, so the output segment is + /// shrunk by the reasoning share to keep the bar partitioning the day total. + private var categoryBar: some View { + GeometryReader { geo in + let total = max(1, self.barTokenTotal) + HStack(spacing: 1) { + ForEach(self.detail.buckets) { bucket in + Rectangle() + .fill(bucket.kind.color) + .frame(width: max(2, geo.size.width * CGFloat(self.barTokens(for: bucket)) / CGFloat(total))) + } + } + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + } + .frame(height: 12) + .accessibilityHidden(true) + } + + private var legend: some View { + HStack(spacing: 12) { + ForEach(self.detail.buckets) { bucket in + HStack(spacing: 5) { + Circle() + .fill(bucket.kind.color) + .frame(width: 8, height: 8) + .accessibilityHidden(true) + Text("\(bucket.kind.title) \(UsageFormatter.tokenCountString(bucket.tokens))") + .font(.caption) + .foregroundStyle(.secondary) + .monospacedDigit() + .lineLimit(1) + } + } + } + } + + private var barTokenTotal: Int { + self.detail.buckets.reduce(0) { $0 + self.barTokens(for: $1) } + } + + private func barTokens(for bucket: SpendModelsDayDetailPresentation.Bucket) -> Int { + guard bucket.kind == .output, + let reasoning = self.detail.buckets.first(where: { $0.kind == .reasoning })?.tokens + else { + return bucket.tokens + } + return max(0, bucket.tokens - reasoning) + } + + // MARK: Model rows + + private func modelRow(_ model: SpendModelsDayDetailPresentation.Model) -> some View { + HStack(spacing: 9) { + RoundedRectangle(cornerRadius: 2, style: .continuous) + .fill(self.colorForModel(model.id)) + .frame(width: 10, height: 10) + .accessibilityHidden(true) + Text(model.name) + .font(.body) + .lineLimit(1) + if !model.providerNames.isEmpty { + Text(model.providerNames.joined(separator: " · ")) + .font(.caption) + .foregroundStyle(.tertiary) + .lineLimit(1) + } + Spacer() + Text(spendModelsDayDetailModelSplitText(model)) + .font(.body) + .foregroundStyle(.secondary) + .monospacedDigit() + if self.metric == .estimatedSpend { + Text(self.costText(for: model)) + .font(.body) + .foregroundStyle(.secondary) + .monospacedDigit() + } + } + } + + private func costText(for model: SpendModelsDayDetailPresentation.Model) -> String { + guard let cost = model.cost else { return "—" } + let formatted = UsageFormatter.currencyString(cost, currencyCode: "USD") + return model.costIsEstimated ? "\(formatted) · \(L("estimated"))" : formatted + } + + // MARK: Header + + private var dayText: String { + SpendModelsEnglishFormatter.dayText(self.detail.day) + } + + private var totalText: String { + switch self.metric { + case .tokens: self.detail.totalTokens.map(UsageFormatter.tokenCountString) ?? "—" + case .estimatedSpend: self.detail.totalCost.map { + UsageFormatter.currencyString($0, currencyCode: "USD") + } ?? "—" + } + } +} diff --git a/Sources/CodexBar/PreferencesSpendModelsView.swift b/Sources/CodexBar/PreferencesSpendModelsView.swift index 171bf4901e..c29360b7f0 100644 --- a/Sources/CodexBar/PreferencesSpendModelsView.swift +++ b/Sources/CodexBar/PreferencesSpendModelsView.swift @@ -1,3 +1,4 @@ +import AppKit import Charts import CodexBarCore import SwiftUI @@ -12,18 +13,25 @@ enum SpendModelMetric: String, CaseIterable, Identifiable { var title: String { switch self { - case .tokens: "Tokens" - case .estimatedSpend: "Estimated spend" + case .tokens: L("Tokens") + case .estimatedSpend: L("Estimated spend") } } } -func spendModelsDayRangeText(_ days: Int) -> String { - switch days { - case 7: "7d" - case 30: "30d" - case 365: "All" - default: "\(days)d" +enum SpendModelsViewMode: String, CaseIterable, Identifiable { + case models + case clients + + var id: Self { + self + } + + var title: String { + switch self { + case .models: L("By model") + case .clients: L("By tool") + } } } @@ -43,13 +51,32 @@ func spendModelsRowDetailText(_ row: SpendModelsPresentation.Row) -> String { let inputTokens = row.source.inputTokens, let outputTokens = row.source.outputTokens { - "\(UsageFormatter.tokenCountString(inputTokens)) in · \(UsageFormatter.tokenCountString(outputTokens)) out" + L( + "%@ in · %@ out", + UsageFormatter.tokenCountString(inputTokens), + UsageFormatter.tokenCountString(outputTokens)) } else { UsageFormatter.tokenCountString(Int(value.rounded())) } return providers.isEmpty ? metric : "\(metric) · \(providers)" } +enum SpendModelsRanking { + static let collapsedRowLimit = 20 + + static func showsDisclosure(rowCount: Int) -> Bool { + rowCount > self.collapsedRowLimit + } + + static func visibleRows( + _ rows: [SpendModelsPresentation.Row], + showsAll: Bool) -> [SpendModelsPresentation.Row] + { + guard !showsAll, self.showsDisclosure(rowCount: rows.count) else { return rows } + return Array(rows.prefix(self.collapsedRowLimit)) + } +} + struct SpendModelsPresentation: Equatable { struct Row: Identifiable, Equatable { let source: SpendDashboardModel.ModelAnalysisRow @@ -147,6 +174,76 @@ struct SpendModelsPresentation: Equatable { } } + private init( + metric: SpendModelMetric, + rows: [Row], + series: [Series], + points: [Point], + coverage: SpendDashboardModel.ModelMetricCoverage, + metricTotal: Double?) + { + self.metric = metric + self.rows = rows + self.series = series + self.points = points + self.coverage = coverage + self.metricTotal = metricTotal + } + + // MARK: Trailing average + + static let trailingAverageWindow = 7 + + /// Returns a copy whose stacked points are smoothed with a per-series trailing moving average + /// over the visible day window (tokens.ci style). Days at the window edge average over fewer + /// samples. Rows and series stay raw, so ranking and day details are unaffected; this is + /// meant for the chart only. + func applyingTrailingAverage(window: Int = Self.trailingAverageWindow) -> SpendModelsPresentation { + guard window > 1, !self.points.isEmpty else { return self } + + let days = Array(Set(self.points.map(\.day))).sorted() + var rawValues: [String: [Date: Double]] = [:] + for point in self.points { + rawValues[point.seriesID, default: [:]][point.day, default: 0] += point.value + } + + var smoothed: [Point] = [] + for (index, day) in days.enumerated() { + let firstSample = max(0, index - window + 1) + let samples = days[firstSample...index] + var cursor = 0.0 + for series in self.series { + let total = samples.reduce(0.0) { $0 + (rawValues[series.id]?[$1] ?? 0) } + let value = total / Double(samples.count) + guard value > 0 else { continue } + let start = cursor + cursor += value + smoothed.append(Point( + day: day, + seriesID: series.id, + seriesName: series.name, + value: value, + stackStart: start, + stackEnd: cursor)) + } + } + + return SpendModelsPresentation( + metric: self.metric, + rows: self.rows, + series: self.series, + points: smoothed, + coverage: self.coverage, + metricTotal: self.metricTotal) + } + + // MARK: Selection + + /// Returns the charted day matching `day`, or nil when it falls outside the visible range. + func day(matching day: Date, calendar: Calendar = .current) -> Date? { + self.points.map(\.day).first { calendar.isDate($0, inSameDayAs: day) } + } + private static func compare( _ lhs: SpendDashboardModel.ModelAnalysisRow, _ rhs: SpendDashboardModel.ModelAnalysisRow, @@ -285,30 +382,111 @@ struct SpendModelsAxisDates { struct SpendModelsSection: View { let analysis: SpendDashboardModel.ModelAnalysis let chartDomain: ClosedRange? - @Binding var selectedDays: Int + /// Global dashboard range, passed read-only: the single top-level time-range picker drives all + /// sections now, so this block no longer renders its own 7d/30d/All selector. + let selectedDays: Int + @AppStorage("spendModelsMetric") private var selectedMetric: SpendModelMetric = .tokens + @AppStorage("spendModelsTrailingAverage") private var trailingAverage = false + @AppStorage("spendModelsViewMode") private var viewMode: SpendModelsViewMode = .models @State private var selectedDay: Date? - + @State private var pinnedDay: Date? + @State private var showsAllModels = false + @State private var cachedPresentation: SpendModelsPresentation? + @State private var cachedChartPresentation: SpendModelsPresentation? + // Hover lookup caches (rebuilt alongside the presentations): a sorted unique-day list for the + // nearest-day snap, and points grouped by start-of-day for the tooltip. Both replace an + // O(points) scan with Calendar.isDate(inSameDayAs:) per hover tick. + @State private var cachedSortedDays: [Date] = [] + @State private var cachedPointsByDay: [Date: [SpendModelsPresentation.Point]] = [:] + + /// Memoized presentations. Building these sorts + aggregates every model row, and the chart + /// variant also runs the trailing-average smoothing; recomputing them on every hover tick + /// (selectedDay changes re-evaluate body) is the hover lag. Cache and only rebuild when the + /// inputs change, never on hover. private var presentation: SpendModelsPresentation { - SpendModelsPresentation(analysis: self.analysis, metric: .tokens) + if let cached = self.cachedPresentation { return cached } + return SpendModelsPresentation(analysis: self.analysis, metric: self.selectedMetric) + } + + private var chartPresentation: SpendModelsPresentation { + if let cached = self.cachedChartPresentation { return cached } + let base = self.presentation + return self.trailingAverage ? base.applyingTrailingAverage() : base + } + + private func rebuildPresentations() { + let base = SpendModelsPresentation(analysis: self.analysis, metric: self.selectedMetric) + self.cachedPresentation = base + let chart = self.trailingAverage ? base.applyingTrailingAverage() : base + self.cachedChartPresentation = chart + self.cachedSortedDays = Array(Set(chart.points.map(\.day))).sorted() + self.cachedPointsByDay = Dictionary(grouping: chart.points) { + Calendar.current.startOfDay(for: $0.day) + } } var body: some View { SpendDashboardPanel { VStack(alignment: .leading, spacing: 16) { - HStack(alignment: .firstTextBaseline, spacing: 12) { + HStack(alignment: .firstTextBaseline) { Text(L("Models")) .font(.headline) Spacer() - ForEach([7, 30, 365], id: \.self) { days in - self.rangeButton(days) + Picker(L("View"), selection: self.$viewMode) { + ForEach(SpendModelsViewMode.allCases) { mode in + Text(mode.title).tag(mode) + } } + .labelsHidden() + .pickerStyle(.segmented) + .fixedSize() } - if self.presentation.points.isEmpty { - Text(L("No model-level history")) - .foregroundStyle(.secondary) - .padding(.vertical, 10) + HStack(spacing: 10) { + if self.viewMode == .models { + Picker(L("Metric"), selection: self.$selectedMetric) { + ForEach(SpendModelMetric.allCases) { metric in + Text(metric.title).tag(metric) + } + } + .labelsHidden() + .pickerStyle(.segmented) + .fixedSize() + Toggle(isOn: self.$trailingAverage) { + Text(L("7-day avg")) + .font(.callout) + .lineLimit(1) + .fixedSize() + } + .toggleStyle(.switch) + .controlSize(.small) + } + Spacer() + } + if self.viewMode == .clients { + SpendClientsView(analysis: self.analysis) + } else if self.chartPresentation.points.isEmpty { + if self.presentation.metric == .estimatedSpend { + Text(L("No priced model history")) + .foregroundStyle(.secondary) + .padding(.vertical, 10) + } else { + Text(L("No model-level history")) + .foregroundStyle(.secondary) + .padding(.vertical, 10) + } } else { self.chart + if let pinnedDay = self.pinnedDay, + let detail = SpendModelsDayDetailPresentation( + analysis: self.analysis, + day: pinnedDay, + metric: self.selectedMetric) + { + SpendModelsDayDetailView( + detail: detail, + metric: self.selectedMetric, + colorForModel: { self.modelColor(for: $0) }) + } self.ranking } if self.presentation.coverage == .partial { @@ -316,24 +494,54 @@ struct SpendModelsSection: View { .font(.caption) .foregroundStyle(.tertiary) } + if self.showsEstimatedCostFootnote { + Text(L("Estimated costs are priced from local logs and may differ from provider bills.")) + .font(.caption) + .foregroundStyle(.tertiary) + } } } + .onAppear { + if self.cachedPresentation == nil { self.rebuildPresentations() } + } + .onChange(of: self.analysis) { _, _ in self.rebuildPresentations() } + .onChange(of: self.selectedMetric) { _, _ in self.rebuildPresentations() } + .onChange(of: self.trailingAverage) { _, isOn in + // Day pinning is disabled while smoothing is on (documented tokens.ci behavior: + // the chart shows averages, so a raw per-day panel would be misleading). + if isOn { self.pinnedDay = nil } + self.rebuildPresentations() + } + .onChange(of: self.chartPresentation.points) { _, points in + guard let pinnedDay = self.pinnedDay else { return } + if !Set(points.map(\.day)).contains(pinnedDay) { self.pinnedDay = nil } + } + } + + private var showsEstimatedCostFootnote: Bool { + self.presentation.metric == .estimatedSpend && + self.presentation.rows.contains { $0.value != nil && $0.source.costIsEstimated } } private var chart: some View { Chart { - ForEach(self.presentation.points) { point in + ForEach(self.chartPresentation.points) { point in BarMark( - x: .value("Day", point.day, unit: .day), - yStart: .value("Tokens", point.stackStart), - yEnd: .value("Tokens", point.stackEnd), + x: .value(L("Day"), point.day, unit: .day), + yStart: .value(self.chartPresentation.metric.title, point.stackStart), + yEnd: .value(self.chartPresentation.metric.title, point.stackEnd), width: .ratio(0.68)) - .foregroundStyle(by: .value("Models", point.seriesName)) + .foregroundStyle(by: .value(L("Models"), point.seriesName)) .accessibilityLabel(Text("\(point.seriesName), \(self.dayText(point.day))")) .accessibilityValue(Text(self.metricText(point.value))) } + if let pinnedDay = self.pinnedDay { + RuleMark(x: .value(L("Day"), pinnedDay, unit: .day)) + .foregroundStyle(Color.secondary.opacity(0.4)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 3])) + } if let selectedDay { - RuleMark(x: .value("Day", selectedDay, unit: .day)) + RuleMark(x: .value(L("Day"), selectedDay, unit: .day)) .foregroundStyle(.clear) .annotation(position: .top, overflowResolution: .init( x: .fit(to: .chart), @@ -366,21 +574,28 @@ struct SpendModelsSection: View { AxisMarks(position: .leading, values: .automatic(desiredCount: 4)) { value in AxisValueLabel { if let amount = value.as(Double.self) { - Text(self.metricText(amount)) + Text(self.axisMetricText(amount)) .foregroundStyle(.secondary) } } } } .frame(height: 220) - .accessibilityLabel("Models") + .accessibilityLabel(L("Models")) .accessibilityValue(self.chartAccessibilityValue) .chartOverlay { proxy in GeometryReader { geo in - MouseLocationReader { location in - self.updateSelectedDay(location: location, proxy: proxy, geo: geo) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) + SpendModelsChartMouseReader( + onMoved: { location in + self.updateSelectedDay(location: location, proxy: proxy, geo: geo) + }, + onClicked: { location in + self.handleChartClick(location: location, proxy: proxy, geo: geo) + }, + onEscape: { + self.pinnedDay = nil + }) + .frame(maxWidth: .infinity, maxHeight: .infinity) } } } @@ -391,7 +606,7 @@ struct SpendModelsSection: View { private var rankingContent: some View { VStack(alignment: .leading, spacing: 8) { - ForEach(self.presentation.rows) { row in + ForEach(SpendModelsRanking.visibleRows(self.presentation.rows, showsAll: self.showsAllModels)) { row in HStack(spacing: 9) { RoundedRectangle(cornerRadius: 2, style: .continuous) .fill(self.color(for: row)) @@ -411,6 +626,20 @@ struct SpendModelsSection: View { .frame(width: 58, alignment: .trailing) } } + if SpendModelsRanking.showsDisclosure(rowCount: self.presentation.rows.count) { + Button { + self.showsAllModels.toggle() + } label: { + Text(self.showsAllModels + ? L("Show top 20") + : L("Show all %d models", self.presentation.rows.count)) + .font(.body) + .foregroundStyle(.secondary) + .padding(.vertical, 2) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } } } @@ -418,8 +647,10 @@ struct SpendModelsSection: View { guard self.presentation.metric == .tokens else { guard let value = row.value else { return "—" } let providers = row.source.providerNames.joined(separator: " · ") - let metric = self.metricText(value) - return providers.isEmpty ? metric : "\(metric) · \(providers)" + var parts = [self.metricText(value)] + if !providers.isEmpty { parts.append(providers) } + if row.source.costIsEstimated { parts.append(L("estimated")) } + return parts.joined(separator: " · ") } return spendModelsRowDetailText(row) } @@ -431,8 +662,7 @@ struct SpendModelsSection: View { } private func tooltip(_ day: Date) -> some View { - let points = self.presentation.points - .filter { Calendar.current.isDate($0.day, inSameDayAs: day) } + let points = (self.cachedPointsByDay[Calendar.current.startOfDay(for: day)] ?? []) .sorted { $0.value > $1.value } return VStack(alignment: .leading, spacing: 5) { Text(self.dayText(day)) @@ -456,12 +686,12 @@ struct SpendModelsSection: View { } private var chartAccessibilityValue: String { - let days = Set(self.presentation.points.map(\.day)).count - return "\(days) days · \(self.presentation.series.count) models" + let days = Set(self.chartPresentation.points.map(\.day)).count + return L("%d days of usage data across %d models", days, self.chartPresentation.series.count) } private var fallbackDomain: ClosedRange { - let days = self.presentation.points.map(\.day) + let days = self.chartPresentation.points.map(\.day) let start = days.min() ?? Date() let end = days.max() ?? start return start...Calendar.current.date(byAdding: .day, value: 1, to: end)! @@ -470,31 +700,10 @@ struct SpendModelsSection: View { private var xAxisDates: [Date] { SpendModelsAxisDates.make( selectedDays: self.selectedDays, - dataDays: self.presentation.points.map(\.day), + dataDays: self.chartPresentation.points.map(\.day), domain: self.chartDomain ?? self.fallbackDomain) } - private func rangeButton(_ days: Int) -> some View { - Button { - self.selectedDays = days - self.selectedDay = nil - } label: { - Text(spendModelsDayRangeText(days)) - .font(.body) - .padding(.horizontal, 10) - .padding(.vertical, 5) - .background { - if self.selectedDays == days { - RoundedRectangle(cornerRadius: 7, style: .continuous) - .fill(Color.secondary.opacity(0.12)) - } - } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityAddTraits(self.selectedDays == days ? .isSelected : []) - } - private func xAxisLabelAnchor(for date: Date) -> UnitPoint { if let first = self.xAxisDates.first, Calendar.current.isDate(date, inSameDayAs: first) { return .topLeading @@ -506,7 +715,17 @@ struct SpendModelsSection: View { } private func metricText(_ value: Double) -> String { - UsageFormatter.tokenCountString(Int(value.rounded())) + switch self.presentation.metric { + case .tokens: UsageFormatter.tokenCountString(Int(value.rounded())) + case .estimatedSpend: UsageFormatter.currencyString(value, currencyCode: "USD") + } + } + + private func axisMetricText(_ value: Double) -> String { + switch self.presentation.metric { + case .tokens: self.metricText(value) + case .estimatedSpend: UsageFormatter.compactCurrencyString(value, currencyCode: "USD") + } } private func dayText(_ day: Date) -> String { @@ -530,6 +749,13 @@ struct SpendModelsSection: View { return self.color(for: index) } + private func modelColor(for id: String) -> Color { + guard let index = self.presentation.series.firstIndex(where: { $0.id == id }) else { + return Color(nsColor: .tertiaryLabelColor).opacity(0.55) + } + return self.color(for: index) + } + private func seriesIndex(_ id: String) -> Int { self.presentation.series.firstIndex(where: { $0.id == id }) ?? 0 } @@ -546,8 +772,135 @@ struct SpendModelsSection: View { } let xInPlot = location.x - plotFrame.origin.x guard let date: Date = proxy.value(atX: xInPlot) else { return } - self.selectedDay = Set(self.presentation.points.map(\.day)).min { - abs($0.timeIntervalSince(date)) < abs($1.timeIntervalSince(date)) + self.selectedDay = self.nearestDay(to: date) + } + + /// Binary search over the cached sorted day list; avoids rebuilding a Set + linear scan each + /// hover tick. + private func nearestDay(to date: Date) -> Date? { + let days = self.cachedSortedDays + guard !days.isEmpty else { return nil } + var lo = 0, hi = days.count + while lo < hi { + let mid = (lo + hi) / 2 + if days[mid] < date { lo = mid + 1 } else { hi = mid } + } + if lo == 0 { return days[0] } + if lo == days.count { return days[days.count - 1] } + let before = days[lo - 1], after = days[lo] + return abs(before.timeIntervalSince(date)) <= abs(after.timeIntervalSince(date)) ? before : after + } + + /// Clicking a bar day pins it (clicking the same day again clears); clicking empty space clears. + /// Pinning is disabled while the trailing average smooths the chart. + private func handleChartClick(location: CGPoint, proxy: ChartProxy, geo: GeometryProxy) { + guard !self.trailingAverage else { return } + guard let plotAnchor = proxy.plotFrame else { + self.pinnedDay = nil + return + } + let plotFrame = geo[plotAnchor] + guard plotFrame.contains(location), + let date: Date = proxy.value(atX: location.x - plotFrame.origin.x) + else { + self.pinnedDay = nil + return + } + guard let day = self.nearestDay(to: date), abs(day.timeIntervalSince(date)) <= 43200 else { + self.pinnedDay = nil + return + } + self.pinnedDay = self.pinnedDay == day ? nil : day + } +} + +/// Hover/click/Escape reader for the models chart. Mirrors `MouseLocationReader` (which has no +/// click support) and adds day pinning: mouse-down reports the location, and once the view holds +/// first responder, Escape clears the pinned day. +@MainActor +private struct SpendModelsChartMouseReader: NSViewRepresentable { + let onMoved: (CGPoint?) -> Void + let onClicked: (CGPoint) -> Void + let onEscape: () -> Void + + func makeNSView(context: Context) -> TrackingView { + let view = TrackingView() + view.onMoved = self.onMoved + view.onClicked = self.onClicked + view.onEscape = self.onEscape + return view + } + + func updateNSView(_ nsView: TrackingView, context: Context) { + nsView.onMoved = self.onMoved + nsView.onClicked = self.onClicked + nsView.onEscape = self.onEscape + } + + final class TrackingView: NSView { + var onMoved: ((CGPoint?) -> Void)? + var onClicked: ((CGPoint) -> Void)? + var onEscape: (() -> Void)? + private var trackingArea: NSTrackingArea? + + override var isFlipped: Bool { + true + } + + override var acceptsFirstResponder: Bool { + true + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + self.window?.acceptsMouseMovedEvents = true + self.updateTrackingAreas() + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let trackingArea { + self.removeTrackingArea(trackingArea) + } + + let options: NSTrackingArea.Options = [ + .activeAlways, + .inVisibleRect, + .mouseEnteredAndExited, + .mouseMoved, + ] + let area = NSTrackingArea(rect: .zero, options: options, owner: self, userInfo: nil) + self.addTrackingArea(area) + self.trackingArea = area + } + + override func mouseEntered(with event: NSEvent) { + super.mouseEntered(with: event) + self.onMoved?(self.convert(event.locationInWindow, from: nil)) + } + + override func mouseMoved(with event: NSEvent) { + super.mouseMoved(with: event) + self.onMoved?(self.convert(event.locationInWindow, from: nil)) + } + + override func mouseExited(with event: NSEvent) { + super.mouseExited(with: event) + self.onMoved?(nil) + } + + override func mouseDown(with event: NSEvent) { + self.window?.makeFirstResponder(self) + self.onClicked?(self.convert(event.locationInWindow, from: nil)) + } + + override func keyDown(with event: NSEvent) { + if event.keyCode == 53 { // Escape + self.onEscape?() + self.window?.makeFirstResponder(nil) + } else { + super.keyDown(with: event) + } } } } diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index 64371c707f..2b1a78020b 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -1272,14 +1272,17 @@ "By subscription" = "حسب الاشتراك"; "No model-level history" = "لا يوجد سجل على مستوى النموذج"; "Tokens" = "Tokens"; -"Tracked model tokens" = "Tracked model tokens"; -"Priced model spend" = "Priced model spend"; +"Tracked model tokens" = "رموز النماذج المتتبعة"; +"Priced model spend" = "إنفاق النماذج المسعّرة"; "Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; -"No priced model history" = "No priced model history"; +"No priced model history" = "لا يوجد سجل للنماذج المسعّرة"; "Other" = "Other"; -"Show all %d models" = "Show all %d models"; -"Show top 20" = "Show top 20"; +"Show all %d models" = "عرض جميع النماذج (%d)"; +"Show top 20" = "عرض أول 20"; "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; +"Metric" = "المقياس"; +"%d days of usage data across %d models" = "%d بيانات الاستخدام عبر نماذج %d"; +"%@ in · %@ out" = "%@ داخل · %@ خارج"; "Daily estimated spend" = "الإنفاق اليومي التقديري"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d نافذة كاملة مدتها 5 ساعات متبقية من الأسبوعي · %d نافذة حتى إعادة التعيين"; "Weekly cannot run out before reset at this pace" = "لا يمكن أن ينفد الحد الأسبوعي قبل إعادة التعيين بهذه الوتيرة"; @@ -1289,6 +1292,20 @@ "Agent Plan" = "خطة الوكيل"; "Team" = "فريق"; +"7-day average" = "متوسط 7 أيام"; +"Input" = "الإدخال"; +"Output" = "الإخراج"; +"Cache write" = "كتابة ذاكرة التخزين المؤقت"; +"Reasoning" = "الاستدلال"; +"%@ in" = "%@ إدخال"; +"%@ out" = "%@ إخراج"; +"%@ cache read" = "%@ قراءة التخزين المؤقت"; +"%@ cache write" = "%@ كتابة التخزين المؤقت"; +"%@ reasoning" = "%@ استدلال"; +"estimated" = "تقديري"; +"Estimated costs are priced from local logs and may differ from provider bills." = "يتم احتساب التكاليف التقديرية من السجلات المحلية بأسعار القائمة وقد تختلف عن فواتير مزودي الخدمة."; +"Usage details for %@" = "تفاصيل الاستخدام ليوم %@"; + /* Menu bar layout editor */ "menu_bar_layout_title" = "التخطيط"; "menu_bar_layout_footer" = "اسحب العناصر لترتيب شريط القوائم. انقر على عنصر لإضافته؛ حدّد عنصراً موضوعاً واضغط Delete لإزالته."; @@ -1354,3 +1371,22 @@ "Cost today unavailable" = "تكلفة اليوم: غير متوفر"; "30-day cost unavailable" = "تكلفة 30 يوماً: غير متوفر"; "Resets" = "إعادات الضبط"; +"Cumulative" = "تراكمي"; + +/* Token usage dashboard (usage & spend) */ +"Token activity" = "نشاط الرموز"; +"By model" = "حسب النموذج"; +"By tool" = "حسب الأداة"; +"View" = "عرض"; +"7-day avg" = "متوسط 7 أيام"; +"in the last year" = "في العام الماضي"; +"No activity in the last 12 months" = "لا يوجد نشاط في آخر 12 شهرًا"; +"Each column = 1 week" = "كل عمود = أسبوع واحد"; +"Running total" = "الإجمالي التراكمي"; +"Less" = "أقل"; +"More" = "أكثر"; +"Mo" = "إث"; +"We" = "أر"; +"Fr" = "جم"; +"Estimated" = "تقديري"; +"No per-client model history" = "لا يوجد سجل نماذج لكل عميل"; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index 82a14c2a27..b095351bd1 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -1271,14 +1271,17 @@ "By subscription" = "Per subscripció"; "No model-level history" = "Sense historial per model"; "Tokens" = "Tokens"; -"Tracked model tokens" = "Tracked model tokens"; -"Priced model spend" = "Priced model spend"; +"Tracked model tokens" = "Tokens de models seguits"; +"Priced model spend" = "Despesa de models amb preu"; "Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; -"No priced model history" = "No priced model history"; +"No priced model history" = "No hi ha historial de models amb preu"; "Other" = "Other"; -"Show all %d models" = "Show all %d models"; -"Show top 20" = "Show top 20"; +"Show all %d models" = "Mostra els %d models"; +"Show top 20" = "Mostra els 20 primers"; "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; +"Metric" = "Mètrica"; +"%d days of usage data across %d models" = "%d dies de dades d'ús en %d models"; +"%@ in · %@ out" = "%@ d'entrada · %@ de sortida"; "Daily estimated spend" = "Despesa diària estimada"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d finestres completes de 5 h de quota setmanal · %d finestres fins al reinici"; "Weekly cannot run out before reset at this pace" = "La quota setmanal no es pot esgotar abans del reinici a aquest ritme"; @@ -1288,6 +1291,20 @@ "Agent Plan" = "Pla d'agent"; "Team" = "Equip"; +"7-day average" = "Mitjana de 7 dies"; +"Input" = "Entrada"; +"Output" = "Sortida"; +"Cache write" = "Escriptura de memòria cau"; +"Reasoning" = "Raonament"; +"%@ in" = "%@ entrada"; +"%@ out" = "%@ sortida"; +"%@ cache read" = "%@ lectura de memòria cau"; +"%@ cache write" = "%@ escriptura de memòria cau"; +"%@ reasoning" = "%@ raonament"; +"estimated" = "estimat"; +"Estimated costs are priced from local logs and may differ from provider bills." = "Els costos estimats es calculen a partir dels registres locals a preus de llista i poden diferir de les factures dels proveïdors."; +"Usage details for %@" = "Detalls d'ús per a %@"; + /* Menu bar layout editor */ "menu_bar_layout_title" = "Disposició"; "menu_bar_layout_footer" = "Arrossega les fitxes per ordenar la barra de menús. Fes clic en una fitxa per afegir-la; selecciona una fitxa col·locada i prem Suprimir per eliminar-la."; @@ -1353,3 +1370,22 @@ "Cost today unavailable" = "Cost d’avui: No disponible"; "30-day cost unavailable" = "Cost de 30 dies: No disponible"; "Resets" = "Reinicis"; +"Cumulative" = "Acumulat"; + +/* Token usage dashboard (usage & spend) */ +"Token activity" = "Activitat de tokens"; +"By model" = "Per model"; +"By tool" = "Per eina"; +"View" = "Vista"; +"7-day avg" = "Mitjana de 7 dies"; +"in the last year" = "l'últim any"; +"No activity in the last 12 months" = "Sense activitat en els últims 12 mesos"; +"Each column = 1 week" = "Cada columna = 1 setmana"; +"Running total" = "Total acumulat"; +"Less" = "Menys"; +"More" = "Més"; +"Mo" = "dl"; +"We" = "dc"; +"Fr" = "dv"; +"Estimated" = "Estimat"; +"No per-client model history" = "Sense historial de models per client"; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index a4ac8e0aea..868dc294e3 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -1269,14 +1269,17 @@ "By subscription" = "Nach Abonnement"; "No model-level history" = "Kein Verlauf auf Modellebene"; "Tokens" = "Tokens"; -"Tracked model tokens" = "Tracked model tokens"; -"Priced model spend" = "Priced model spend"; +"Tracked model tokens" = "Erfasste Modell-Tokens"; +"Priced model spend" = "Bepreiste Modellausgaben"; "Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; -"No priced model history" = "No priced model history"; +"No priced model history" = "Kein Verlauf bepreister Modelle"; "Other" = "Other"; -"Show all %d models" = "Show all %d models"; -"Show top 20" = "Show top 20"; +"Show all %d models" = "Alle %d Modelle anzeigen"; +"Show top 20" = "Top 20 anzeigen"; "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; +"Metric" = "Metrik"; +"%d days of usage data across %d models" = "%d Tage Nutzungsdaten für %d Modelle"; +"%@ in · %@ out" = "%@ ein · %@ aus"; "Daily estimated spend" = "Geschätzte tägliche Ausgaben"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d volle 5-Std.-Fenster des Wochenlimits übrig · %d Fenster bis zum Reset"; "Weekly cannot run out before reset at this pace" = "Das Wochenlimit kann bei diesem Tempo nicht vor dem Reset aufgebraucht sein"; @@ -1286,6 +1289,20 @@ "Agent Plan" = "Agentenplan"; "Team" = "Team"; +"7-day average" = "7-Tage-Durchschnitt"; +"Input" = "Eingabe"; +"Output" = "Ausgabe"; +"Cache write" = "Cache-Schreibvorgänge"; +"Reasoning" = "Reasoning"; +"%@ in" = "%@ Eingabe"; +"%@ out" = "%@ Ausgabe"; +"%@ cache read" = "%@ Cache-Lesevorgänge"; +"%@ cache write" = "%@ Cache-Schreibvorgänge"; +"%@ reasoning" = "%@ Reasoning"; +"estimated" = "geschätzt"; +"Estimated costs are priced from local logs and may differ from provider bills." = "Geschätzte Kosten werden aus lokalen Protokollen zu Listenpreisen berechnet und können von den Anbieterrechnungen abweichen."; +"Usage details for %@" = "Nutzungsdetails für %@"; + /* Menu bar layout editor */ "menu_bar_layout_title" = "Layout"; "menu_bar_layout_footer" = "Ziehe Bausteine, um die Menüleiste anzuordnen. Klicke einen Baustein zum Anhängen an; wähle einen platzierten Baustein und drücke die Löschtaste, um ihn zu entfernen."; @@ -1351,3 +1368,22 @@ "Cost today unavailable" = "Kosten heute: Nicht verfügbar"; "30-day cost unavailable" = "Kosten 30 Tage: Nicht verfügbar"; "Resets" = "Zurücksetzungen"; +"Cumulative" = "Kumulativ"; + +/* Token usage dashboard (usage & spend) */ +"Token activity" = "Token-Aktivität"; +"By model" = "Nach Modell"; +"By tool" = "Nach Tool"; +"View" = "Ansicht"; +"7-day avg" = "7-Tage-Ø"; +"in the last year" = "im letzten Jahr"; +"No activity in the last 12 months" = "Keine Aktivität in den letzten 12 Monaten"; +"Each column = 1 week" = "Jede Spalte = 1 Woche"; +"Running total" = "Laufende Summe"; +"Less" = "Weniger"; +"More" = "Mehr"; +"Mo" = "Mo"; +"We" = "Mi"; +"Fr" = "Fr"; +"Estimated" = "Geschätzt"; +"No per-client model history" = "Keinen Modellverlauf pro Client"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 61492e275f..cdd6afa12d 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -1281,6 +1281,9 @@ "Show all %d models" = "Show all %d models"; "Show top 20" = "Show top 20"; "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; +"Metric" = "Metric"; +"%d days of usage data across %d models" = "%d days of usage data across %d models"; +"%@ in · %@ out" = "%@ in · %@ out"; "Daily estimated spend" = "Daily estimated spend"; "≈%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"; @@ -1290,6 +1293,20 @@ "Agent Plan" = "Agent Plan"; "Team" = "Team"; +"7-day average" = "7-day average"; +"Input" = "Input"; +"Output" = "Output"; +"Cache write" = "Cache write"; +"Reasoning" = "Reasoning"; +"%@ in" = "%@ in"; +"%@ out" = "%@ out"; +"%@ cache read" = "%@ cache read"; +"%@ cache write" = "%@ cache write"; +"%@ reasoning" = "%@ reasoning"; +"estimated" = "estimated"; +"Estimated costs are priced from local logs and may differ from provider bills." = "Estimated costs are priced from local logs and may differ from provider bills."; +"Usage details for %@" = "Usage details for %@"; + /* Menu bar layout editor */ "menu_bar_layout_title" = "Layout"; "menu_bar_layout_footer" = "Drag tokens to arrange the menu bar. Click a token to append it; select a placed token and press Delete to remove it."; @@ -1355,3 +1372,23 @@ "Cost today unavailable" = "Cost today unavailable"; "30-day cost unavailable" = "30-day cost unavailable"; "Resets" = "Resets"; +"Cumulative" = "Cumulative"; + +/* Token usage dashboard (usage & spend) */ +"Token activity" = "Token activity"; +"By model" = "By model"; +"By tool" = "By tool"; +"View" = "View"; +"Day" = "Day"; +"7-day avg" = "7-day avg"; +"in the last year" = "in the last year"; +"No activity in the last 12 months" = "No activity in the last 12 months"; +"Each column = 1 week" = "Each column = 1 week"; +"Running total" = "Running total"; +"Less" = "Less"; +"More" = "More"; +"Mo" = "Mo"; +"We" = "We"; +"Fr" = "Fr"; +"Estimated" = "Estimated"; +"No per-client model history" = "No per-client model history"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 806d491124..1310b56d97 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -1267,14 +1267,17 @@ "By subscription" = "Por suscripción"; "No model-level history" = "No hay historial por modelo"; "Tokens" = "Tokens"; -"Tracked model tokens" = "Tracked model tokens"; -"Priced model spend" = "Priced model spend"; +"Tracked model tokens" = "Tokens de modelos registrados"; +"Priced model spend" = "Gasto de modelos con precio"; "Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; -"No priced model history" = "No priced model history"; +"No priced model history" = "No hay historial de modelos con precio"; "Other" = "Other"; -"Show all %d models" = "Show all %d models"; -"Show top 20" = "Show top 20"; +"Show all %d models" = "Mostrar los %d modelos"; +"Show top 20" = "Mostrar los 20 primeros"; "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; +"Metric" = "Métrica"; +"%d days of usage data across %d models" = "%d días de datos de uso en %d modelos"; +"%@ in · %@ out" = "%@ de entrada · %@ de salida"; "Daily estimated spend" = "Gasto diario estimado"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d ventanas completas de 5 h de cuota semanal · %d ventanas hasta el reinicio"; "Weekly cannot run out before reset at this pace" = "La cuota semanal no puede agotarse antes del reinicio a este ritmo"; @@ -1284,6 +1287,20 @@ "Agent Plan" = "Plan de agente"; "Team" = "Equipo"; +"7-day average" = "Promedio de 7 días"; +"Input" = "Entrada"; +"Output" = "Salida"; +"Cache write" = "Escritura de caché"; +"Reasoning" = "Razonamiento"; +"%@ in" = "%@ entrada"; +"%@ out" = "%@ salida"; +"%@ cache read" = "%@ lectura de caché"; +"%@ cache write" = "%@ escritura de caché"; +"%@ reasoning" = "%@ razonamiento"; +"estimated" = "estimado"; +"Estimated costs are priced from local logs and may differ from provider bills." = "Los costos estimados se calculan a partir de registros locales a precios de lista y pueden diferir de las facturas de los proveedores."; +"Usage details for %@" = "Detalles de uso del %@"; + /* Menu bar layout editor */ "menu_bar_layout_title" = "Disposición"; "menu_bar_layout_footer" = "Arrastra fichas para ordenar la barra de menús. Haz clic en una ficha para añadirla; selecciona una ficha colocada y pulsa Suprimir para quitarla."; @@ -1349,3 +1366,22 @@ "Cost today unavailable" = "Coste de hoy: No disponible"; "30-day cost unavailable" = "Coste de 30 días: No disponible"; "Resets" = "Reinicios"; +"Cumulative" = "Acumulado"; + +/* Token usage dashboard (usage & spend) */ +"Token activity" = "Actividad de tokens"; +"By model" = "Por modelo"; +"By tool" = "Por herramienta"; +"View" = "Vista"; +"7-day avg" = "Prom. 7 días"; +"in the last year" = "en el último año"; +"No activity in the last 12 months" = "Sin actividad en los últimos 12 meses"; +"Each column = 1 week" = "Cada columna = 1 semana"; +"Running total" = "Total acumulado"; +"Less" = "Menos"; +"More" = "Más"; +"Mo" = "lu"; +"We" = "mi"; +"Fr" = "vi"; +"Estimated" = "Estimado"; +"No per-client model history" = "Sin historial de modelos por cliente"; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index a86d9cbc73..55428b7961 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -1272,14 +1272,17 @@ "By subscription" = "بر اساس اشتراک"; "No model-level history" = "تاریخچه‌ای در سطح مدل وجود ندارد"; "Tokens" = "Tokens"; -"Tracked model tokens" = "Tracked model tokens"; -"Priced model spend" = "Priced model spend"; +"Tracked model tokens" = "توکن های مدل ردیابی شده"; +"Priced model spend" = "هزینه مدل های قیمت گذاری شده"; "Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; -"No priced model history" = "No priced model history"; +"No priced model history" = "تاریخچه مدل قیمت گذاری شده وجود ندارد"; "Other" = "Other"; -"Show all %d models" = "Show all %d models"; -"Show top 20" = "Show top 20"; +"Show all %d models" = "نمایش هر %d مدل"; +"Show top 20" = "نمایش 20 مورد برتر"; "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; +"Metric" = "متریک"; +"%d days of usage data across %d models" = "%d روز داده های استفاده در مدل های %d"; +"%@ in · %@ out" = "%@ ورودی · %@ خروجی"; "Daily estimated spend" = "برآورد هزینه روزانه"; "≈%d full 5h windows of weekly left · %d windows until reset" = "حدود %d بازه کامل ۵ ساعته از سهم هفتگی مانده · %d بازه تا بازنشانی"; "Weekly cannot run out before reset at this pace" = "با این روند، سهم هفتگی پیش از بازنشانی تمام نمی‌شود"; @@ -1289,6 +1292,20 @@ "Agent Plan" = "طرح عامل"; "Team" = "تیم"; +"7-day average" = "میانگین ۷ روزه"; +"Input" = "ورودی"; +"Output" = "خروجی"; +"Cache write" = "نوشتن حافظه پنهان"; +"Reasoning" = "استدلال"; +"%@ in" = "%@ ورودی"; +"%@ out" = "%@ خروجی"; +"%@ cache read" = "%@ خواندن حافظه پنهان"; +"%@ cache write" = "%@ نوشتن حافظه پنهان"; +"%@ reasoning" = "%@ استدلال"; +"estimated" = "تخمینی"; +"Estimated costs are priced from local logs and may differ from provider bills." = "هزینه‌های تخمینی از گزارش‌های محلی با قیمت‌های فهرست محاسبه می‌شوند و ممکن است با صورت‌حساب ارائه‌دهندگان متفاوت باشند."; +"Usage details for %@" = "جزئیات استفاده برای %@"; + /* Menu bar layout editor */ "menu_bar_layout_title" = "چیدمان"; "menu_bar_layout_footer" = "نشانه‌ها را برای چیدمان نوار منو بکشید. برای افزودن روی نشانه کلیک کنید؛ نشانهٔ قرارگرفته را انتخاب کنید و برای حذف Delete را بزنید."; @@ -1354,3 +1371,22 @@ "Cost today unavailable" = "هزینهٔ امروز: در دسترس نیست"; "30-day cost unavailable" = "هزینهٔ ۳۰ روز: در دسترس نیست"; "Resets" = "بازنشانی‌ها"; +"Cumulative" = "تجمعی"; + +/* Token usage dashboard (usage & spend) */ +"Token activity" = "فعالیت توکن"; +"By model" = "بر اساس مدل"; +"By tool" = "بر اساس ابزار"; +"View" = "نمایش"; +"7-day avg" = "میانگین ۷ روز"; +"in the last year" = "در سال گذشته"; +"No activity in the last 12 months" = "هیچ فعالیتی در ۱۲ ماه گذشته وجود ندارد"; +"Each column = 1 week" = "هر ستون = ۱ هفته"; +"Running total" = "مجموع تجمعی"; +"Less" = "کمتر"; +"More" = "بیشتر"; +"Mo" = "دو"; +"We" = "چه"; +"Fr" = "جم"; +"Estimated" = "تخمینی"; +"No per-client model history" = "بدون سابقه مدل برای هر کلاینت"; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index ff0c82c299..8369255654 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -1268,14 +1268,17 @@ "By subscription" = "Par abonnement"; "No model-level history" = "Aucun historique au niveau des modèles"; "Tokens" = "Tokens"; -"Tracked model tokens" = "Tracked model tokens"; -"Priced model spend" = "Priced model spend"; +"Tracked model tokens" = "Jetons de modèles suivis"; +"Priced model spend" = "Dépenses des modèles tarifés"; "Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; -"No priced model history" = "No priced model history"; +"No priced model history" = "Aucun historique de modèles tarifés"; "Other" = "Other"; -"Show all %d models" = "Show all %d models"; -"Show top 20" = "Show top 20"; +"Show all %d models" = "Afficher les %d modèles"; +"Show top 20" = "Afficher le top 20"; "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; +"Metric" = "Métrique"; +"%d days of usage data across %d models" = "%d jours de données d'utilisation sur %d modèles"; +"%@ in · %@ out" = "%@ en entrée · %@ en sortie"; "Daily estimated spend" = "Dépenses quotidiennes estimées"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d fenêtres complètes de 5 h de quota hebdomadaire · %d fenêtres avant réinitialisation"; "Weekly cannot run out before reset at this pace" = "Le quota hebdomadaire ne peut pas être épuisé avant la réinitialisation à ce rythme"; @@ -1285,6 +1288,20 @@ "Agent Plan" = "Plan d'agent"; "Team" = "Équipe"; +"7-day average" = "Moyenne sur 7 jours"; +"Input" = "Entrée"; +"Output" = "Sortie"; +"Cache write" = "Écriture de cache"; +"Reasoning" = "Raisonnement"; +"%@ in" = "%@ entrée"; +"%@ out" = "%@ sortie"; +"%@ cache read" = "%@ lecture de cache"; +"%@ cache write" = "%@ écriture de cache"; +"%@ reasoning" = "%@ raisonnement"; +"estimated" = "estimé"; +"Estimated costs are priced from local logs and may differ from provider bills." = "Les coûts estimés sont calculés à partir des journaux locaux aux tarifs officiels et peuvent différer des factures des fournisseurs."; +"Usage details for %@" = "Détails d'utilisation pour %@"; + /* Menu bar layout editor */ "menu_bar_layout_title" = "Disposition"; "menu_bar_layout_footer" = "Faites glisser les jetons pour organiser la barre des menus. Cliquez sur un jeton pour l’ajouter ; sélectionnez un jeton placé et appuyez sur Supprimer pour le retirer."; @@ -1350,3 +1367,22 @@ "Cost today unavailable" = "Coût aujourd’hui: Indisponible"; "30-day cost unavailable" = "Coût sur 30 j: Indisponible"; "Resets" = "Réinitialisations"; +"Cumulative" = "Cumulé"; + +/* Token usage dashboard (usage & spend) */ +"Token activity" = "Activité des tokens"; +"By model" = "Par modèle"; +"By tool" = "Par outil"; +"View" = "Vue"; +"7-day avg" = "Moy. 7 j"; +"in the last year" = "sur l'année écoulée"; +"No activity in the last 12 months" = "Aucune activité au cours des 12 derniers mois"; +"Each column = 1 week" = "Chaque colonne = 1 semaine"; +"Running total" = "Total cumulé"; +"Less" = "Moins"; +"More" = "Plus"; +"Mo" = "lu"; +"We" = "me"; +"Fr" = "ve"; +"Estimated" = "Estimé"; +"No per-client model history" = "Aucun historique de modèle par client"; diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings index 6a939ec512..40d3bbbc93 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -1268,14 +1268,17 @@ "By subscription" = "Por subscrición"; "No model-level history" = "Sen historial por modelo"; "Tokens" = "Tokens"; -"Tracked model tokens" = "Tracked model tokens"; -"Priced model spend" = "Priced model spend"; +"Tracked model tokens" = "Tokens de modelos rexistrados"; +"Priced model spend" = "Gasto de modelos con prezo"; "Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; -"No priced model history" = "No priced model history"; +"No priced model history" = "Non hai historial de modelos con prezo"; "Other" = "Other"; -"Show all %d models" = "Show all %d models"; -"Show top 20" = "Show top 20"; +"Show all %d models" = "Mostrar os %d modelos"; +"Show top 20" = "Mostrar os 20 primeiros"; "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; +"Metric" = "Métrica"; +"%d days of usage data across %d models" = "%d días de datos de uso en %d modelos"; +"%@ in · %@ out" = "%@ de entrada · %@ de saída"; "Daily estimated spend" = "Gasto diario estimado"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d xanelas completas de 5 h de cota semanal · %d xanelas ata o restablecemento"; "Weekly cannot run out before reset at this pace" = "A cota semanal non pode esgotarse antes do restablecemento a este ritmo"; @@ -1285,6 +1288,20 @@ "Agent Plan" = "Plan de axente"; "Team" = "Equipo"; +"7-day average" = "Media de 7 días"; +"Input" = "Entrada"; +"Output" = "Saída"; +"Cache write" = "Escritura de caché"; +"Reasoning" = "Razoamento"; +"%@ in" = "%@ entrada"; +"%@ out" = "%@ saída"; +"%@ cache read" = "%@ lectura de caché"; +"%@ cache write" = "%@ escritura de caché"; +"%@ reasoning" = "%@ razoamento"; +"estimated" = "estimado"; +"Estimated costs are priced from local logs and may differ from provider bills." = "Os custos estimados calcúlanse a partir dos rexistros locais a prezos de lista e poden diferir das facturas dos provedores."; +"Usage details for %@" = "Detalles de uso de %@"; + /* Menu bar layout editor */ "menu_bar_layout_title" = "Disposición"; "menu_bar_layout_footer" = "Arrastra fichas para ordenar a barra de menús. Preme nunha ficha para engadila; selecciona unha ficha colocada e preme Suprimir para retirala."; @@ -1350,3 +1367,22 @@ "Cost today unavailable" = "Custo de hoxe: Non dispoñible"; "30-day cost unavailable" = "Custo de 30 días: Non dispoñible"; "Resets" = "Reinicios"; +"Cumulative" = "Acumulado"; + +/* Token usage dashboard (usage & spend) */ +"Token activity" = "Actividade de tokens"; +"By model" = "Por modelo"; +"By tool" = "Por ferramenta"; +"View" = "Vista"; +"7-day avg" = "Media de 7 días"; +"in the last year" = "no último ano"; +"No activity in the last 12 months" = "Sen actividade nos últimos 12 meses"; +"Each column = 1 week" = "Cada columna = 1 semana"; +"Running total" = "Total acumulado"; +"Less" = "Menos"; +"More" = "Máis"; +"Mo" = "lu"; +"We" = "mé"; +"Fr" = "ve"; +"Estimated" = "Estimado"; +"No per-client model history" = "Sen historial de modelos por cliente"; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index 5295c524dc..d128f51b33 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -1272,14 +1272,17 @@ "By subscription" = "Berdasarkan langganan"; "No model-level history" = "Tidak ada riwayat tingkat model"; "Tokens" = "Tokens"; -"Tracked model tokens" = "Tracked model tokens"; -"Priced model spend" = "Priced model spend"; +"Tracked model tokens" = "Token model terlacak"; +"Priced model spend" = "Pengeluaran model berharga"; "Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; -"No priced model history" = "No priced model history"; +"No priced model history" = "Tidak ada riwayat model berharga"; "Other" = "Other"; -"Show all %d models" = "Show all %d models"; -"Show top 20" = "Show top 20"; +"Show all %d models" = "Tampilkan semua %d model"; +"Show top 20" = "Tampilkan 20 teratas"; "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; +"Metric" = "Metrik"; +"%d days of usage data across %d models" = "%d hari data penggunaan di %d model"; +"%@ in · %@ out" = "%@ masuk · %@ keluar"; "Daily estimated spend" = "Perkiraan pengeluaran harian"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d jendela 5 jam penuh dari kuota mingguan tersisa · %d jendela hingga reset"; "Weekly cannot run out before reset at this pace" = "Kuota mingguan tidak dapat habis sebelum reset dengan laju ini"; @@ -1289,6 +1292,20 @@ "Agent Plan" = "Paket Agen"; "Team" = "Tim"; +"7-day average" = "Rata-rata 7 hari"; +"Input" = "Input"; +"Output" = "Output"; +"Cache write" = "Tulis cache"; +"Reasoning" = "Penalaran"; +"%@ in" = "%@ input"; +"%@ out" = "%@ output"; +"%@ cache read" = "%@ baca cache"; +"%@ cache write" = "%@ tulis cache"; +"%@ reasoning" = "%@ penalaran"; +"estimated" = "perkiraan"; +"Estimated costs are priced from local logs and may differ from provider bills." = "Biaya perkiraan dihitung dari log lokal dengan harga daftar dan mungkin berbeda dari tagihan penyedia."; +"Usage details for %@" = "Detail penggunaan untuk %@"; + /* Menu bar layout editor */ "menu_bar_layout_title" = "Tata letak"; "menu_bar_layout_footer" = "Seret token untuk mengatur bar menu. Klik token untuk menambahkannya; pilih token yang sudah ditempatkan lalu tekan Delete untuk menghapusnya."; @@ -1354,3 +1371,22 @@ "Cost today unavailable" = "Biaya hari ini: Tidak tersedia"; "30-day cost unavailable" = "Biaya 30 hari: Tidak tersedia"; "Resets" = "Reset"; +"Cumulative" = "Kumulatif"; + +/* Token usage dashboard (usage & spend) */ +"Token activity" = "Aktivitas token"; +"By model" = "Per model"; +"By tool" = "Per alat"; +"View" = "Tampilan"; +"7-day avg" = "Rata-rata 7 hari"; +"in the last year" = "dalam setahun terakhir"; +"No activity in the last 12 months" = "Tidak ada aktivitas dalam 12 bulan terakhir"; +"Each column = 1 week" = "Setiap kolom = 1 minggu"; +"Running total" = "Total berjalan"; +"Less" = "Sedikit"; +"More" = "Banyak"; +"Mo" = "Sen"; +"We" = "Rab"; +"Fr" = "Jum"; +"Estimated" = "Perkiraan"; +"No per-client model history" = "Tidak ada riwayat model per klien"; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index f12e98e3c3..7141c4a2c6 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -1272,14 +1272,17 @@ "By subscription" = "Per abbonamento"; "No model-level history" = "Nessuna cronologia a livello di modello"; "Tokens" = "Tokens"; -"Tracked model tokens" = "Tracked model tokens"; -"Priced model spend" = "Priced model spend"; +"Tracked model tokens" = "Token dei modelli tracciati"; +"Priced model spend" = "Spesa dei modelli con prezzo"; "Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; -"No priced model history" = "No priced model history"; +"No priced model history" = "Nessuno storico dei modelli con prezzo"; "Other" = "Other"; -"Show all %d models" = "Show all %d models"; -"Show top 20" = "Show top 20"; +"Show all %d models" = "Mostra tutti i %d modelli"; +"Show top 20" = "Mostra i primi 20"; "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; +"Metric" = "Metrica"; +"%d days of usage data across %d models" = "%d giorni di dati di utilizzo su %d modelli"; +"%@ in · %@ out" = "%@ in ingresso · %@ in uscita"; "Daily estimated spend" = "Spesa giornaliera stimata"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d finestre complete da 5 h di quota settimanale · %d finestre al reset"; "Weekly cannot run out before reset at this pace" = "La quota settimanale non può esaurirsi prima del reset a questo ritmo"; @@ -1289,6 +1292,20 @@ "Agent Plan" = "Piano agente"; "Team" = "Squadra"; +"7-day average" = "Media a 7 giorni"; +"Input" = "Input"; +"Output" = "Output"; +"Cache write" = "Scrittura cache"; +"Reasoning" = "Ragionamento"; +"%@ in" = "%@ input"; +"%@ out" = "%@ output"; +"%@ cache read" = "%@ lettura cache"; +"%@ cache write" = "%@ scrittura cache"; +"%@ reasoning" = "%@ ragionamento"; +"estimated" = "stimato"; +"Estimated costs are priced from local logs and may differ from provider bills." = "I costi stimati sono calcolati dai log locali ai prezzi di listino e possono differire dalle fatture dei provider."; +"Usage details for %@" = "Dettagli di utilizzo per %@"; + /* Menu bar layout editor */ "menu_bar_layout_title" = "Disposizione"; "menu_bar_layout_footer" = "Trascina i token per disporre la barra dei menu. Fai clic su un token per aggiungerlo; seleziona un token posizionato e premi Canc per rimuoverlo."; @@ -1354,3 +1371,22 @@ "Cost today unavailable" = "Costo oggi: Non disponibile"; "30-day cost unavailable" = "Costo 30 gg: Non disponibile"; "Resets" = "Ripristini"; +"Cumulative" = "Cumulativo"; + +/* Token usage dashboard (usage & spend) */ +"Token activity" = "Attività token"; +"By model" = "Per modello"; +"By tool" = "Per strumento"; +"View" = "Vista"; +"7-day avg" = "Media 7 gg"; +"in the last year" = "nell'ultimo anno"; +"No activity in the last 12 months" = "Nessuna attività negli ultimi 12 mesi"; +"Each column = 1 week" = "Ogni colonna = 1 settimana"; +"Running total" = "Totale progressivo"; +"Less" = "Meno"; +"More" = "Più"; +"Mo" = "lu"; +"We" = "me"; +"Fr" = "ve"; +"Estimated" = "Stimato"; +"No per-client model history" = "Nessuna cronologia modelli per client"; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index 3ffae5aef7..0435b993d6 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -1269,14 +1269,17 @@ "By subscription" = "サブスクリプション別"; "No model-level history" = "モデル別の履歴はありません"; "Tokens" = "Tokens"; -"Tracked model tokens" = "Tracked model tokens"; -"Priced model spend" = "Priced model spend"; +"Tracked model tokens" = "追跡されたモデルトークン"; +"Priced model spend" = "価格設定済みモデルの支出"; "Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; -"No priced model history" = "No priced model history"; +"No priced model history" = "価格設定済みモデルの履歴がありません"; "Other" = "Other"; -"Show all %d models" = "Show all %d models"; -"Show top 20" = "Show top 20"; +"Show all %d models" = "すべての %d モデルを表示"; +"Show top 20" = "上位 20 件を表示"; "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; +"Metric" = "指標"; +"%d days of usage data across %d models" = "%2$dモデルにわたる%1$d日間の使用状況データ"; +"%@ in · %@ out" = "%@ 入力 · %@ 出力"; "Daily estimated spend" = "日別推定支出"; "≈%d full 5h windows of weekly left · %d windows until reset" = "週間枠は約%d回分の完全な5時間ウィンドウ · リセットまで%d回"; "Weekly cannot run out before reset at this pace" = "このペースではリセット前に週間枠を使い切れません"; @@ -1286,6 +1289,20 @@ "Agent Plan" = "エージェントプラン"; "Team" = "チーム"; +"7-day average" = "7日平均"; +"Input" = "入力"; +"Output" = "出力"; +"Cache write" = "キャッシュ書き込み"; +"Reasoning" = "推論"; +"%@ in" = "%@ 入力"; +"%@ out" = "%@ 出力"; +"%@ cache read" = "%@ キャッシュ読み取り"; +"%@ cache write" = "%@ キャッシュ書き込み"; +"%@ reasoning" = "%@ 推論"; +"estimated" = "推定"; +"Estimated costs are priced from local logs and may differ from provider bills." = "推定コストはローカルログから定価で計算されており、プロバイダーの請求と異なる場合があります。"; +"Usage details for %@" = "%@ の使用詳細"; + /* Menu bar layout editor */ "menu_bar_layout_title" = "レイアウト"; "menu_bar_layout_footer" = "トークンをドラッグしてメニューバーを並べます。クリックすると追加でき、配置済みのトークンを選択して Delete キーを押すと削除できます。"; @@ -1351,3 +1368,22 @@ "Cost today unavailable" = "今日のコスト: 利用不可"; "30-day cost unavailable" = "30日間のコスト: 利用不可"; "Resets" = "リセット"; +"Cumulative" = "累計"; + +/* Token usage dashboard (usage & spend) */ +"Token activity" = "トークンアクティビティ"; +"By model" = "モデル別"; +"By tool" = "ツール別"; +"View" = "表示"; +"7-day avg" = "7日平均"; +"in the last year" = "過去1年"; +"No activity in the last 12 months" = "過去12か月にアクティビティなし"; +"Each column = 1 week" = "各列 = 1週間"; +"Running total" = "累計"; +"Less" = "少"; +"More" = "多"; +"Mo" = "月"; +"We" = "水"; +"Fr" = "金"; +"Estimated" = "推定"; +"No per-client model history" = "クライアント別のモデル履歴なし"; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index 2134d0b08a..7de240d82d 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -1236,14 +1236,17 @@ "By subscription" = "구독별"; "No model-level history" = "모델별 내역이 없습니다"; "Tokens" = "Tokens"; -"Tracked model tokens" = "Tracked model tokens"; -"Priced model spend" = "Priced model spend"; +"Tracked model tokens" = "추적된 모델 토큰"; +"Priced model spend" = "가격이 책정된 모델 지출"; "Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; -"No priced model history" = "No priced model history"; +"No priced model history" = "가격이 책정된 모델 기록 없음"; "Other" = "Other"; -"Show all %d models" = "Show all %d models"; -"Show top 20" = "Show top 20"; +"Show all %d models" = "%d개 모델 모두 표시"; +"Show top 20" = "상위 20개 표시"; "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; +"Metric" = "지표"; +"%d days of usage data across %d models" = "%2$d개 모델의 %1$d일간 사용량 데이터"; +"%@ in · %@ out" = "%@ 입력 · %@ 출력"; "Daily estimated spend" = "일별 예상 지출"; "≈%d full 5h windows of weekly left · %d windows until reset" = "주간 한도 약 %d개의 전체 5시간 창 남음 · 재설정까지 %d개 창"; "Weekly cannot run out before reset at this pace" = "이 속도라면 재설정 전에 주간 한도를 소진할 수 없습니다"; @@ -1253,6 +1256,20 @@ "Agent Plan" = "에이전트 요금제"; "Team" = "팀"; +"7-day average" = "7일 평균"; +"Input" = "입력"; +"Output" = "출력"; +"Cache write" = "캐시 쓰기"; +"Reasoning" = "추론"; +"%@ in" = "%@ 입력"; +"%@ out" = "%@ 출력"; +"%@ cache read" = "%@ 캐시 읽기"; +"%@ cache write" = "%@ 캐시 쓰기"; +"%@ reasoning" = "%@ 추론"; +"estimated" = "추정"; +"Estimated costs are priced from local logs and may differ from provider bills." = "추정 비용은 로컬 로그를 기준으로 정가로 계산되며, 제공업체 청구서와 다를 수 있습니다."; +"Usage details for %@" = "%@ 사용량 세부 정보"; + /* Menu bar layout editor */ "menu_bar_layout_title" = "레이아웃"; "menu_bar_layout_footer" = "토큰을 드래그해 메뉴 막대를 배치하세요. 토큰을 클릭하면 추가되고, 배치된 토큰을 선택한 뒤 Delete 키를 누르면 제거됩니다."; @@ -1318,3 +1335,22 @@ "Cost today unavailable" = "오늘 비용: 사용할 수 없음"; "30-day cost unavailable" = "30일 비용: 사용할 수 없음"; "Resets" = "재설정"; +"Cumulative" = "누적"; + +/* Token usage dashboard (usage & spend) */ +"Token activity" = "토큰 활동"; +"By model" = "모델별"; +"By tool" = "도구별"; +"View" = "보기"; +"7-day avg" = "7일 평균"; +"in the last year" = "지난 1년"; +"No activity in the last 12 months" = "지난 12개월 동안 활동 없음"; +"Each column = 1 week" = "각 열 = 1주"; +"Running total" = "누적 합계"; +"Less" = "적음"; +"More" = "많음"; +"Mo" = "월"; +"We" = "수"; +"Fr" = "금"; +"Estimated" = "추정"; +"No per-client model history" = "클이언트별 모델 기록 없음"; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index 89b918540d..ecda921645 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -1268,14 +1268,17 @@ "By subscription" = "Per abonnement"; "No model-level history" = "Geen geschiedenis op modelniveau"; "Tokens" = "Tokens"; -"Tracked model tokens" = "Tracked model tokens"; -"Priced model spend" = "Priced model spend"; +"Tracked model tokens" = "Bijgehouden modeltokens"; +"Priced model spend" = "Geprijsde modeluitgaven"; "Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; -"No priced model history" = "No priced model history"; +"No priced model history" = "Geen geschiedenis van geprijsde modellen"; "Other" = "Other"; -"Show all %d models" = "Show all %d models"; -"Show top 20" = "Show top 20"; +"Show all %d models" = "Toon alle %d modellen"; +"Show top 20" = "Toon top 20"; "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; +"Metric" = "Metriek"; +"%d days of usage data across %d models" = "%d dagen aan gebruiksgegevens voor %d modellen"; +"%@ in · %@ out" = "%@ in · %@ uit"; "Daily estimated spend" = "Geschatte dagelijkse uitgaven"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d volledige vensters van 5 uur aan weeklimiet over · %d vensters tot reset"; "Weekly cannot run out before reset at this pace" = "Het weeklimiet kan bij dit tempo niet vóór de reset opraken"; @@ -1285,6 +1288,20 @@ "Agent Plan" = "Agentplan"; "Team" = "Team"; +"7-day average" = "7-daags gemiddelde"; +"Input" = "Invoer"; +"Output" = "Uitvoer"; +"Cache write" = "Cache schrijven"; +"Reasoning" = "Redenering"; +"%@ in" = "%@ invoer"; +"%@ out" = "%@ uitvoer"; +"%@ cache read" = "%@ cache lezen"; +"%@ cache write" = "%@ cache schrijven"; +"%@ reasoning" = "%@ redenering"; +"estimated" = "geschat"; +"Estimated costs are priced from local logs and may differ from provider bills." = "Geschatte kosten worden berekend uit lokale logs tegen catalogusprijzen en kunnen afwijken van de facturen van providers."; +"Usage details for %@" = "Gebruiksdetails voor %@"; + /* Menu bar layout editor */ "menu_bar_layout_title" = "Indeling"; "menu_bar_layout_footer" = "Sleep tokens om de menubalk in te delen. Klik op een token om deze toe te voegen; selecteer een geplaatst token en druk op Delete om het te verwijderen."; @@ -1350,3 +1367,22 @@ "Cost today unavailable" = "Kosten vandaag: Niet beschikbaar"; "30-day cost unavailable" = "Kosten 30 dagen: Niet beschikbaar"; "Resets" = "Resets"; +"Cumulative" = "Cumulatief"; + +/* Token usage dashboard (usage & spend) */ +"Token activity" = "Token-activiteit"; +"By model" = "Per model"; +"By tool" = "Per tool"; +"View" = "Weergave"; +"7-day avg" = "7-daags gem."; +"in the last year" = "in het afgelopen jaar"; +"No activity in the last 12 months" = "Geen activiteit in de afgelopen 12 maanden"; +"Each column = 1 week" = "Elke kolom = 1 week"; +"Running total" = "Lopend totaal"; +"Less" = "Minder"; +"More" = "Meer"; +"Mo" = "ma"; +"We" = "wo"; +"Fr" = "vr"; +"Estimated" = "Geschat"; +"No per-client model history" = "Geen modelgeschiedenis per client"; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index bdd6a4ff9d..20973a0c1b 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -1272,14 +1272,17 @@ "By subscription" = "Według subskrypcji"; "No model-level history" = "Brak historii na poziomie modeli"; "Tokens" = "Tokens"; -"Tracked model tokens" = "Tracked model tokens"; -"Priced model spend" = "Priced model spend"; +"Tracked model tokens" = "Śledzone tokeny modeli"; +"Priced model spend" = "Wycenione wydatki modeli"; "Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; -"No priced model history" = "No priced model history"; +"No priced model history" = "Brak historii wycenionych modeli"; "Other" = "Other"; -"Show all %d models" = "Show all %d models"; -"Show top 20" = "Show top 20"; +"Show all %d models" = "Pokaż wszystkie modele (%d)"; +"Show top 20" = "Pokaż 20 najlepszych"; "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; +"Metric" = "Metryka"; +"%d days of usage data across %d models" = "%d dni danych użycia dla %d modeli"; +"%@ in · %@ out" = "%@ wej. · %@ wyj."; "Daily estimated spend" = "Szacowane dzienne wydatki"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d pełnych 5-godz. okien limitu tygodniowego · %d okien do resetu"; "Weekly cannot run out before reset at this pace" = "Przy tym tempie limit tygodniowy nie może wyczerpać się przed resetem"; @@ -1289,6 +1292,20 @@ "Agent Plan" = "Plan agenta"; "Team" = "Zespół"; +"7-day average" = "Średnia z 7 dni"; +"Input" = "Wejście"; +"Output" = "Wyjście"; +"Cache write" = "Zapis pamięci podręcznej"; +"Reasoning" = "Wnioskowanie"; +"%@ in" = "%@ wejście"; +"%@ out" = "%@ wyjście"; +"%@ cache read" = "%@ odczyt pamięci podręcznej"; +"%@ cache write" = "%@ zapis pamięci podręcznej"; +"%@ reasoning" = "%@ wnioskowanie"; +"estimated" = "szacunkowe"; +"Estimated costs are priced from local logs and may differ from provider bills." = "Szacunkowe koszty są obliczane na podstawie lokalnych dzienników według cen katalogowych i mogą się różnić od faktur dostawców."; +"Usage details for %@" = "Szczegóły użycia dla %@"; + /* Menu bar layout editor */ "menu_bar_layout_title" = "Układ"; "menu_bar_layout_footer" = "Przeciągaj elementy, aby ułożyć pasek menu. Kliknij element, aby go dodać; zaznacz umieszczony element i naciśnij Delete, aby go usunąć."; @@ -1354,3 +1371,22 @@ "Cost today unavailable" = "Koszt dzisiaj: Niedostępne"; "30-day cost unavailable" = "Koszt 30 dni: Niedostępne"; "Resets" = "Resety"; +"Cumulative" = "Łącznie"; + +/* Token usage dashboard (usage & spend) */ +"Token activity" = "Aktywność tokenów"; +"By model" = "Według modelu"; +"By tool" = "Według narzędzia"; +"View" = "Widok"; +"7-day avg" = "Śr. 7-dniowa"; +"in the last year" = "w ciągu ostatniego roku"; +"No activity in the last 12 months" = "Brak aktywności w ciągu ostatnich 12 miesięcy"; +"Each column = 1 week" = "Każda kolumna = 1 tydzień"; +"Running total" = "Suma narastająca"; +"Less" = "Mniej"; +"More" = "Więcej"; +"Mo" = "pn"; +"We" = "śr"; +"Fr" = "pt"; +"Estimated" = "Szacunkowo"; +"No per-client model history" = "Brak historii modeli na klienta"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index 85783c7fe0..4a5b7cc88a 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -1269,14 +1269,17 @@ "By subscription" = "Por assinatura"; "No model-level history" = "Sem histórico por modelo"; "Tokens" = "Tokens"; -"Tracked model tokens" = "Tracked model tokens"; -"Priced model spend" = "Priced model spend"; +"Tracked model tokens" = "Tokens de modelos rastreados"; +"Priced model spend" = "Gasto de modelos precificados"; "Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; -"No priced model history" = "No priced model history"; +"No priced model history" = "Sem histórico de modelos precificados"; "Other" = "Other"; -"Show all %d models" = "Show all %d models"; -"Show top 20" = "Show top 20"; +"Show all %d models" = "Mostrar todos os %d modelos"; +"Show top 20" = "Mostrar os 20 primeiros"; "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; +"Metric" = "Métrica"; +"%d days of usage data across %d models" = "%d dias de dados de uso em %d modelos"; +"%@ in · %@ out" = "%@ de entrada · %@ de saída"; "Daily estimated spend" = "Gasto diário estimado"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d janelas completas de 5 h da cota semanal · %d janelas até a renovação"; "Weekly cannot run out before reset at this pace" = "A cota semanal não pode acabar antes da renovação nesse ritmo"; @@ -1286,6 +1289,20 @@ "Agent Plan" = "Plano de agente"; "Team" = "Equipe"; +"7-day average" = "Média de 7 dias"; +"Input" = "Entrada"; +"Output" = "Saída"; +"Cache write" = "Gravação de cache"; +"Reasoning" = "Raciocínio"; +"%@ in" = "%@ entrada"; +"%@ out" = "%@ saída"; +"%@ cache read" = "%@ leitura de cache"; +"%@ cache write" = "%@ gravação de cache"; +"%@ reasoning" = "%@ raciocínio"; +"estimated" = "estimado"; +"Estimated costs are priced from local logs and may differ from provider bills." = "Os custos estimados são calculados a partir de logs locais a preços de tabela e podem diferir das faturas dos provedores."; +"Usage details for %@" = "Detalhes de uso de %@"; + /* Menu bar layout editor */ "menu_bar_layout_title" = "Layout"; "menu_bar_layout_footer" = "Arraste os itens para organizar a barra de menus. Clique em um item para adicioná-lo; selecione um item posicionado e pressione Delete para removê-lo."; @@ -1351,3 +1368,22 @@ "Cost today unavailable" = "Custo hoje: Indisponível"; "30-day cost unavailable" = "Custo em 30 dias: Indisponível"; "Resets" = "Redefinições"; +"Cumulative" = "Acumulado"; + +/* Token usage dashboard (usage & spend) */ +"Token activity" = "Atividade de tokens"; +"By model" = "Por modelo"; +"By tool" = "Por ferramenta"; +"View" = "Visualização"; +"7-day avg" = "Média de 7 dias"; +"in the last year" = "no último ano"; +"No activity in the last 12 months" = "Sem atividade nos últimos 12 meses"; +"Each column = 1 week" = "Cada coluna = 1 semana"; +"Running total" = "Total acumulado"; +"Less" = "Menos"; +"More" = "Mais"; +"Mo" = "seg"; +"We" = "qua"; +"Fr" = "sex"; +"Estimated" = "Estimado"; +"No per-client model history" = "Sem histórico de modelos por cliente"; diff --git a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings index bb71e16a2c..e829715503 100644 --- a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings @@ -1270,14 +1270,17 @@ "By subscription" = "По подпискам"; "No model-level history" = "Нет истории по моделям"; "Tokens" = "Tokens"; -"Tracked model tokens" = "Tracked model tokens"; -"Priced model spend" = "Priced model spend"; +"Tracked model tokens" = "Отслеживаемые токены моделей"; +"Priced model spend" = "Расходы по тарифицированным моделям"; "Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; -"No priced model history" = "No priced model history"; +"No priced model history" = "Нет истории по тарифицированным моделям"; "Other" = "Other"; -"Show all %d models" = "Show all %d models"; -"Show top 20" = "Show top 20"; +"Show all %d models" = "Показать все модели (%d)"; +"Show top 20" = "Показать первые 20"; "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; +"Metric" = "Метрика"; +"%d days of usage data across %d models" = "Данные об использовании за %d дней по %d моделям"; +"%@ in · %@ out" = "%@ вх. · %@ исх."; "Daily estimated spend" = "Предполагаемые ежедневные расходы"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d полных 5-часовых окон недельного лимита · %d окон до сброса"; "Weekly cannot run out before reset at this pace" = "При таком темпе недельный лимит не может закончиться до сброса"; @@ -1287,6 +1290,20 @@ "Agent Plan" = "План агента"; "Team" = "Команда"; +"7-day average" = "Среднее за 7 дней"; +"Input" = "Входные"; +"Output" = "Выходные"; +"Cache write" = "Запись кэша"; +"Reasoning" = "Рассуждения"; +"%@ in" = "%@ входные"; +"%@ out" = "%@ выходные"; +"%@ cache read" = "%@ чтение кэша"; +"%@ cache write" = "%@ запись кэша"; +"%@ reasoning" = "%@ рассуждения"; +"estimated" = "оценка"; +"Estimated costs are priced from local logs and may differ from provider bills." = "Оценочные затраты рассчитываются по локальным журналам по прейскурантным ценам и могут отличаться от счетов провайдеров."; +"Usage details for %@" = "Сведения об использовании за %@"; + /* Menu bar layout editor */ "menu_bar_layout_title" = "Компоновка"; "menu_bar_layout_footer" = "Перетаскивайте элементы, чтобы настроить строку меню. Нажмите элемент, чтобы добавить его; выберите размещённый элемент и нажмите Delete, чтобы удалить его."; @@ -1352,3 +1369,22 @@ "Cost today unavailable" = "Расход сегодня: Недоступно"; "30-day cost unavailable" = "Расход за 30 дней: Недоступно"; "Resets" = "Сбросы"; +"Cumulative" = "Накопительно"; + +/* Token usage dashboard (usage & spend) */ +"Token activity" = "Активность токенов"; +"By model" = "По модели"; +"By tool" = "По инструменту"; +"View" = "Вид"; +"7-day avg" = "Ср. за 7 дн."; +"in the last year" = "за последний год"; +"No activity in the last 12 months" = "Нет активности за последние 12 месяцев"; +"Each column = 1 week" = "Каждый столбец = 1 неделя"; +"Running total" = "Накопленный итог"; +"Less" = "Меньше"; +"More" = "Больше"; +"Mo" = "пн"; +"We" = "ср"; +"Fr" = "пт"; +"Estimated" = "Оценочно"; +"No per-client model history" = "Нет истории моделей по клиентам"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index 0cc76dbbae..255f7e1a67 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -1267,14 +1267,17 @@ "By subscription" = "Per abonnemang"; "No model-level history" = "Ingen historik på modellnivå"; "Tokens" = "Tokens"; -"Tracked model tokens" = "Tracked model tokens"; -"Priced model spend" = "Priced model spend"; +"Tracked model tokens" = "Spårade modelltoken"; +"Priced model spend" = "Prissatta modellutgifter"; "Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; -"No priced model history" = "No priced model history"; +"No priced model history" = "Ingen historik för prissatta modeller"; "Other" = "Other"; -"Show all %d models" = "Show all %d models"; -"Show top 20" = "Show top 20"; +"Show all %d models" = "Visa alla %d modeller"; +"Show top 20" = "Visa topp 20"; "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; +"Metric" = "Mått"; +"%d days of usage data across %d models" = "%d dagar med användningsdata för %d modeller"; +"%@ in · %@ out" = "%@ in · %@ ut"; "Daily estimated spend" = "Uppskattade dagliga utgifter"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d fulla 5-timmarsfönster av veckokvoten kvar · %d fönster till återställning"; "Weekly cannot run out before reset at this pace" = "Veckokvoten kan inte ta slut före återställningen i den här takten"; @@ -1284,6 +1287,20 @@ "Agent Plan" = "Agentplan"; "Team" = "Team"; +"7-day average" = "7-dagars medelvärde"; +"Input" = "Indata"; +"Output" = "Utdata"; +"Cache write" = "Cacheskrivning"; +"Reasoning" = "Resonemang"; +"%@ in" = "%@ indata"; +"%@ out" = "%@ utdata"; +"%@ cache read" = "%@ cacheläsning"; +"%@ cache write" = "%@ cacheskrivning"; +"%@ reasoning" = "%@ resonemang"; +"estimated" = "uppskattat"; +"Estimated costs are priced from local logs and may differ from provider bills." = "Uppskattade kostnader beräknas från lokala loggar till listpriser och kan skilja sig från leverantörernas fakturor."; +"Usage details for %@" = "Användningsdetaljer för %@"; + /* Menu bar layout editor */ "menu_bar_layout_title" = "Layout"; "menu_bar_layout_footer" = "Dra brickor för att ordna menyraden. Klicka på en bricka för att lägga till den; markera en placerad bricka och tryck Delete för att ta bort den."; @@ -1349,3 +1366,22 @@ "Cost today unavailable" = "Kostnad idag: Inte tillgänglig"; "30-day cost unavailable" = "Kostnad 30 dagar: Inte tillgänglig"; "Resets" = "Återställningar"; +"Cumulative" = "Kumulativt"; + +/* Token usage dashboard (usage & spend) */ +"Token activity" = "Token-aktivitet"; +"By model" = "Per modell"; +"By tool" = "Per verktyg"; +"View" = "Vy"; +"7-day avg" = "7-dagars snitt"; +"in the last year" = "det senaste året"; +"No activity in the last 12 months" = "Ingen aktivitet de senaste 12 månaderna"; +"Each column = 1 week" = "Varje kolumn = 1 vecka"; +"Running total" = "Löpande summa"; +"Less" = "Mindre"; +"More" = "Mer"; +"Mo" = "må"; +"We" = "on"; +"Fr" = "fr"; +"Estimated" = "Uppskattat"; +"No per-client model history" = "Ingen modellhistorik per klient"; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index 31c705655c..bf1760cf12 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -1272,14 +1272,17 @@ "By subscription" = "แยกตามการสมัครสมาชิก"; "No model-level history" = "ไม่มีประวัติระดับโมเดล"; "Tokens" = "Tokens"; -"Tracked model tokens" = "Tracked model tokens"; -"Priced model spend" = "Priced model spend"; +"Tracked model tokens" = "โทเค็นโมเดลที่ติดตาม"; +"Priced model spend" = "ค่าใช้จ่ายโมเดลที่กำหนดราคา"; "Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; -"No priced model history" = "No priced model history"; +"No priced model history" = "ไม่มีประวัติโมเดลที่กำหนดราคา"; "Other" = "Other"; -"Show all %d models" = "Show all %d models"; -"Show top 20" = "Show top 20"; +"Show all %d models" = "แสดงโมเดลทั้งหมด %d รายการ"; +"Show top 20" = "แสดง 20 อันดับแรก"; "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; +"Metric" = "ตัวชี้วัด"; +"%d days of usage data across %d models" = "ข้อมูลการใช้งาน %d วันในโมเดล %d"; +"%@ in · %@ out" = "%@ ขาเข้า · %@ ขาออก"; "Daily estimated spend" = "ค่าใช้จ่ายรายวันโดยประมาณ"; "≈%d full 5h windows of weekly left · %d windows until reset" = "เหลือโควตารายสัปดาห์ ≈%d ช่วงเต็ม 5 ชม. · อีก %d ช่วงจนรีเซ็ต"; "Weekly cannot run out before reset at this pace" = "ด้วยอัตรานี้ โควตารายสัปดาห์จะไม่หมดก่อนรีเซ็ต"; @@ -1289,6 +1292,20 @@ "Agent Plan" = "แผนเอเจนต์"; "Team" = "ทีม"; +"7-day average" = "ค่าเฉลี่ย 7 วัน"; +"Input" = "อินพุต"; +"Output" = "เอาต์พุต"; +"Cache write" = "เขียนแคช"; +"Reasoning" = "การให้เหตุผล"; +"%@ in" = "%@ อินพุต"; +"%@ out" = "%@ เอาต์พุต"; +"%@ cache read" = "%@ อ่านแคช"; +"%@ cache write" = "%@ เขียนแคช"; +"%@ reasoning" = "%@ การให้เหตุผล"; +"estimated" = "โดยประมาณ"; +"Estimated costs are priced from local logs and may differ from provider bills." = "ค่าใช้จ่ายโดยประมาณคำนวณจากบันทึกในเครื่องตามราคาประกาศ และอาจแตกต่างจากใบแจ้งหนี้ของผู้ให้บริการ"; +"Usage details for %@" = "รายละเอียดการใช้งานสำหรับ %@"; + /* Menu bar layout editor */ "menu_bar_layout_title" = "เค้าโครง"; "menu_bar_layout_footer" = "ลากโทเค็นเพื่อจัดเรียงแถบเมนู คลิกโทเค็นเพื่อเพิ่ม เลือกโทเค็นที่วางแล้วและกด Delete เพื่อลบ"; @@ -1354,3 +1371,22 @@ "Cost today unavailable" = "ค่าใช้จ่ายวันนี้: ไม่พร้อมใช้งาน"; "30-day cost unavailable" = "ค่าใช้จ่าย 30 วัน: ไม่พร้อมใช้งาน"; "Resets" = "การรีเซ็ต"; +"Cumulative" = "สะสม"; + +/* Token usage dashboard (usage & spend) */ +"Token activity" = "กิจกรรมโทเทน"; +"By model" = "ตามโมเดล"; +"By tool" = "ตามเครื่องมือ"; +"View" = "มุมมอง"; +"7-day avg" = "ค่าเฉลี่ย 7 วัน"; +"in the last year" = "ในปีที่ผ่านมา"; +"No activity in the last 12 months" = "ไม่มีกิจกรรมในช่วง 12 เดือนที่ผ่านมา"; +"Each column = 1 week" = "แต่ละคอลัมน์ = 1 สัปดาห์"; +"Running total" = "ยอดรวมสะสม"; +"Less" = "น้อย"; +"More" = "มาก"; +"Mo" = "จ"; +"We" = "พ"; +"Fr" = "ศ"; +"Estimated" = "โดยประมาณ"; +"No per-client model history" = "ไม่มีประวัติโมเดลต่อไคลเอนต์"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index 320c8c6825..d46544e2bf 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -1270,14 +1270,17 @@ "By subscription" = "Aboneliğe göre"; "No model-level history" = "Model düzeyinde geçmiş yok"; "Tokens" = "Tokens"; -"Tracked model tokens" = "Tracked model tokens"; -"Priced model spend" = "Priced model spend"; +"Tracked model tokens" = "İzlenen model jetonları"; +"Priced model spend" = "Fiyatlandırılan model harcamaları"; "Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; -"No priced model history" = "No priced model history"; +"No priced model history" = "Fiyatlandırılmış model geçmişi yok"; "Other" = "Other"; -"Show all %d models" = "Show all %d models"; -"Show top 20" = "Show top 20"; +"Show all %d models" = "Tüm %d modeli göster"; +"Show top 20" = "İlk 20'yi göster"; "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; +"Metric" = "Ölçüt"; +"%d days of usage data across %d models" = "%2$d model için %1$d günlük kullanım verisi"; +"%@ in · %@ out" = "%@ girdi · %@ çıktı"; "Daily estimated spend" = "Günlük tahmini harcama"; "≈%d full 5h windows of weekly left · %d windows until reset" = "Haftalık kotadan ≈%d tam 5 saatlik pencere kaldı · sıfırlamaya %d pencere"; "Weekly cannot run out before reset at this pace" = "Bu hızda haftalık kota sıfırlamadan önce tükenemez"; @@ -1287,6 +1290,20 @@ "Agent Plan" = "Ajan Planı"; "Team" = "Ekip"; +"7-day average" = "7 günlük ortalama"; +"Input" = "Girdi"; +"Output" = "Çıktı"; +"Cache write" = "Önbellek yazma"; +"Reasoning" = "Muhakeme"; +"%@ in" = "%@ girdi"; +"%@ out" = "%@ çıktı"; +"%@ cache read" = "%@ önbellek okuma"; +"%@ cache write" = "%@ önbellek yazma"; +"%@ reasoning" = "%@ muhakeme"; +"estimated" = "tahmini"; +"Estimated costs are priced from local logs and may differ from provider bills." = "Tahmini maliyetler yerel günlüklerden liste fiyatlarıyla hesaplanır ve sağlayıcı faturalarından farklı olabilir."; +"Usage details for %@" = "%@ için kullanım ayrıntıları"; + /* Menu bar layout editor */ "menu_bar_layout_title" = "Düzen"; "menu_bar_layout_footer" = "Menü çubuğunu düzenlemek için belirteçleri sürükleyin. Eklemek için bir belirtece tıklayın; yerleştirilmiş bir belirteci seçip silmek için Delete tuşuna basın."; @@ -1352,3 +1369,22 @@ "Cost today unavailable" = "Bugünkü maliyet: Kullanılamıyor"; "30-day cost unavailable" = "30 günlük maliyet: Kullanılamıyor"; "Resets" = "Sıfırlamalar"; +"Cumulative" = "Kümülatif"; + +/* Token usage dashboard (usage & spend) */ +"Token activity" = "Token etkinliği"; +"By model" = "Modele göre"; +"By tool" = "Araca göre"; +"View" = "Görünüm"; +"7-day avg" = "7 günlük ort."; +"in the last year" = "son yılda"; +"No activity in the last 12 months" = "Son 12 ayda etkinlik yok"; +"Each column = 1 week" = "Her sütun = 1 hafta"; +"Running total" = "Birikimli toplam"; +"Less" = "Az"; +"More" = "Çok"; +"Mo" = "Pt"; +"We" = "Ça"; +"Fr" = "Cu"; +"Estimated" = "Tahmini"; +"No per-client model history" = "İstemci başına model geçmişi yok"; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index 2d99f9662c..082956ac86 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -1268,14 +1268,17 @@ "By subscription" = "За підписками"; "No model-level history" = "Немає історії за моделями"; "Tokens" = "Tokens"; -"Tracked model tokens" = "Tracked model tokens"; -"Priced model spend" = "Priced model spend"; +"Tracked model tokens" = "Відстежувані токени моделей"; +"Priced model spend" = "Витрати на моделі з ціною"; "Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; -"No priced model history" = "No priced model history"; +"No priced model history" = "Немає історії моделей з ціною"; "Other" = "Other"; -"Show all %d models" = "Show all %d models"; -"Show top 20" = "Show top 20"; +"Show all %d models" = "Показати всі моделі (%d)"; +"Show top 20" = "Показати перші 20"; "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; +"Metric" = "Метрика"; +"%d days of usage data across %d models" = "%d днів використання даних у %d моделях"; +"%@ in · %@ out" = "%@ вх. · %@ вих."; "Daily estimated spend" = "Орієнтовні щоденні витрати"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d повних 5-годинних вікон тижневого ліміту · %d вікон до скидання"; "Weekly cannot run out before reset at this pace" = "За такого темпу тижневий ліміт не може вичерпатися до скидання"; @@ -1285,6 +1288,20 @@ "Agent Plan" = "План агента"; "Team" = "Команда"; +"7-day average" = "Середнє за 7 днів"; +"Input" = "Вхідні"; +"Output" = "Вихідні"; +"Cache write" = "Запис кешу"; +"Reasoning" = "Міркування"; +"%@ in" = "%@ вхідні"; +"%@ out" = "%@ вихідні"; +"%@ cache read" = "%@ читання кешу"; +"%@ cache write" = "%@ запис кешу"; +"%@ reasoning" = "%@ міркування"; +"estimated" = "оцінка"; +"Estimated costs are priced from local logs and may differ from provider bills." = "Оціночні витрати обчислюються з локальних журналів за прейскурантними цінами й можуть відрізнятися від рахунків постачальників."; +"Usage details for %@" = "Деталі використання за %@"; + /* Menu bar layout editor */ "menu_bar_layout_title" = "Компонування"; "menu_bar_layout_footer" = "Перетягуйте елементи, щоб упорядкувати смугу меню. Натисніть елемент, щоб додати його; виберіть розміщений елемент і натисніть Delete, щоб видалити його."; @@ -1350,3 +1367,22 @@ "Cost today unavailable" = "Вартість сьогодні: Недоступний"; "30-day cost unavailable" = "Вартість за 30 днів: Недоступний"; "Resets" = "Скидання"; +"Cumulative" = "Наростаючим підсумком"; + +/* Token usage dashboard (usage & spend) */ +"Token activity" = "Активність токенів"; +"By model" = "За моделлю"; +"By tool" = "За інструментом"; +"View" = "Вигляд"; +"7-day avg" = "Сер. за 7 дн."; +"in the last year" = "за останній рік"; +"No activity in the last 12 months" = "Немає активності за останні 12 місяців"; +"Each column = 1 week" = "Кожен стовпець = 1 тиждень"; +"Running total" = "Накопичений підсумок"; +"Less" = "Менше"; +"More" = "Більше"; +"Mo" = "пн"; +"We" = "ср"; +"Fr" = "пт"; +"Estimated" = "Оціночно"; +"No per-client model history" = "Немає історії моделей за клієнтами"; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index 69a79fbeec..729e9acd2b 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -1269,14 +1269,17 @@ "By subscription" = "Theo gói đăng ký"; "No model-level history" = "Không có lịch sử theo mô hình"; "Tokens" = "Tokens"; -"Tracked model tokens" = "Tracked model tokens"; -"Priced model spend" = "Priced model spend"; +"Tracked model tokens" = "Token mô hình được theo dõi"; +"Priced model spend" = "Chi tiêu mô hình đã định giá"; "Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; -"No priced model history" = "No priced model history"; +"No priced model history" = "Không có lịch sử mô hình đã định giá"; "Other" = "Other"; -"Show all %d models" = "Show all %d models"; -"Show top 20" = "Show top 20"; +"Show all %d models" = "Hiển thị tất cả %d mô hình"; +"Show top 20" = "Hiển thị 20 hàng đầu"; "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; +"Metric" = "Chỉ số"; +"%d days of usage data across %d models" = "%d ngày của dữ liệu Mức sử dụng trên %d mô hình"; +"%@ in · %@ out" = "%@ đầu vào · %@ đầu ra"; "Daily estimated spend" = "Chi tiêu ước tính hằng ngày"; "≈%d full 5h windows of weekly left · %d windows until reset" = "Còn ≈%d cửa sổ 5 giờ đầy đủ của hạn mức tuần · %d cửa sổ đến khi đặt lại"; "Weekly cannot run out before reset at this pace" = "Với tốc độ này, hạn mức tuần không thể hết trước khi đặt lại"; @@ -1286,6 +1289,20 @@ "Agent Plan" = "Gói tác nhân"; "Team" = "Nhóm"; +"7-day average" = "Trung bình 7 ngày"; +"Input" = "Đầu vào"; +"Output" = "Đầu ra"; +"Cache write" = "Ghi bộ nhớ đệm"; +"Reasoning" = "Suy luận"; +"%@ in" = "%@ đầu vào"; +"%@ out" = "%@ đầu ra"; +"%@ cache read" = "%@ đọc bộ nhớ đệm"; +"%@ cache write" = "%@ ghi bộ nhớ đệm"; +"%@ reasoning" = "%@ suy luận"; +"estimated" = "ước tính"; +"Estimated costs are priced from local logs and may differ from provider bills." = "Chi phí ước tính được tính từ nhật ký cục bộ theo giá niêm yết và có thể khác với hóa đơn của nhà cung cấp."; +"Usage details for %@" = "Chi tiết sử dụng cho %@"; + /* Menu bar layout editor */ "menu_bar_layout_title" = "Bố cục"; "menu_bar_layout_footer" = "Kéo các thẻ để sắp xếp thanh menu. Bấm vào thẻ để thêm; chọn thẻ đã đặt rồi nhấn Delete để xóa."; @@ -1351,3 +1368,22 @@ "Cost today unavailable" = "Chi phí hôm nay: Không có sẵn"; "30-day cost unavailable" = "Chi phí 30 ngày: Không có sẵn"; "Resets" = "Lần đặt lại"; +"Cumulative" = "Tích lũy"; + +/* Token usage dashboard (usage & spend) */ +"Token activity" = "Hoạt động token"; +"By model" = "Theo mô hình"; +"By tool" = "Theo công cụ"; +"View" = "Chế độ xem"; +"7-day avg" = "TB 7 ngày"; +"in the last year" = "trong năm qua"; +"No activity in the last 12 months" = "Không có hoạt động trong 12 tháng qua"; +"Each column = 1 week" = "Mỗi cột = 1 tuần"; +"Running total" = "Tổng lũy kế"; +"Less" = "Ít"; +"More" = "Nhiều"; +"Mo" = "T2"; +"We" = "T4"; +"Fr" = "T6"; +"Estimated" = "Ước tính"; +"No per-client model history" = "Không có lịch sử mô hình theo ứng dụng"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 36600abedb..022a584b0c 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -1252,6 +1252,9 @@ "Show all %d models" = "显示全部 %d 个模型"; "Show top 20" = "仅显示前 20 个"; "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "模型名称会在去除首尾空白并忽略大小写后精确合并;不同提供商之间不会自动去重。"; +"Metric" = "指标"; +"%d days of usage data across %d models" = "%d 天用量数据,涵盖 %d 个模型"; +"%@ in · %@ out" = "%@ 输入 · %@ 输出"; "Daily estimated spend" = "每日估算支出"; "≈%d full 5h windows of weekly left · %d windows until reset" = "每周额度约剩 %d 个完整 5 小时窗口 · 距重置还有 %d 个窗口"; "Weekly cannot run out before reset at this pace" = "按此速度,每周额度无法在重置前用完"; @@ -1261,6 +1264,20 @@ "Agent Plan" = "智能体套餐"; "Team" = "团队"; +"7-day average" = "7 天平均"; +"Input" = "输入"; +"Output" = "输出"; +"Cache write" = "缓存写入"; +"Reasoning" = "推理"; +"%@ in" = "%@ 输入"; +"%@ out" = "%@ 输出"; +"%@ cache read" = "%@ 缓存读取"; +"%@ cache write" = "%@ 缓存写入"; +"%@ reasoning" = "%@ 推理"; +"estimated" = "估算"; +"Estimated costs are priced from local logs and may differ from provider bills." = "估算费用基于本地日志按刊例价计算,可能与提供商账单不一致。"; +"Usage details for %@" = "%@ 的用量详情"; + /* Menu bar layout editor */ "menu_bar_layout_title" = "布局"; "menu_bar_layout_footer" = "拖动项目以排列菜单栏。点按项目可追加;选择已放置的项目并按 Delete 键可将其移除。"; @@ -1326,3 +1343,23 @@ "Cost today unavailable" = "今日费用: 不可用"; "30-day cost unavailable" = "30 天费用: 不可用"; "Resets" = "重置"; +"Cumulative" = "累计"; + +/* Token usage dashboard (usage & spend) */ +"Token activity" = "Token 活动"; +"By model" = "按模型"; +"By tool" = "按工具"; +"View" = "视图"; +"Day" = "日"; +"7-day avg" = "7 天平均"; +"in the last year" = "近一年"; +"No activity in the last 12 months" = "近 12 个月暂无活动"; +"Each column = 1 week" = "每列 = 1 周"; +"Running total" = "累计总量"; +"Less" = "少"; +"More" = "多"; +"Mo" = "一"; +"We" = "三"; +"Fr" = "五"; +"Estimated" = "估算"; +"No per-client model history" = "暂无按工具划分的模型历史"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index e8e8478071..0bd07b5385 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -1299,14 +1299,17 @@ "By subscription" = "依訂閱"; "No model-level history" = "尚無模型層級歷史"; "Tokens" = "Tokens"; -"Tracked model tokens" = "Tracked model tokens"; -"Priced model spend" = "Priced model spend"; +"Tracked model tokens" = "已追蹤的模型 token"; +"Priced model spend" = "已計價模型支出"; "Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; -"No priced model history" = "No priced model history"; +"No priced model history" = "沒有已計價模型歷史記錄"; "Other" = "Other"; -"Show all %d models" = "Show all %d models"; -"Show top 20" = "Show top 20"; +"Show all %d models" = "顯示全部 %d 個模型"; +"Show top 20" = "僅顯示前 20 個"; "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; +"Metric" = "指標"; +"%d days of usage data across %d models" = "%d 天使用量資料,涵蓋 %d 個模型"; +"%@ in · %@ out" = "%@ 輸入 · %@ 輸出"; "Daily estimated spend" = "每日預估支出"; "≈%d full 5h windows of weekly left · %d windows until reset" = "每週額度約剩 %d 個完整 5 小時視窗 · 距重置還有 %d 個視窗"; "Weekly cannot run out before reset at this pace" = "依此速度,每週額度無法在重置前用完"; @@ -1316,6 +1319,20 @@ "Agent Plan" = "智慧體方案"; "Team" = "團隊"; +"7-day average" = "7 天平均"; +"Input" = "輸入"; +"Output" = "輸出"; +"Cache write" = "快取寫入"; +"Reasoning" = "推理"; +"%@ in" = "%@ 輸入"; +"%@ out" = "%@ 輸出"; +"%@ cache read" = "%@ 快取讀取"; +"%@ cache write" = "%@ 快取寫入"; +"%@ reasoning" = "%@ 推理"; +"estimated" = "估算"; +"Estimated costs are priced from local logs and may differ from provider bills." = "估算費用依本地日誌以牌價計算,可能與供應商帳單不同。"; +"Usage details for %@" = "%@ 的用量詳情"; + /* Menu bar layout editor */ "menu_bar_layout_title" = "佈局"; "menu_bar_layout_footer" = "拖曳項目以排列選單列。點按項目可附加;選取已放置的項目並按 Delete 鍵可將其移除。"; @@ -1381,3 +1398,23 @@ "Cost today unavailable" = "今日費用: 無法使用"; "30-day cost unavailable" = "30 天費用: 無法使用"; "Resets" = "重設"; +"Cumulative" = "累計"; + +/* Token usage dashboard (usage & spend) */ +"Token activity" = "Token 活動"; +"By model" = "按模型"; +"By tool" = "按工具"; +"View" = "檢視"; +"Day" = "日"; +"7-day avg" = "7 天平均"; +"in the last year" = "近一年"; +"No activity in the last 12 months" = "近 12 個月暫無活動"; +"Each column = 1 week" = "每列 = 1 週"; +"Running total" = "累計總量"; +"Less" = "少"; +"More" = "多"; +"Mo" = "一"; +"We" = "三"; +"Fr" = "五"; +"Estimated" = "估算"; +"No per-client model history" = "暫無按工具劃分的模型歷史"; diff --git a/Sources/CodexBar/SpendActivityHeatmap.swift b/Sources/CodexBar/SpendActivityHeatmap.swift new file mode 100644 index 0000000000..7cb7133709 --- /dev/null +++ b/Sources/CodexBar/SpendActivityHeatmap.swift @@ -0,0 +1,394 @@ +import CodexBarCore +import SwiftUI + +// MARK: - Token 活动热力图(参照 Codex `/usage` 的图表算法) + +enum SpendActivityViewMode: String, CaseIterable, Identifiable { + case daily + case weekly + case cumulative + + var id: Self { + self + } + + var title: String { + switch self { + case .daily: L("Daily") + case .weekly: L("Weekly") + case .cumulative: L("Cumulative") + } + } +} + +/// Per-day token totals keyed by start-of-day, plus the fixed 52-week window Codex uses. +struct SpendActivitySeries { + /// 52*7 values ordered oldest-first, starting at the Sunday 51 weeks before this week's Sunday. + let daily: [Int] + /// Naive start-of-day for `daily[0]`. + let start: Date + let today: Date + let calendar: Calendar + + static let weekCount = 52 + static let dayCount = 7 + + /// Builds the fixed 52-week window ending this week (GitHub/Codex style). The first cell is the + /// Sunday that begins the oldest week, so the last column always lands on the current week. + static func make( + from analysis: SpendDashboardModel.ModelAnalysis, + calendar: Calendar = .current) -> SpendActivitySeries + { + var totals: [Date: Int] = [:] + for value in analysis.dailyValues { + let day = calendar.startOfDay(for: value.day) + totals[day, default: 0] += max(value.totalTokens ?? 0, 0) + } + + let today = calendar.startOfDay(for: Date()) + let weekday = calendar.component(.weekday, from: today) // 1 = Sunday + let thisWeekSunday = calendar.date(byAdding: .day, value: -(weekday - 1), to: today)! + let start = calendar.date(byAdding: .weekOfYear, value: -(Self.weekCount - 1), to: thisWeekSunday)! + + let cellCount = Self.weekCount * Self.dayCount + var daily = [Int](repeating: 0, count: cellCount) + for offset in 0.. Date? { + self.calendar.date(byAdding: .day, value: index, to: self.start) + } +} + +enum SpendActivityLevels { + /// Codex's 5-step daily grading: ratio against the window max (linear, not log). + /// >3/4 max → 4, >1/2 max → 3, >1/4 max → 2, >0 → 1, 0 → 0. + static func dailyLevels(_ values: [Int]) -> [Int] { + let maxValue = values.max() ?? 0 + return values.map { value in + guard value > 0, maxValue > 0 else { return 0 } + if value * 4 > maxValue * 3 { return 4 } + if value * 2 > maxValue { return 3 } + if value * 4 > maxValue { return 2 } + return 1 + } + } + + /// Weekly totals (sum each 7-day column). + static func weeklyTotals(_ daily: [Int]) -> [Int] { + stride(from: 0, to: daily.count, by: SpendActivitySeries.dayCount).map { + daily[$0.. [Int] { + var sum = 0 + return weekly.map { sum += $0; return sum } + } + + /// Color for a daily level, blending accent toward the background (Codex's alpha ramp). + static func color(forLevel level: Int) -> Color { + // Alphas mirror Codex: empty ~0.14 (dark) and a 4-step active ramp. + let accent = Color.accentColor + switch level { + case 4: return accent + case 3: return accent.opacity(0.68) + case 2: return accent.opacity(0.42) + case 1: return accent.opacity(0.22) + default: return Color(nsColor: .separatorColor).opacity(0.16) + } + } +} + +// MARK: - 视图 + +struct SpendActivityHeatmapView: View { + let analysis: SpendDashboardModel.ModelAnalysis + @AppStorage("spendActivityViewMode") private var mode: SpendActivityViewMode = .daily + /// Cache the 365-day aggregation: recomputing it on every body evaluation (each hover tick + /// re-evaluates body) is wasted work. Rebuild only when the analysis input changes. + @State private var cachedSeries: SpendActivitySeries? + + private var series: SpendActivitySeries { + self.cachedSeries ?? SpendActivitySeries.make(from: self.analysis) + } + + var body: some View { + let series = self.series + let hasActivity = (series.daily.max() ?? 0) > 0 + let totalTokens = series.daily.reduce(0, +) + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 2) { + Text(L("Token activity")) + .font(.headline) + if hasActivity { + Text("\(UsageFormatter.tokenCountString(totalTokens)) \(L("in the last year"))") + .font(.caption) + .foregroundStyle(.secondary) + } + } + Spacer() + Picker(L("View"), selection: self.$mode) { + ForEach(SpendActivityViewMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + .labelsHidden() + .pickerStyle(.segmented) + .fixedSize() + } + if !hasActivity { + Text(L("No activity in the last 12 months")) + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 12) + } else { + switch self.mode { + case .daily: + SpendActivityDailyGrid(series: series) + self.dailyLegend + case .weekly: + SpendActivityBarGrid(series: series, values: SpendActivityLevels.weeklyTotals(series.daily)) + self.barCaption(text: L("Each column = 1 week")) + case .cumulative: + SpendActivityBarGrid( + series: series, + values: SpendActivityLevels.cumulativeTotals(SpendActivityLevels.weeklyTotals(series.daily))) + self.barCaption(text: L("Running total")) + } + } + } + .onAppear { self.cachedSeries = SpendActivitySeries.make(from: self.analysis) } + .onChange(of: self.analysis) { _, newValue in + self.cachedSeries = SpendActivitySeries.make(from: newValue) + } + } + + private var dailyLegend: some View { + HStack(spacing: 4) { + Spacer() + Text(L("Less")) + ForEach(0...4, id: \.self) { level in + RoundedRectangle(cornerRadius: 2, style: .continuous) + .fill(SpendActivityLevels.color(forLevel: level)) + .frame(width: 9, height: 9) + } + Text(L("More")) + } + .font(.caption2) + .foregroundStyle(.secondary) + } + + private func barCaption(text: String) -> some View { + Text(text) + .font(.caption2) + .foregroundStyle(.secondary) + } +} + +// MARK: - 每日网格(圆点 + 月份/星期标签) + +private struct SpendActivityDailyGrid: View { + let series: SpendActivitySeries + + private let columns = SpendActivitySeries.weekCount + private let rows = SpendActivitySeries.dayCount + + /// Hovered cell index (nil when the pointer leaves the grid). Drives the tooltip only; the + /// Canvas itself does not re-render on hover, so this stays cheap. + @State private var hoveredIndex: Int? + + var body: some View { + let levels = SpendActivityLevels.dailyLevels(self.series.daily) + VStack(alignment: .leading, spacing: 3) { + self.monthRow + HStack(alignment: .top, spacing: 4) { + self.weekdayGutter + GeometryReader { geo in + let pitch = (geo.size.width) / CGFloat(self.columns) + let cell = max(min(pitch, (geo.size.height) / CGFloat(self.rows)) - 1.5, 2) + Canvas { context, size in + let rowPitch = size.height / CGFloat(self.rows) + let corner = min(cell * 0.22, 2.5) // GitHub-style slightly rounded squares + for index in 0..<(self.columns * self.rows) { + guard self.isVisibleCell(index) else { continue } + let col = index / self.rows + let row = index % self.rows + let rect = CGRect( + x: CGFloat(col) * pitch + (pitch - cell) / 2, + y: CGFloat(row) * rowPitch + (rowPitch - cell) / 2, + width: cell, + height: cell) + context.fill( + RoundedRectangle(cornerRadius: corner, style: .continuous).path(in: rect), + with: .color(SpendActivityLevels.color(forLevel: levels[index]))) + } + } + .overlay(alignment: .topLeading) { + self.tooltip(in: geo.size, pitch: pitch) + } + .contentShape(Rectangle()) + .onContinuousHover { phase in + switch phase { + case let .active(location): + self.hoveredIndex = self.cellIndex(at: location, size: geo.size, pitch: pitch) + case .ended: + self.hoveredIndex = nil + } + } + } + .aspectRatio(CGFloat(self.columns) / CGFloat(self.rows), contentMode: .fit) + } + } + } + + /// Hide future cells in the current (last) week. + private func isVisibleCell(_ index: Int) -> Bool { + guard let date = self.series.date(at: index) else { return false } + return date <= self.series.today + } + + /// Maps a pointer location to a grid cell index, or nil when over a future/hidden cell. + private func cellIndex(at location: CGPoint, size: CGSize, pitch: CGFloat) -> Int? { + let rowPitch = size.height / CGFloat(self.rows) + let col = Int(location.x / pitch) + let row = Int(location.y / rowPitch) + guard col >= 0, col < self.columns, row >= 0, row < self.rows else { return nil } + let index = col * self.rows + row + return self.isVisibleCell(index) ? index : nil + } + + @ViewBuilder + private func tooltip(in size: CGSize, pitch: CGFloat) -> some View { + if let index = self.hoveredIndex, + let date = self.series.date(at: index) + { + let tokens = self.series.daily[index] + let col = index / self.rows + let rowPitch = size.height / CGFloat(self.rows) + // Flip the tooltip to the left once the pointer is past the horizontal midpoint so it + // never clips the grid's trailing edge. + let anchorX = CGFloat(col) * pitch + pitch / 2 + let flip = anchorX > size.width * 0.6 + VStack(alignment: .leading, spacing: 1) { + Text(UsageFormatter.tokenCountString(tokens)) + .font(.caption.weight(.semibold)) + Text(date, format: .dateTime.month(.abbreviated).day().year()) + .font(.caption2) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 7) + .padding(.vertical, 5) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 6, style: .continuous)) + .fixedSize() + .offset( + x: flip ? anchorX - 120 : anchorX + 6, + y: CGFloat(index % self.rows) * rowPitch - 4) + .allowsHitTesting(false) + } + } + + private var weekdayGutter: some View { + VStack(spacing: 0) { + ForEach(0.. String { + // Rows start at Sunday (Codex order), but only label a few to avoid clutter. + switch row { + case 1: L("Mo") + case 3: L("We") + case 5: L("Fr") + default: "" + } + } + + private var monthRow: some View { + // One label per month, placed over the column containing that month's first day (day <= 7), + // matching Codex's `month_labels`. Overlapping labels are skipped. + GeometryReader { geo in + let pitch = geo.size.width / CGFloat(self.columns) + ZStack(alignment: .topLeading) { + ForEach(self.monthMarkers(pitch: pitch), id: \.offset) { marker in + Text(marker.label) + .font(.system(size: 8)) + .foregroundStyle(.tertiary) + .offset(x: marker.offset) + } + } + } + .frame(height: 12) + .padding(.leading, 20) // align with the grid (gutter width + spacing) + } + + private struct MonthMarker { + let offset: CGFloat + let label: String + } + + private func monthMarkers(pitch: CGFloat) -> [MonthMarker] { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "MMM" + var markers: [MonthMarker] = [] + var lastLabel = "" + for col in 0.. 0, maxValue > 0 else { continue } + let height = size.height * CGFloat(value) / CGFloat(maxValue) + let rect = CGRect( + x: CGFloat(index) * pitch + (pitch - barWidth) / 2, + y: size.height - height, + width: barWidth, + height: height) + context.fill( + RoundedRectangle(cornerRadius: 1.5, style: .continuous).path(in: rect), + with: .color(Color.accentColor.opacity(0.78))) + } + } + .frame(height: 90) + .frame(maxWidth: .infinity) + } + .padding(.leading, 20) + } +} diff --git a/Sources/CodexBar/SpendClientsView.swift b/Sources/CodexBar/SpendClientsView.swift new file mode 100644 index 0000000000..0eadc0e699 --- /dev/null +++ b/Sources/CodexBar/SpendClientsView.swift @@ -0,0 +1,231 @@ +import CodexBarCore +import SwiftUI + +// MARK: - 按工具分组数据 + +/// A model's usage attributed to one tool (client), with the five-bucket token breakdown +/// taken from the parent model row (buckets are tracked per model, so the per-client split +/// shares them proportionally by that client's token contribution). +struct SpendClientModel: Identifiable, Equatable { + let id: String + let displayName: String + let tokens: Int + let cost: Double? + let costIsEstimated: Bool + let inputTokens: Int? + let outputTokens: Int? + let cacheReadTokens: Int? + let cacheCreationTokens: Int? + let reasoningTokens: Int? +} + +/// One tool (client) with its models, sorted by tokens descending. +struct SpendClientGroup: Identifiable, Equatable { + let provider: UsageProvider + let name: String + let totalTokens: Int + let totalCost: Double? + let costIsEstimated: Bool + let models: [SpendClientModel] + + var id: String { + self.provider.rawValue + } +} + +enum SpendClientBreakdown { + /// Groups model rows by contributing tool. A model used by several tools appears under each, + /// with that tool's token/cost share (from `contributions`); the five-bucket breakdown is the + /// model's own, shown for context under each tool it ran in. + static func groups(from analysis: SpendDashboardModel.ModelAnalysis) -> [SpendClientGroup] { + var byProvider: [UsageProvider: (name: String, models: [String: Accum])] = [:] + + for row in analysis.rows { + for contribution in row.contributions { + let tokens = contribution.totalTokens ?? 0 + guard tokens > 0 || contribution.estimatedCost != nil else { continue } + var bucket = byProvider[contribution.provider] ?? (contribution.providerName, [:]) + var accum = bucket.models[row.id] ?? Accum( + displayName: row.displayName, + costIsEstimated: row.costIsEstimated) + accum.tokens += tokens + if let cost = contribution.estimatedCost { + accum.cost = (accum.cost ?? 0) + cost + } + accum.inputTokens = row.inputTokens + accum.outputTokens = row.outputTokens + accum.cacheReadTokens = row.cacheReadTokens + accum.cacheCreationTokens = row.cacheCreationTokens + accum.reasoningTokens = row.reasoningTokens + bucket.models[row.id] = accum + byProvider[contribution.provider] = bucket + } + } + + return byProvider.map { provider, bucket in + let models = bucket.models.map { id, accum in + SpendClientModel( + id: id, + displayName: accum.displayName, + tokens: accum.tokens, + cost: accum.cost, + costIsEstimated: accum.costIsEstimated, + inputTokens: accum.inputTokens, + outputTokens: accum.outputTokens, + cacheReadTokens: accum.cacheReadTokens, + cacheCreationTokens: accum.cacheCreationTokens, + reasoningTokens: accum.reasoningTokens) + } + .sorted { $0.tokens > $1.tokens } + let totalTokens = models.reduce(0) { $0 + $1.tokens } + let totalCost = models.reduce(nil as Double?) { partial, model in + guard let cost = model.cost else { return partial } + return (partial ?? 0) + cost + } + return SpendClientGroup( + provider: provider, + name: bucket.name, + totalTokens: totalTokens, + totalCost: totalCost, + costIsEstimated: models.contains { $0.costIsEstimated }, + models: models) + } + .sorted { $0.totalTokens > $1.totalTokens } + } + + private struct Accum { + let displayName: String + var tokens = 0 + var cost: Double? + var costIsEstimated: Bool + var inputTokens: Int? + var outputTokens: Int? + var cacheReadTokens: Int? + var cacheCreationTokens: Int? + var reasoningTokens: Int? + } +} + +// MARK: - 按工具分组视图 + +struct SpendClientsView: View { + let analysis: SpendDashboardModel.ModelAnalysis + + var body: some View { + let groups = SpendClientBreakdown.groups(from: self.analysis) + if groups.isEmpty { + Text(L("No per-client model history")) + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 10) + } else { + VStack(alignment: .leading, spacing: 12) { + ForEach(groups) { group in + self.card(group) + } + } + } + } + + private func card(_ group: SpendClientGroup) -> some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 8) { + if let icon = ProviderBrandIcon.image(for: group.provider) { + Image(nsImage: icon).resizable().scaledToFit() + .frame(width: 16, height: 16) + .accessibilityHidden(true) + } + Text(group.name) + .font(.body.weight(.semibold)) + if group.costIsEstimated { + Text(L("Estimated")) + .font(.caption2) + .foregroundStyle(.secondary) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(Color.secondary.opacity(0.15), in: Capsule()) + } + Spacer() + Text(self.totalText(group)) + .font(.body) + .foregroundStyle(.secondary) + .monospacedDigit() + } + .padding(.bottom, 8) + + ForEach(Array(group.models.enumerated()), id: \.element.id) { index, model in + if index > 0 { Divider().padding(.vertical, 2) } + self.modelRow(model, groupTokens: group.totalTokens) + } + } + .padding(12) + .background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + } + + private func modelRow(_ model: SpendClientModel, groupTokens: Int) -> some View { + VStack(alignment: .leading, spacing: 3) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(model.displayName) + .font(.body) + .lineLimit(1) + Spacer() + Text(self.modelMetric(model)) + .font(.body) + .monospacedDigit() + } + if let meta = self.metaText(model) { + Text(meta) + .font(.caption) + .foregroundStyle(.secondary) + .monospacedDigit() + .lineLimit(2) + } + if groupTokens > 0 { + GeometryReader { geo in + let share = CGFloat(model.tokens) / CGFloat(groupTokens) + RoundedRectangle(cornerRadius: 2, style: .continuous) + .fill(Color.accentColor.opacity(0.75)) + .frame(width: max(geo.size.width * share, 2), height: 3) + } + .frame(height: 3) + } + } + .padding(.vertical, 5) + } + + private func totalText(_ group: SpendClientGroup) -> String { + var parts = [UsageFormatter.tokenCountString(group.totalTokens)] + if let cost = group.totalCost { + parts.append(UsageFormatter.currencyString(cost, currencyCode: "USD")) + } + return parts.joined(separator: " · ") + } + + private func modelMetric(_ model: SpendClientModel) -> String { + if let cost = model.cost { + return UsageFormatter.currencyString(cost, currencyCode: "USD") + } + return UsageFormatter.tokenCountString(model.tokens) + } + + private func metaText(_ model: SpendClientModel) -> String? { + var parts: [String] = [] + if let input = model.inputTokens, input > 0 { + parts.append("Input \(UsageFormatter.tokenCountString(input))") + } + if let output = model.outputTokens, output > 0 { + parts.append("Output \(UsageFormatter.tokenCountString(output))") + } + if let cacheRead = model.cacheReadTokens, cacheRead > 0 { + parts.append("Cache read \(UsageFormatter.tokenCountString(cacheRead))") + } + if let cacheWrite = model.cacheCreationTokens, cacheWrite > 0 { + parts.append("Cache write \(UsageFormatter.tokenCountString(cacheWrite))") + } + if let reasoning = model.reasoningTokens, reasoning > 0 { + parts.append("Reasoning \(UsageFormatter.tokenCountString(reasoning))") + } + return parts.isEmpty ? nil : parts.joined(separator: " · ") + } +} diff --git a/Sources/CodexBar/SpendDashboardController.swift b/Sources/CodexBar/SpendDashboardController.swift index 8457d433d4..4c269bc643 100644 --- a/Sources/CodexBar/SpendDashboardController.swift +++ b/Sources/CodexBar/SpendDashboardController.swift @@ -63,6 +63,9 @@ struct SpendDashboardLoadRequest: Sendable { let confirmedEmptySourceIDs: Set let codexRequests: [CodexSpendScanRequest] let kimiCodeHomePath: String? + let geminiCLIHomePath: String? + let openCodeDataHomePath: String? + let miniMaxHomePath: String? let now: Date let force: Bool @@ -73,6 +76,9 @@ struct SpendDashboardLoadRequest: Sendable { confirmedEmptySourceIDs: Set = [], codexRequests: [CodexSpendScanRequest], kimiCodeHomePath: String? = nil, + geminiCLIHomePath: String? = nil, + openCodeDataHomePath: String? = nil, + miniMaxHomePath: String? = nil, now: Date, force: Bool) { @@ -82,6 +88,9 @@ struct SpendDashboardLoadRequest: Sendable { self.confirmedEmptySourceIDs = confirmedEmptySourceIDs self.codexRequests = codexRequests self.kimiCodeHomePath = kimiCodeHomePath + self.geminiCLIHomePath = geminiCLIHomePath + self.openCodeDataHomePath = openCodeDataHomePath + self.miniMaxHomePath = miniMaxHomePath self.now = now self.force = force } @@ -123,11 +132,35 @@ struct KimiCodeSpendSnapshotLoadContext: Sendable { let historyDays: Int } +struct GeminiSpendSnapshotLoadContext: Sendable { + let homePath: String + let now: Date + let historyDays: Int +} + +struct OpenCodeSpendSnapshotLoadContext: Sendable { + let homePath: String + let now: Date + let historyDays: Int +} + +struct MiniMaxSpendSnapshotLoadContext: Sendable { + let homePath: String + let now: Date + let historyDays: Int +} + enum SpendDashboardSource { typealias CodexSnapshotLoader = @Sendable (CodexSpendSnapshotLoadContext) async throws -> CostUsageTokenSnapshot typealias KimiCodeSnapshotLoader = @Sendable (KimiCodeSpendSnapshotLoadContext) async throws -> CostUsageTokenSnapshot? + typealias GeminiSnapshotLoader = @Sendable (GeminiSpendSnapshotLoadContext) async throws + -> CostUsageTokenSnapshot? + typealias OpenCodeSnapshotLoader = @Sendable (OpenCodeSpendSnapshotLoadContext) async throws + -> CostUsageTokenSnapshot? + typealias MiniMaxSnapshotLoader = @Sendable (MiniMaxSpendSnapshotLoadContext) async throws + -> CostUsageTokenSnapshot? static let scanDays = 365 @@ -256,6 +289,15 @@ enum SpendDashboardSource { kimiCodeHomePath: self.localModelHistoryProviders(store: store).contains(.kimi) ? KimiSettingsReader.kimiCodeHomeURL().path : nil, + geminiCLIHomePath: self.localModelHistoryProviders(store: store).contains(.gemini) + ? Self.geminiCLIHomeURL().path + : nil, + openCodeDataHomePath: self.localModelHistoryProviders(store: store).contains(.opencode) + ? Self.openCodeDataHomeURL().path + : nil, + miniMaxHomePath: self.localModelHistoryProviders(store: store).contains(.minimax) + ? Self.miniMaxHomeURL().path + : nil, now: captureNow, force: mode.forcesLoader) } @@ -268,14 +310,54 @@ enum SpendDashboardSource { }, kimiCodeSnapshotLoader: { context in try await self.loadKimiCodeSnapshot(context) + }, + geminiSnapshotLoader: { context in + try await self.loadGeminiSnapshot(context) + }, + openCodeSnapshotLoader: { context in + try await self.loadOpenCodeSnapshot(context) + }, + miniMaxSnapshotLoader: { context in + try await self.loadMiniMaxSnapshot(context) }) } + /// A local CLI provider (Kimi/Gemini/OpenCode/MiniMax) whose usage snapshot is loaded + /// from a home directory via an injectable loader closure. + private struct LocalSnapshotSource { + let homePath: String? + let sourceID: String + let provider: UsageProvider + let displayName: String + let load: (String) async throws -> CostUsageTokenSnapshot? + + func loadInput() async throws -> SpendDashboardModel.ProviderInput? { + guard let homePath else { return nil } + guard let snapshot = try await self.load(homePath) else { return nil } + return SpendDashboardModel.ProviderInput( + id: self.sourceID, + provider: self.provider, + displayName: self.displayName, + modelProviderName: ProviderDescriptorRegistry.descriptor(for: self.provider) + .metadata.displayName, + snapshot: snapshot) + } + } + static func load( _ request: SpendDashboardLoadRequest, codexSnapshotLoader: CodexSnapshotLoader, - kimiCodeSnapshotLoader: KimiCodeSnapshotLoader = { context in + kimiCodeSnapshotLoader: @escaping KimiCodeSnapshotLoader = { context in try await Self.loadKimiCodeSnapshot(context) + }, + geminiSnapshotLoader: @escaping GeminiSnapshotLoader = { context in + try await Self.loadGeminiSnapshot(context) + }, + openCodeSnapshotLoader: @escaping OpenCodeSnapshotLoader = { context in + try await Self.loadOpenCodeSnapshot(context) + }, + miniMaxSnapshotLoader: @escaping MiniMaxSnapshotLoader = { context in + try await Self.loadMiniMaxSnapshot(context) }) async -> SpendDashboardLoadResult { var inputs = request.capturedInputs @@ -322,28 +404,57 @@ enum SpendDashboardSource { failedSourceIDs.insert(sourceID) } } - if let homePath = request.kimiCodeHomePath { + let localSources: [LocalSnapshotSource] = [ + LocalSnapshotSource( + homePath: request.kimiCodeHomePath, + sourceID: "kimi:local", + provider: .kimi, + displayName: "Kimi Code CLI", + load: { homePath in + try await kimiCodeSnapshotLoader(KimiCodeSpendSnapshotLoadContext( + homePath: homePath, now: request.now, historyDays: Self.scanDays)) + }), + LocalSnapshotSource( + homePath: request.geminiCLIHomePath, + sourceID: "gemini:local", + provider: .gemini, + displayName: "Gemini CLI", + load: { homePath in + try await geminiSnapshotLoader(GeminiSpendSnapshotLoadContext( + homePath: homePath, now: request.now, historyDays: Self.scanDays)) + }), + LocalSnapshotSource( + homePath: request.openCodeDataHomePath, + sourceID: "opencode:local", + provider: .opencode, + displayName: "OpenCode", + load: { homePath in + try await openCodeSnapshotLoader(OpenCodeSpendSnapshotLoadContext( + homePath: homePath, now: request.now, historyDays: Self.scanDays)) + }), + LocalSnapshotSource( + homePath: request.miniMaxHomePath, + sourceID: "minimax:local", + provider: .minimax, + displayName: "MiniMax", + load: { homePath in + try await miniMaxSnapshotLoader(MiniMaxSpendSnapshotLoadContext( + homePath: homePath, now: request.now, historyDays: Self.scanDays)) + }), + ] + for source in localSources { do { - if let snapshot = try await kimiCodeSnapshotLoader(KimiCodeSpendSnapshotLoadContext( - homePath: homePath, - now: request.now, - historyDays: Self.scanDays)) - { - inputs.append(SpendDashboardModel.ProviderInput( - id: "kimi:local", - provider: .kimi, - displayName: "Kimi Code CLI", - modelProviderName: ProviderDescriptorRegistry.descriptor(for: .kimi).metadata.displayName, - snapshot: snapshot)) + if let input = try await source.loadInput() { + inputs.append(input) } } catch is CancellationError { - failedSourceIDs.insert("kimi:local") + failedSourceIDs.insert(source.sourceID) return SpendDashboardLoadResult( inputs: [], failedSourceIDs: failedSourceIDs, invalidatedSourceIDs: invalidatedSourceIDs) } catch { - failedSourceIDs.insert("kimi:local") + failedSourceIDs.insert(source.sourceID) } } let lateInvalidatedSourceIDs = Set(request.codexRequests.compactMap { account in @@ -388,6 +499,94 @@ enum SpendDashboardSource { }.value } + private static func loadGeminiSnapshot( + _ context: GeminiSpendSnapshotLoadContext) async throws -> CostUsageTokenSnapshot? + { + try await Task.detached(priority: .utility) { + try Task.checkCancellation() + let snapshot = GeminiSessionScanner.scan( + environment: [GeminiSessionScanner.cliHomeEnvironmentKey: context.homePath], + historyDays: context.historyDays, + now: context.now) + try Task.checkCancellation() + return snapshot + }.value + } + + private static func loadOpenCodeSnapshot( + _ context: OpenCodeSpendSnapshotLoadContext) async throws -> CostUsageTokenSnapshot? + { + try await Task.detached(priority: .utility) { + try Task.checkCancellation() + let snapshot = OpenCodeSessionScanner.scan( + environment: [OpenCodeSessionScanner.dataHomeEnvironmentKey: context.homePath], + historyDays: context.historyDays, + now: context.now) + try Task.checkCancellation() + return snapshot + }.value + } + + private static func loadMiniMaxSnapshot( + _ context: MiniMaxSpendSnapshotLoadContext) async throws -> CostUsageTokenSnapshot? + { + try await Task.detached(priority: .utility) { + try Task.checkCancellation() + let snapshot = MiniMaxSessionScanner.scan( + environment: [MiniMaxSessionScanner.homeEnvironmentKey: context.homePath], + historyDays: context.historyDays, + now: context.now) + try Task.checkCancellation() + return snapshot + }.value + } + + /// Main-actor capture of the Gemini CLI home, mirroring `KimiSettingsReader.kimiCodeHomeURL` + /// (the scanner itself appends `tmp` to whatever `GEMINI_CLI_HOME` resolves to). + private static func geminiCLIHomeURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + if let override = environment[GeminiSessionScanner.cliHomeEnvironmentKey]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !override.isEmpty + { + return URL(fileURLWithPath: override, isDirectory: true) + } + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".gemini", isDirectory: true) + } + + /// Main-actor capture of the XDG data home feeding `OpenCodeSessionScanner` (the scanner + /// itself appends `opencode/storage/message` to whatever `XDG_DATA_HOME` resolves to). + private static func openCodeDataHomeURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + if let override = environment[OpenCodeSessionScanner.dataHomeEnvironmentKey]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !override.isEmpty + { + return URL(fileURLWithPath: override, isDirectory: true) + } + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".local", isDirectory: true) + .appendingPathComponent("share", isDirectory: true) + } + + /// Main-actor capture of the MiniMax home feeding `MiniMaxSessionScanner` (the scanner itself + /// appends `v2/sqlite/runtime-state.sqlite` to whatever `MINIMAX_HOME` resolves to). + private static func miniMaxHomeURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + if let override = environment[MiniMaxSessionScanner.homeEnvironmentKey]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !override.isEmpty + { + return URL(fileURLWithPath: override, isDirectory: true) + } + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".minimax", isDirectory: true) + } + @MainActor static func costCapableProviders(store: UsageStore) -> [UsageProvider] { store.enabledProvidersForDisplay().filter { @@ -397,7 +596,9 @@ enum SpendDashboardSource { @MainActor static func localModelHistoryProviders(store: UsageStore) -> [UsageProvider] { - store.enabledProvidersForDisplay().filter { $0 == .kimi } + store.enabledProvidersForDisplay().filter { + $0 == .kimi || $0 == .gemini || $0 == .opencode || $0 == .minimax + } } @MainActor diff --git a/Sources/CodexBar/SpendDashboardModel.swift b/Sources/CodexBar/SpendDashboardModel.swift index 7574858c1b..065a91b800 100644 --- a/Sources/CodexBar/SpendDashboardModel.swift +++ b/Sources/CodexBar/SpendDashboardModel.swift @@ -9,6 +9,11 @@ struct SpendDashboardModel: Equatable, Sendable { let modelProviderName: String let snapshot: CostUsageTokenSnapshot + /// Origin of this source's cost figures (provider-billed vs locally estimated). + var costSource: CostUsageCostSource { + self.snapshot.costSource + } + init( id: String? = nil, provider: UsageProvider, @@ -97,6 +102,46 @@ struct SpendDashboardModel: Equatable, Sendable { let inputTokens: Int? let outputTokens: Int? let estimatedCost: Double? + /// Non-cached input bucket complement: cache reads/writes, reported separately when every + /// contributing breakdown carries them (nil means "unknown", not zero). + let cacheReadTokens: Int? + let cacheCreationTokens: Int? + /// Reasoning sub-bucket of `outputTokens` (never add it on top of output). + let reasoningTokens: Int? + /// True when any contributing cost was locally estimated rather than provider-billed. + let costIsEstimated: Bool + + init( + id: String, + displayName: String, + rawModelNames: [String], + providers: [UsageProvider], + providerNames: [String], + contributions: [ModelSourceContribution], + totalTokens: Int?, + inputTokens: Int?, + outputTokens: Int?, + estimatedCost: Double?, + cacheReadTokens: Int? = nil, + cacheCreationTokens: Int? = nil, + reasoningTokens: Int? = nil, + costIsEstimated: Bool = false) + { + self.id = id + self.displayName = displayName + self.rawModelNames = rawModelNames + self.providers = providers + self.providerNames = providerNames + self.contributions = contributions + self.totalTokens = totalTokens + self.inputTokens = inputTokens + self.outputTokens = outputTokens + self.estimatedCost = estimatedCost + self.cacheReadTokens = cacheReadTokens + self.cacheCreationTokens = cacheCreationTokens + self.reasoningTokens = reasoningTokens + self.costIsEstimated = costIsEstimated + } } struct ModelDailyValue: Identifiable, Equatable, Sendable { @@ -107,10 +152,38 @@ struct SpendDashboardModel: Equatable, Sendable { let inputTokens: Int? let outputTokens: Int? let estimatedCost: Double? + let cacheReadTokens: Int? + let cacheCreationTokens: Int? + /// Reasoning sub-bucket of `outputTokens` (never add it on top of output). + let reasoningTokens: Int? var id: String { "\(self.modelID):\(Int(self.day.timeIntervalSince1970))" } + + init( + modelID: String, + modelName: String, + day: Date, + totalTokens: Int?, + inputTokens: Int?, + outputTokens: Int?, + estimatedCost: Double?, + cacheReadTokens: Int? = nil, + cacheCreationTokens: Int? = nil, + reasoningTokens: Int? = nil) + { + self.modelID = modelID + self.modelName = modelName + self.day = day + self.totalTokens = totalTokens + self.inputTokens = inputTokens + self.outputTokens = outputTokens + self.estimatedCost = estimatedCost + self.cacheReadTokens = cacheReadTokens + self.cacheCreationTokens = cacheCreationTokens + self.reasoningTokens = reasoningTokens + } } struct ModelAnalysis: Equatable, Sendable { @@ -303,7 +376,7 @@ struct SpendDashboardModel: Equatable, Sendable { let completeness: ModelHistoryCompleteness } - private struct ModelAnalysisAccumulator { + fileprivate struct ModelAnalysisAccumulator { var rawNames: Set = [] var displayNames: Set = [] var providerNames: [UsageProvider: String] = [:] @@ -311,18 +384,31 @@ struct SpendDashboardModel: Equatable, Sendable { var tokens: Int? = 0 var inputTokens: Int? = 0 var outputTokens: Int? = 0 + var cacheReadTokens: Int? = 0 + var cacheCreationTokens: Int? = 0 + var reasoningTokens: Int? = 0 var cost: Double? = 0 var sawTokens = false var sawTokenSplit = false + var sawCacheReadTokens = false + var missingCacheReadTokens = false + var sawCacheCreationTokens = false + var missingCacheCreationTokens = false + var sawReasoningTokens = false + var missingReasoningTokens = false var sawCost = false + var sawEstimatedCost = false var invalidTokenSplit = false var overflowedTokens = false var overflowedInputTokens = false var overflowedOutputTokens = false + var overflowedCacheReadTokens = false + var overflowedCacheCreationTokens = false + var overflowedReasoningTokens = false var overflowedCost = false } - private struct ModelAnalysisSourceAccumulator { + fileprivate struct ModelAnalysisSourceAccumulator { let provider: UsageProvider let sourceName: String let providerName: String @@ -340,18 +426,30 @@ struct SpendDashboardModel: Equatable, Sendable { let day: Date } - private struct ModelAnalysisDailyAccumulator { + fileprivate struct ModelAnalysisDailyAccumulator { var tokens: Int? = 0 var inputTokens: Int? = 0 var outputTokens: Int? = 0 + var cacheReadTokens: Int? = 0 + var cacheCreationTokens: Int? = 0 + var reasoningTokens: Int? = 0 var cost: Double? = 0 var sawTokens = false var sawTokenSplit = false + var sawCacheReadTokens = false + var missingCacheReadTokens = false + var sawCacheCreationTokens = false + var missingCacheCreationTokens = false + var sawReasoningTokens = false + var missingReasoningTokens = false var sawCost = false var invalidTokenSplit = false var overflowedTokens = false var overflowedInputTokens = false var overflowedOutputTokens = false + var overflowedCacheReadTokens = false + var overflowedCacheCreationTokens = false + var overflowedReasoningTokens = false var overflowedCost = false } @@ -442,11 +540,20 @@ extension SpendDashboardModel { let hasCompleteCostHistory = Self.hasCompleteCostHistory(input, displayCalendar: calendar) let costAggregateIsConsistent = input.snapshot.last30DaysCostUSD == nil || hasCompleteCostHistory let invalidCostHistory = hasInvalidCostHistory || !costAggregateIsConsistent - let totalCost = invalidCostHistory - ? nil - : entries.isEmpty - ? (coveredDayCount > 0 && hasCompleteCostHistory ? 0 : nil) - : Self.completeCostSum(entries.map { Self.validCost($0.entry.costUSD) }) + // Daily-sum fallback: when the provider's aggregate cost figure uses a different window or + // accounting than the local per-day logs (so the strict consistency check fails), fall back + // to summing the per-day costs instead of voiding the whole provider. This keeps the spend + // figure available as long as every in-range day was individually priceable. + let dailyCostSum = Self.completeCostSum(entries.map { Self.validCost($0.entry.costUSD) }) + let totalCost: Double? = if !invalidCostHistory { + entries.isEmpty + ? (coveredDayCount > 0 && hasCompleteCostHistory ? 0 : nil) + : dailyCostSum + } else if !hasInvalidCostHistory, dailyCostSum != nil { + dailyCostSum + } else { + nil + } return InputSummary( input: input, entries: entries, @@ -610,6 +717,9 @@ extension SpendDashboardModel { if costBreakdownIsComplete, let cost = Self.validCost(breakdown.costUSD) { aggregate.sawCost = true aggregate.cost = Self.add(cost, to: aggregate.cost, overflowed: &aggregate.overflowedCost) + if input.costSource == .estimated { + aggregate.sawEstimatedCost = true + } source.sawCost = true source.cost = Self.add(cost, to: source.cost, overflowed: &source.overflowedCost) let key = ModelAnalysisDailyKey(modelID: identity, day: windowEntry.day) @@ -627,12 +737,7 @@ extension SpendDashboardModel { let rows = models.compactMap { identity, aggregate -> ModelAnalysisRow? in let totalTokens = aggregate.sawTokens && !aggregate.overflowedTokens ? aggregate.tokens : nil - let hasCompleteTokenSplit = aggregate.sawTokenSplit - && !aggregate.invalidTokenSplit - && !aggregate.overflowedInputTokens - && !aggregate.overflowedOutputTokens - let inputTokens = hasCompleteTokenSplit ? aggregate.inputTokens : nil - let outputTokens = hasCompleteTokenSplit ? aggregate.outputTokens : nil + let buckets = aggregate.resolvedTokenBuckets() let estimatedCost = aggregate.sawCost && !aggregate.overflowedCost ? aggregate.cost : nil guard totalTokens != nil || estimatedCost != nil else { return nil } let rawNames = aggregate.rawNames.sorted(by: Self.modelNameOrder) @@ -666,21 +771,20 @@ extension SpendDashboardModel { providerNames: providers.map { aggregate.providerNames[$0] ?? $0.rawValue }, contributions: contributions, totalTokens: totalTokens, - inputTokens: inputTokens, - outputTokens: outputTokens, - estimatedCost: estimatedCost) + inputTokens: buckets.inputTokens, + outputTokens: buckets.outputTokens, + estimatedCost: estimatedCost, + cacheReadTokens: buckets.cacheReadTokens, + cacheCreationTokens: buckets.cacheCreationTokens, + reasoningTokens: buckets.reasoningTokens, + costIsEstimated: aggregate.sawEstimatedCost) } .sorted(by: Self.modelAnalysisRowOrder) let namesByID: [String: String] = Dictionary(uniqueKeysWithValues: rows.map { ($0.id, $0.displayName) }) let dailyValues = daily.compactMap { key, value -> ModelDailyValue? in let tokens = value.sawTokens && !value.overflowedTokens ? value.tokens : nil - let hasCompleteTokenSplit = value.sawTokenSplit - && !value.invalidTokenSplit - && !value.overflowedInputTokens - && !value.overflowedOutputTokens - let inputTokens = hasCompleteTokenSplit ? value.inputTokens : nil - let outputTokens = hasCompleteTokenSplit ? value.outputTokens : nil + let buckets = value.resolvedTokenBuckets() let cost = value.sawCost && !value.overflowedCost ? value.cost : nil guard tokens != nil || cost != nil, let name = namesByID[key.modelID] else { return nil } return ModelDailyValue( @@ -688,9 +792,12 @@ extension SpendDashboardModel { modelName: name, day: key.day, totalTokens: tokens, - inputTokens: inputTokens, - outputTokens: outputTokens, - estimatedCost: cost) + inputTokens: buckets.inputTokens, + outputTokens: buckets.outputTokens, + estimatedCost: cost, + cacheReadTokens: buckets.cacheReadTokens, + cacheCreationTokens: buckets.cacheCreationTokens, + reasoningTokens: buckets.reasoningTokens) } .sorted { lhs, rhs in if lhs.day != rhs.day { return lhs.day < rhs.day } @@ -752,6 +859,42 @@ extension SpendDashboardModel { split.output, to: dailyValue.outputTokens, overflowed: &dailyValue.overflowedOutputTokens) + Self.addOptionalTokenBucket( + split.cacheRead, + into: &aggregate.cacheReadTokens, + saw: &aggregate.sawCacheReadTokens, + missing: &aggregate.missingCacheReadTokens, + overflowed: &aggregate.overflowedCacheReadTokens) + Self.addOptionalTokenBucket( + split.cacheRead, + into: &dailyValue.cacheReadTokens, + saw: &dailyValue.sawCacheReadTokens, + missing: &dailyValue.missingCacheReadTokens, + overflowed: &dailyValue.overflowedCacheReadTokens) + Self.addOptionalTokenBucket( + split.cacheCreation, + into: &aggregate.cacheCreationTokens, + saw: &aggregate.sawCacheCreationTokens, + missing: &aggregate.missingCacheCreationTokens, + overflowed: &aggregate.overflowedCacheCreationTokens) + Self.addOptionalTokenBucket( + split.cacheCreation, + into: &dailyValue.cacheCreationTokens, + saw: &dailyValue.sawCacheCreationTokens, + missing: &dailyValue.missingCacheCreationTokens, + overflowed: &dailyValue.overflowedCacheCreationTokens) + Self.addOptionalTokenBucket( + split.reasoning, + into: &aggregate.reasoningTokens, + saw: &aggregate.sawReasoningTokens, + missing: &aggregate.missingReasoningTokens, + overflowed: &aggregate.overflowedReasoningTokens) + Self.addOptionalTokenBucket( + split.reasoning, + into: &dailyValue.reasoningTokens, + saw: &dailyValue.sawReasoningTokens, + missing: &dailyValue.missingReasoningTokens, + overflowed: &dailyValue.overflowedReasoningTokens) } else { aggregate.invalidTokenSplit = true dailyValue.invalidTokenSplit = true @@ -759,24 +902,122 @@ extension SpendDashboardModel { return true } + /// Accumulates one optional split bucket (cache read/creation, reasoning). Mirrors the + /// `merged()` breakdown rule: the bucket is only known when *every* contributing breakdown + /// reports it, so a single missing value poisons the aggregate to nil. + private static func addOptionalTokenBucket( + _ value: Int?, + into bucket: inout Int?, + saw: inout Bool, + missing: inout Bool, + overflowed: inout Bool) + { + guard let value else { + missing = true + return + } + saw = true + bucket = Self.add(value, to: bucket, overflowed: &overflowed) + } + + fileprivate static func optionalTokenBucket( + _ bucket: Int?, + saw: Bool, + missing: Bool, + overflowed: Bool, + splitIsComplete: Bool) -> Int? + { + guard splitIsComplete, saw, !missing, !overflowed else { return nil } + return bucket + } + + /// Resolved per-model token buckets for one analysis row or daily value. + fileprivate struct ModelTokenSplitBuckets: Equatable, Sendable { + let inputTokens: Int? + let outputTokens: Int? + let cacheReadTokens: Int? + let cacheCreationTokens: Int? + let reasoningTokens: Int? + } + private static func modelMetricCoverage(hasValue: Bool, isPartial: Bool) -> ModelMetricCoverage { guard hasValue else { return .unavailable } return isPartial ? .partial : .complete } + /// Per-breakdown token buckets for the model analysis. `input` is always the non-cached + /// input, so `input + cacheRead + cacheCreation + output == total` holds whenever every + /// bucket is known. `reasoning` is a sub-bucket of `output` (billing-inclusive) and must + /// never be added on top. Optional buckets are nil when the source does not report them. + private struct ModelTokenSplit { + let input: Int + let output: Int + let cacheRead: Int? + let cacheCreation: Int? + let reasoning: Int? + } + private static func modelTokenSplit( - _ breakdown: CostUsageDailyReport.ModelBreakdown) -> (input: Int, output: Int)? + _ breakdown: CostUsageDailyReport.ModelBreakdown) -> ModelTokenSplit? { - guard self.nonnegative(breakdown.inputTokens) != nil, - breakdown.cacheReadTokens.map({ $0 >= 0 }) ?? true, + guard breakdown.cacheReadTokens.map({ $0 >= 0 }) ?? true, breakdown.cacheCreationTokens.map({ $0 >= 0 }) ?? true, let total = nonnegative(breakdown.totalTokens), let output = nonnegative(breakdown.outputTokens), - output <= total + output <= total, + // Reasoning is billed as output, so it can never exceed the output bucket. + breakdown.reasoningTokens.map({ $0 >= 0 && $0 <= output }) ?? true else { return nil } - return (total - output, output) + + if let input = nonnegative(breakdown.inputTokens) { + let cacheRead = breakdown.cacheReadTokens ?? 0 + let cacheCreation = breakdown.cacheCreationTokens ?? 0 + if let explicitSum = Self.sumTokenBuckets([input, cacheRead, cacheCreation, output]), + explicitSum == total + { + // Cache-exclusive input (Claude/Gemini/OpenCode shape): carry every bucket as-is. + return ModelTokenSplit( + input: input, + output: output, + cacheRead: breakdown.cacheReadTokens, + cacheCreation: breakdown.cacheCreationTokens, + reasoning: breakdown.reasoningTokens) + } + // Cache-inclusive input (Codex shape, where input + output == total): subtract the + // cache read overlap so the explicit buckets sum to the total, mirroring tokscale. + if input + output == total, cacheRead <= input { + return ModelTokenSplit( + input: input - cacheRead, + output: output, + cacheRead: breakdown.cacheReadTokens, + cacheCreation: breakdown.cacheCreationTokens, + reasoning: breakdown.reasoningTokens) + } + // Mixed-source merges (e.g. Codex native + Pi) fit neither shape exactly; fall + // through to the legacy inference so the row keeps a consistent input/output split. + } + + // Legacy inference: everything non-output counts as input and the cache buckets stay + // unknown. Reasoning is shape-independent (a validated sub-bucket of output), so it is + // carried whenever the source reports it. + return ModelTokenSplit( + input: total - output, + output: output, + cacheRead: nil, + cacheCreation: nil, + reasoning: breakdown.reasoningTokens) + } + + private static func sumTokenBuckets(_ values: [Int]) -> Int? { + var result = 0 + for value in values { + let addition = result.addingReportingOverflow(value) + guard !addition.overflow else { return nil } + result = addition.partialValue + } + return result } private static func modelNameOrder(_ lhs: String, _ rhs: String) -> Bool { @@ -786,19 +1027,9 @@ extension SpendDashboardModel { return lhs < rhs } - private static func modelIdentity(rawName: String, provider _: UsageProvider) -> (id: String, displayName: String) { - let normalizedName = rawName.lowercased().hasPrefix("kimi-code/") - ? String(rawName.dropFirst("kimi-code/".count)) - : rawName - let displayName = switch normalizedName.lowercased() { - case "k3", "kimi-k3": "Kimi K3" - case "k2.5", "kimi-k2.5": "Kimi K2.5" - case "k2", "kimi-k2": "Kimi K2" - case "kimi-for-coding": "Kimi for Coding" - case "kimi-for-coding-highspeed": "Kimi for Coding High-Speed" - default: normalizedName - } - return (displayName.lowercased(), displayName) + private static func modelIdentity(rawName: String, provider: UsageProvider) -> (id: String, displayName: String) { + let identity = SpendModelIdentity(rawName: rawName, provider: provider) + return (identity.id, identity.displayName) } private static func modelAnalysisRowOrder(_ lhs: ModelAnalysisRow, _ rhs: ModelAnalysisRow) -> Bool { @@ -1153,3 +1384,58 @@ extension SpendDashboardModel { return result } } + +/// Shared split-resolution state of the model-analysis accumulators. +private protocol ModelTokenSplitAccumulating { + var inputTokens: Int? { get } + var outputTokens: Int? { get } + var cacheReadTokens: Int? { get } + var cacheCreationTokens: Int? { get } + var reasoningTokens: Int? { get } + var sawTokenSplit: Bool { get } + var sawCacheReadTokens: Bool { get } + var missingCacheReadTokens: Bool { get } + var sawCacheCreationTokens: Bool { get } + var missingCacheCreationTokens: Bool { get } + var sawReasoningTokens: Bool { get } + var missingReasoningTokens: Bool { get } + var invalidTokenSplit: Bool { get } + var overflowedInputTokens: Bool { get } + var overflowedOutputTokens: Bool { get } + var overflowedCacheReadTokens: Bool { get } + var overflowedCacheCreationTokens: Bool { get } + var overflowedReasoningTokens: Bool { get } +} + +extension ModelTokenSplitAccumulating { + func resolvedTokenBuckets() -> SpendDashboardModel.ModelTokenSplitBuckets { + let hasCompleteTokenSplit = self.sawTokenSplit + && !self.invalidTokenSplit + && !self.overflowedInputTokens + && !self.overflowedOutputTokens + return SpendDashboardModel.ModelTokenSplitBuckets( + inputTokens: hasCompleteTokenSplit ? self.inputTokens : nil, + outputTokens: hasCompleteTokenSplit ? self.outputTokens : nil, + cacheReadTokens: SpendDashboardModel.optionalTokenBucket( + self.cacheReadTokens, + saw: self.sawCacheReadTokens, + missing: self.missingCacheReadTokens, + overflowed: self.overflowedCacheReadTokens, + splitIsComplete: hasCompleteTokenSplit), + cacheCreationTokens: SpendDashboardModel.optionalTokenBucket( + self.cacheCreationTokens, + saw: self.sawCacheCreationTokens, + missing: self.missingCacheCreationTokens, + overflowed: self.overflowedCacheCreationTokens, + splitIsComplete: hasCompleteTokenSplit), + reasoningTokens: SpendDashboardModel.optionalTokenBucket( + self.reasoningTokens, + saw: self.sawReasoningTokens, + missing: self.missingReasoningTokens, + overflowed: self.overflowedReasoningTokens, + splitIsComplete: hasCompleteTokenSplit)) + } +} + +extension SpendDashboardModel.ModelAnalysisAccumulator: ModelTokenSplitAccumulating {} +extension SpendDashboardModel.ModelAnalysisDailyAccumulator: ModelTokenSplitAccumulating {} diff --git a/Sources/CodexBar/SpendModelIdentity.swift b/Sources/CodexBar/SpendModelIdentity.swift new file mode 100644 index 0000000000..b2b1ee38e8 --- /dev/null +++ b/Sources/CodexBar/SpendModelIdentity.swift @@ -0,0 +1,211 @@ +import CodexBarCore +import Foundation + +// MARK: - SpendModelIdentity + +/// Normalized cross-provider identity for one reported model name. +/// +/// The spend dashboard's "Models" card merges usage across providers, so the same model must land on a +/// single row no matter how each source spells it: Claude Code reports `claude-sonnet-4-5`, Vertex reports +/// `anthropic/claude-sonnet-4-5-20250929`, and OpenAI snapshots report `gpt-5-2025-08-07` next to `gpt-5`. +/// +/// Normalization is deliberately conservative — it only removes decoration that provably does not +/// identify a different model: +/// - surrounding whitespace and repeated internal whitespace; +/// - CLIProxyAPI-style `(high)` reasoning-tier annotations (recognized tier names only); +/// - vendor routing path prefixes (`anthropic/…`, `openai/…`, `openrouter/…`, or the provider's own name); +/// - trailing snapshot dates (`-YYYYMMDD` and `-YYYY-MM-DD`, valid calendar dates only); +/// - Claude version punctuation and order (`claude-3.5-sonnet` ≡ `claude-3-5-sonnet` ≡ `claude-sonnet-3-5`). +/// +/// Semantic suffixes that denote a genuinely different model (`-codex`, `-thinking`, `-mini`, `-latest`, …) +/// are never stripped: when unsure, spellings keep distinct identities and stay on separate rows. +struct SpendModelIdentity: Equatable, Sendable { + /// Lowercase merge key shared by every spelling of the same model. + let id: String + /// Human-facing label: a curated alias when one exists, else the stripped name in its original casing. + let displayName: String + + init(rawName: String, provider: UsageProvider? = nil) { + let collapsed = Self.collapsingWhitespace(rawName) + let withoutTier = Self.strippingReasoningTierSuffix(collapsed) + let withoutPrefix = Self.strippingVendorPathPrefixes(withoutTier, providerHint: provider?.rawValue) + let pretty = Self.strippingTrailingDateStamp(withoutPrefix) + let canonical = Self.canonicalID(for: pretty) + if let alias = Self.displayAliases[canonical] { + self.id = alias.lowercased() + self.displayName = alias + } else { + self.id = canonical + self.displayName = pretty + } + } + + // MARK: - Normalization steps + + /// Trims the name and collapses every internal whitespace run to a single space. + private static func collapsingWhitespace(_ name: String) -> String { + name.components(separatedBy: .whitespacesAndNewlines) + .filter { !$0.isEmpty } + .joined(separator: " ") + } + + /// Strips a trailing `(tier)` reasoning-effort annotation added by routing proxies. Only recognized + /// tier names are stripped, and a space before the parenthesis refuses the strip (the annotation is + /// then treated as part of the name), so unknown parenthesized suffixes keep a distinct identity. + private static func strippingReasoningTierSuffix(_ name: String) -> String { + guard name.hasSuffix(")"), + let openingParen = name.lastIndex(of: "(") + else { return name } + let base = name[.. String { + var segments = name.split(separator: "/", omittingEmptySubsequences: true) + guard !segments.isEmpty else { return name } + let hint = providerHint?.lowercased() + while segments.count > 1 { + let head = segments[0].lowercased() + guard self.vendorPathPrefixes.contains(head) || head == hint else { break } + segments.removeFirst() + } + return segments.joined(separator: "/") + } + + /// Strips one trailing snapshot date (`-YYYYMMDD` or `-YYYY-MM-DD`). The digits must form a + /// plausible calendar date (year 1900–2099, month 1–12, day 1–31), so version-ish suffixes such as + /// `deepseek-v3-0324` or `gpt-5-20251345` are never eaten. A date with no model name before it is + /// kept as-is. + private static func strippingTrailingDateStamp(_ name: String) -> String { + if name.count > 11 { + let suffix = name.suffix(11) + let parts = suffix.split(separator: "-", omittingEmptySubsequences: false) + if parts.count == 4, parts[0].isEmpty, + let year = Self.paddedNumber(parts[1], digits: 4), + let month = Self.paddedNumber(parts[2], digits: 2), + let day = Self.paddedNumber(parts[3], digits: 2), + self.looksLikeSnapshotDate(year: year, month: month, day: day) + { + return String(name.dropLast(11)) + } + } + if name.count > 9 { + let suffix = name.suffix(9) + if suffix.hasPrefix("-") { + let digits = suffix.dropFirst() + if let year = Self.paddedNumber(digits.prefix(4), digits: 4), + let month = Self.paddedNumber(digits.dropFirst(4).prefix(2), digits: 2), + let day = Self.paddedNumber(digits.suffix(2), digits: 2), + self.looksLikeSnapshotDate(year: year, month: month, day: day) + { + return String(name.dropLast(9)) + } + } + } + return name + } + + /// Lowercase merge key: Claude version dots (`claude-3.5` → `claude-3-5`) and the legacy + /// `claude---` segment order are folded so API-style and Claude Code-style + /// spellings of the same model merge. + private static func canonicalID(for name: String) -> String { + let lowercased = name.lowercased() + let dotsNormalized = Self.normalizingClaudeVersionDots(lowercased) + return Self.normalizingClaudeVersionOrder(dotsNormalized) + } + + /// Rewrites `.` to `-` between digits, but only for Claude names: `claude-3.5-sonnet` and the + /// `claude-3-5-sonnet` spelling refer to one model, while dots in other families (`k2.5`, …) are + /// part of the name and stay untouched. + private static func normalizingClaudeVersionDots(_ name: String) -> String { + guard name.contains("claude"), name.contains(".") else { return name } + let characters = Array(name) + var result = String() + result.reserveCapacity(characters.count) + for index in characters.indices { + let character = characters[index] + if character == ".", + index > characters.startIndex, + index < characters.index(before: characters.endIndex), + characters[index - 1].isASCII, characters[index - 1].isNumber, + characters[index + 1].isASCII, characters[index + 1].isNumber + { + result.append("-") + } else { + result.append(character) + } + } + return result + } + + /// Rewrites the legacy `claude---` order (Anthropic API, Bedrock, and Vertex + /// spelling) to the `claude---` order Claude Code reports. The match is exact + /// — two numeric segments plus a known family and nothing else — so unrelated names never reorder. + private static func normalizingClaudeVersionOrder(_ name: String) -> String { + guard name.hasPrefix("claude-") else { return name } + let parts = name.dropFirst("claude-".count).split(separator: "-", omittingEmptySubsequences: false) + guard parts.count == 3, + Self.isASCIINumber(parts[0]), + Self.isASCIINumber(parts[1]), + self.claudeVersionFamilies.contains(String(parts[2])) + else { return name } + return "claude-\(parts[2])-\(parts[0])-\(parts[1])" + } + + // MARK: - Lookup tables and parsing helpers + + /// Reasoning-effort tier names a routing proxy may append as `(tier)`. + private static let reasoningTierSuffixes: Set = [ + "minimal", "low", "medium", "high", "xhigh", "auto", "none", + ] + + /// Vendor and gateway tokens that only route to a model when they appear as a leading path segment. + private static let vendorPathPrefixes: Set = [ + "anthropic", "openai", "google", "gemini", "moonshot", "moonshotai", "kimi", "kimi-code", + "deepseek", "xai", "x-ai", "zai", "z-ai", "meta", "meta-llama", "mistral", "mistralai", + "azure", "azureopenai", "bedrock", "vertex", "vertexai", "vertex_ai", "openrouter", + "qwen", "cohere", "perplexity", "minimax", + ] + + /// Claude family names allowed in the legacy `claude---` order. + private static let claudeVersionFamilies: Set = ["opus", "sonnet", "haiku"] + + /// Curated pretty names for Kimi models, keyed by canonical id. Kept in sync with the historical + /// `kimi-code/` prefix behavior: the prefix strips via `vendorPathPrefixes`, then these aliases + /// provide the display casing. + private static let displayAliases: [String: String] = [ + "k3": "Kimi K3", + "kimi-k3": "Kimi K3", + "k2.5": "Kimi K2.5", + "kimi-k2.5": "Kimi K2.5", + "k2": "Kimi K2", + "kimi-k2": "Kimi K2", + "kimi-for-coding": "Kimi for Coding", + "kimi-for-coding-highspeed": "Kimi for Coding High-Speed", + ] + + /// Parses an ASCII digit run of exactly `digits` characters, rejecting anything else. + private static func paddedNumber(_ text: some StringProtocol, digits: Int) -> Int? { + guard text.count == digits, text.allSatisfy({ $0.isASCII && $0.isNumber }) else { return nil } + return Int(text) + } + + private static func isASCIINumber(_ text: some StringProtocol) -> Bool { + !text.isEmpty && text.allSatisfy { $0.isASCII && $0.isNumber } + } + + /// Plausibility check for snapshot dates: real model stamps use calendar-ish values, so loose + /// bounds reject version fragments (`-0324`) and digit runs (`-12345678`) that are not dates. + private static func looksLikeSnapshotDate(year: Int, month: Int, day: Int) -> Bool { + (1900...2099).contains(year) && (1...12).contains(month) && (1...31).contains(day) + } +} diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 295fa75e06..b3392fa42e 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -618,6 +618,8 @@ public struct CostUsageFetcher: Sendable { historyDays: historyDays, useCurrentLocalDayForSession: true, meteredCostUSD: report.meteredCostUSD, + // The Cursor dashboard API returns the account's actual billed usage events. + costSource: .providerReported, credentialScopeFingerprint: report.credentialScopeFingerprint) } #endif @@ -628,6 +630,7 @@ public struct CostUsageFetcher: Sendable { historyDays: Int = 30, useCurrentLocalDayForSession: Bool = true, meteredCostUSD: Double? = nil, + costSource: CostUsageCostSource = .estimated, credentialScopeFingerprint: String? = nil, historyLabel: String? = nil, projects: [CostUsageProjectBreakdown] = [], @@ -668,6 +671,7 @@ public struct CostUsageFetcher: Sendable { historyDays: historyDays, historyLabel: historyLabel, meteredCostUSD: meteredCostUSD, + costSource: costSource, credentialScopeFingerprint: credentialScopeFingerprint, daily: daily.data, projects: projects, @@ -907,7 +911,9 @@ extension CostUsageFetcher { from: daily, now: now, historyDays: historyDays, - useCurrentLocalDayForSession: false) + useCurrentLocalDayForSession: false, + // Bedrock Cost Explorer reports actual billed spend, not a rate-card estimate. + costSource: .providerReported) } #if os(macOS) diff --git a/Sources/CodexBarCore/CostUsageModels.swift b/Sources/CodexBarCore/CostUsageModels.swift index 2078fc24ba..58c4c82f9f 100644 --- a/Sources/CodexBarCore/CostUsageModels.swift +++ b/Sources/CodexBarCore/CostUsageModels.swift @@ -1,5 +1,15 @@ import Foundation +/// Where a snapshot's `costUSD` figures come from. Local scanners price token counts against +/// models.dev rate cards, so their cost is an API-rate *estimate* of the real bill; provider +/// dashboards/APIs (Cursor usage events, Bedrock Cost Explorer) report the actual billed amount. +public enum CostUsageCostSource: String, Sendable, Equatable { + /// Billed spend as reported by the provider itself. + case providerReported + /// Locally estimated spend (token counts priced against public rate cards). + case estimated +} + public struct CostUsageWindowSummary: Sendable, Equatable { public let days: Int public let totalTokens: Int? @@ -77,6 +87,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? + /// Origin of the cost figures in this snapshot. Defaults to `.estimated` because most + /// snapshots are priced locally; provider-billed sources opt into `.providerReported`. + public let costSource: CostUsageCostSource /// 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? @@ -97,6 +110,7 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { historyCoverageIsEstablished: Bool = true, historyLabel: String? = nil, meteredCostUSD: Double? = nil, + costSource: CostUsageCostSource = .estimated, credentialScopeFingerprint: String? = nil, daily: [CostUsageDailyReport.Entry], projects: [CostUsageProjectBreakdown] = [], @@ -115,6 +129,7 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { self.historyCoverageIsEstablished = historyCoverageIsEstablished self.historyLabel = historyLabel self.meteredCostUSD = meteredCostUSD + self.costSource = costSource self.credentialScopeFingerprint = credentialScopeFingerprint self.daily = daily self.projects = projects @@ -281,6 +296,11 @@ public struct CostUsageDailyReport: Sendable, Decodable { public let cacheReadTokens: Int? public let cacheCreationTokens: Int? public let outputTokens: Int? + /// Reasoning ("thinking") tokens, when the source format reports them separately + /// (Codex `reasoning_output_tokens`, Gemini `thoughts`, OpenCode `reasoning`). + /// Reasoning is always a sub-bucket of `outputTokens` — output stays billing-inclusive, + /// so reasoning must never be added on top of output when summing buckets. + public let reasoningTokens: Int? public let requestCount: Int? public let standardCostUSD: Double? public let priorityCostUSD: Double? @@ -298,6 +318,8 @@ public struct CostUsageDailyReport: Sendable, Decodable { case cacheReadInputTokens case cacheCreationInputTokens case outputTokens + case reasoningTokens + case reasoningOutputTokens = "reasoning_output_tokens" case requestCount case requests case standardCostUSD @@ -321,6 +343,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.requestCount = try container.decodeIfPresent(Int.self, forKey: .requestCount) ?? container.decodeIfPresent(Int.self, forKey: .requests) @@ -338,6 +363,7 @@ public struct CostUsageDailyReport: Sendable, Decodable { cacheReadTokens: Int? = nil, cacheCreationTokens: Int? = nil, outputTokens: Int? = nil, + reasoningTokens: Int? = nil, requestCount: Int? = nil, standardCostUSD: Double? = nil, priorityCostUSD: Double? = nil, @@ -351,6 +377,7 @@ public struct CostUsageDailyReport: Sendable, Decodable { self.cacheReadTokens = cacheReadTokens self.cacheCreationTokens = cacheCreationTokens self.outputTokens = outputTokens + self.reasoningTokens = reasoningTokens self.requestCount = requestCount self.standardCostUSD = standardCostUSD self.priorityCostUSD = priorityCostUSD @@ -568,6 +595,9 @@ extension CostUsageDailyReport { var outputTokens: Int = 0 var sawOutputTokens = false var missingOutputTokens = false + var reasoningTokens: Int = 0 + var sawReasoningTokens = false + var missingReasoningTokens = false var costUSD: Double = 0 var sawCost = false var standardCostUSD: Double = 0 @@ -608,6 +638,12 @@ extension CostUsageDailyReport { } else { self.missingOutputTokens = true } + if let reasoningTokens = breakdown.reasoningTokens { + self.reasoningTokens += reasoningTokens + self.sawReasoningTokens = true + } else { + self.missingReasoningTokens = true + } if let costUSD = breakdown.costUSD { self.costUSD += costUSD self.sawCost = true @@ -641,6 +677,7 @@ extension CostUsageDailyReport { ? self.cacheCreationTokens : nil, outputTokens: self.sawOutputTokens && !self.missingOutputTokens ? self.outputTokens : nil, + reasoningTokens: self.sawReasoningTokens && !self.missingReasoningTokens ? self.reasoningTokens : nil, standardCostUSD: self.sawStandardCost ? self.standardCostUSD : nil, priorityCostUSD: self.sawPriorityCost ? self.priorityCostUSD : nil, standardTokens: self.sawStandardTokens ? self.standardTokens : nil, diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 80749f652e..86d3e2fea8 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 = "0adc0e9717db47a8" + static let value = "9c63f4de93213b76" } diff --git a/Sources/CodexBarCore/Providers/Gemini/GeminiSessionScanner.swift b/Sources/CodexBarCore/Providers/Gemini/GeminiSessionScanner.swift new file mode 100644 index 0000000000..cef0d50d5f --- /dev/null +++ b/Sources/CodexBarCore/Providers/Gemini/GeminiSessionScanner.swift @@ -0,0 +1,488 @@ +import Foundation + +/// Scans local Gemini CLI session transcripts and folds their per-turn token usage into a +/// token-only `CostUsageTokenSnapshot` for the Usage & Spend dashboard. Same shape as +/// `KimiCodeSessionScanner`; the on-disk semantics mirror tokscale's `sessions/gemini.rs`: +/// +/// - Root: `$GEMINI_CLI_HOME/tmp` (default `~/.gemini/tmp`), one `/` dir per project. +/// - Chat recordings: `/chats/session-*.json` (plus legacy `session-*.json` anywhere +/// under `tmp`) holding a `messages` array. Model turns carry `model` and a `tokens` object with +/// `input`/`prompt`, `output`/`candidates`, `cached`, `thoughts`, `tool`, `total` (plus the +/// snake_case/camelCase aliases below) and an RFC 3339 `timestamp` (file mtime is the fallback). +/// - Chat streams: any `*.jsonl` under `tmp`, one message object per line; `init` lines seed the +/// current model for later token lines, and a line whose `id` repeats replaces the earlier one. +/// +/// Normalization (mirrors tokscale): when `total` equals input+output+thoughts+tool but not that +/// sum plus cached, the prompt count was cache-inclusive, so the cached overlap is subtracted from +/// input. `tool` tokens fold into input because `CostUsageDailyReport.ModelBreakdown` has no tool +/// bucket. `thoughts` stays folded into billing `outputTokens` (reasoning is part of output for +/// billing) and is additionally surfaced as the `reasoningTokens` sub-bucket. Deliberate deviations: +/// headless `stats` blobs are not parsed, string-typed numbers are rejected, and negative values +/// mark the record corrupt (skipped) rather than clamped — both matching this repo's stricter +/// `KimiCodeSessionScanner` robustness style. +public enum GeminiSessionScanner { + public static let defaultHistoryDays = 30 + + /// Environment override for the Gemini CLI home directory, resolved directly here (the same + /// way `KimiSettingsReader` honors `KIMI_CODE_HOME`) so this scanner stays self-contained. + public static let cliHomeEnvironmentKey = "GEMINI_CLI_HOME" + + // MARK: - Wire models + + private struct WireTokens: Decodable { + let input: Int + let output: Int + let cached: Int + let thoughts: Int + let tool: Int + let total: Int? + + private enum CodingKeys: String, CodingKey { + case input + case prompt + case inputTokens = "input_tokens" + case promptTokens = "prompt_tokens" + case promptTokenCount + case output + case candidates + case outputTokens = "output_tokens" + case completionTokens = "completion_tokens" + case candidatesTokenCount + case cached + case cachedTokens = "cached_tokens" + case cachedContentTokenCount + case thoughts + case reasoning + case thoughtsTokens = "thoughts_tokens" + case tool + case toolTokens = "tool_tokens" + case total + case totalTokenCount + case totalTokens = "total_tokens" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.input = Self.first(container, [.input, .prompt, .inputTokens, .promptTokens, .promptTokenCount]) ?? 0 + self.output = Self + .first(container, [.output, .candidates, .outputTokens, .completionTokens, .candidatesTokenCount]) ?? 0 + self.cached = Self.first(container, [.cached, .cachedTokens, .cachedContentTokenCount]) ?? 0 + self.thoughts = Self.first(container, [.thoughts, .reasoning, .thoughtsTokens]) ?? 0 + self.tool = Self.first(container, [.tool, .toolTokens]) ?? 0 + self.total = Self.first(container, [.total, .totalTokenCount, .totalTokens]) + } + + /// First present, integer-typed alias wins; a missing or mistyped alias falls through. + private static func first( + _ container: KeyedDecodingContainer, + _ keys: [CodingKeys]) -> Int? + { + for key in keys { + if let value = try? container.decode(Int.self, forKey: key) { + return value + } + } + return nil + } + } + + private struct WireMessage: Decodable { + let id: String? + let type: String? + let model: String? + let tokens: WireTokens? + let timestamp: Date? + + private enum CodingKeys: String, CodingKey { + case id + case type + case model + case tokens + case timestamp + case createdAt = "created_at" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try? container.decode(String.self, forKey: .id) + self.type = try? container.decode(String.self, forKey: .type) + self.model = try? container.decode(String.self, forKey: .model) + self.tokens = try? container.decode(WireTokens.self, forKey: .tokens) + self.timestamp = Self.timestamp(from: container) + } + + /// Mirrors tokscale: the first *present* key wins; if it is present but unparseable the + /// caller falls back to the file mtime instead of trying the next key. + private static func timestamp(from container: KeyedDecodingContainer) -> Date? { + for key in [CodingKeys.timestamp, .createdAt] { + guard container.contains(key) else { continue } + if let text = try? container.decode(String.self, forKey: key) { + return GeminiSessionScanner.timestampTextDate(text) + } + if let number = try? container.decode(Double.self, forKey: key), + number.isFinite, number > 0 + { + // Milliseconds when the magnitude says so, otherwise seconds (tokscale rule). + return Date(timeIntervalSince1970: number >= 1_000_000_000_000 ? number / 1000 : number) + } + return nil + } + return nil + } + } + + private struct WireChatRecording: Decodable { + let messages: [WireMessage] + } + + // MARK: - Aggregation + + private struct NormalizedUsage { + let input: Int + let output: Int + let cacheRead: Int + /// `thoughts` tokens; already included in `output` (billing-inclusive), tracked separately. + let reasoning: Int + } + + private struct ResolvedTurn { + let date: Date + let model: String + let usage: NormalizedUsage + } + + private struct DayModelKey: Hashable { + let day: String + let model: String + } + + private struct TokenAccumulator { + var input = 0 + var output = 0 + var cacheRead = 0 + var reasoning = 0 + var requests = 0 + + mutating func add(_ usage: NormalizedUsage) -> Bool { + guard let nextInput = Self.adding(self.input, usage.input), + let nextOutput = Self.adding(self.output, usage.output), + let nextCacheRead = Self.adding(self.cacheRead, usage.cacheRead), + let nextReasoning = Self.adding(self.reasoning, usage.reasoning), + let nextRequests = Self.adding(self.requests, 1) + else { + return false + } + self.input = nextInput + self.output = nextOutput + self.cacheRead = nextCacheRead + self.reasoning = nextReasoning + self.requests = nextRequests + return true + } + + mutating func merge(_ other: TokenAccumulator) -> Bool { + guard let nextInput = Self.adding(self.input, other.input), + let nextOutput = Self.adding(self.output, other.output), + let nextCacheRead = Self.adding(self.cacheRead, other.cacheRead), + let nextReasoning = Self.adding(self.reasoning, other.reasoning), + let nextRequests = Self.adding(self.requests, other.requests) + else { + return false + } + self.input = nextInput + self.output = nextOutput + self.cacheRead = nextCacheRead + self.reasoning = nextReasoning + self.requests = nextRequests + return true + } + + var total: Int? { + guard let inputAndOutput = Self.adding(self.input, self.output) else { return nil } + return Self.adding(inputAndOutput, self.cacheRead) + } + + private static func adding(_ lhs: Int, _ rhs: Int) -> Int? { + let result = lhs.addingReportingOverflow(rhs) + return result.overflow ? nil : result.partialValue + } + } + + // MARK: - Scanning + + public static func scan( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default, + historyDays: Int = defaultHistoryDays, + now: Date = Date(), + calendar: Calendar = .current) -> CostUsageTokenSnapshot? + { + let days = max(1, historyDays) + let tmp = self.geminiTmpURL(environment: environment) + guard let enumerator = fileManager.enumerator( + at: tmp, + includingPropertiesForKeys: [.isRegularFileKey, .contentModificationDateKey], + options: [.skipsHiddenFiles]) + else { + return nil + } + + let end = calendar.startOfDay(for: now) + let start = calendar.date(byAdding: .day, value: -(days - 1), to: end) ?? end + var values: [DayModelKey: TokenAccumulator] = [:] + let decoder = JSONDecoder() + + while let url = enumerator.nextObject() as? URL { + let pathExtension = url.pathExtension.lowercased() + guard pathExtension == "json" || pathExtension == "jsonl", + self.isChatTranscript(url, under: tmp) + else { + continue + } + let modificationDate = try? url.resourceValues(forKeys: [.contentModificationDateKey]) + .contentModificationDate + if let modificationDate, modificationDate < start { + continue + } + guard let data = try? Data(contentsOf: url) else { continue } + // Messages without a usable timestamp fall back to the file mtime (tokscale rule); + // epoch zero simply lands outside the window when even the mtime is unavailable. + let fallbackDate = modificationDate ?? Date(timeIntervalSince1970: 0) + let turns = pathExtension == "jsonl" + ? self.parseStream(data: data, fallbackDate: fallbackDate, decoder: decoder) + : self.parseChatRecording(data: data, fallbackDate: fallbackDate, decoder: decoder) + for turn in turns { + let day = calendar.startOfDay(for: turn.date) + guard day >= start, day <= end else { continue } + let key = DayModelKey(day: CostUsageLocalDay.key(from: day, calendar: calendar), model: turn.model) + var value = values[key] ?? TokenAccumulator() + guard value.add(turn.usage) else { continue } + values[key] = value + } + } + + guard !values.isEmpty else { return nil } + let byDay = Dictionary(grouping: values, by: \.key.day) + let daily = byDay.keys.sorted().compactMap { day -> CostUsageDailyReport.Entry? in + let models = (byDay[day] ?? []).sorted { lhs, rhs in + lhs.key.model.localizedCaseInsensitiveCompare(rhs.key.model) == .orderedAscending + } + var total = TokenAccumulator() + var modelBreakdowns: [CostUsageDailyReport.ModelBreakdown] = [] + for (key, value) in models { + guard let modelTotal = value.total else { return nil } + guard total.merge(value) else { return nil } + modelBreakdowns.append(CostUsageDailyReport.ModelBreakdown( + modelName: key.model, + costUSD: nil, + totalTokens: modelTotal, + inputTokens: value.input, + cacheReadTokens: value.cacheRead, + cacheCreationTokens: nil, + outputTokens: value.output, + reasoningTokens: value.reasoning > 0 ? value.reasoning : nil, + requestCount: value.requests)) + } + guard let totalTokens = total.total else { return nil } + return CostUsageDailyReport.Entry( + date: day, + inputTokens: total.input, + outputTokens: total.output, + cacheReadTokens: total.cacheRead, + cacheCreationTokens: nil, + totalTokens: totalTokens, + requestCount: total.requests, + costUSD: nil, + modelsUsed: modelBreakdowns.map(\.modelName), + modelBreakdowns: modelBreakdowns) + } + let totalTokens = self.sum(daily.compactMap(\.totalTokens)) + let totalRequests = self.sum(daily.compactMap(\.requestCount)) + guard let totalTokens, let totalRequests else { return nil } + + return CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + sessionRequests: nil, + last30DaysTokens: totalTokens, + last30DaysCostUSD: nil, + last30DaysRequests: totalRequests, + currencyCode: "XXX", + historyDays: days, + historyCoverageIsEstablished: true, + historyLabel: "Gemini CLI", + daily: daily, + updatedAt: now) + } + + // MARK: - Parsing + + private static func parseChatRecording( + data: Data, + fallbackDate: Date, + decoder: JSONDecoder) -> [ResolvedTurn] + { + guard let recording = try? decoder.decode(WireChatRecording.self, from: data) else { return [] } + return recording.messages.compactMap { message in + guard let model = self.cleaned(message.model), + let tokens = message.tokens, + let usage = self.normalize(tokens) + else { + return nil + } + return ResolvedTurn(date: message.timestamp ?? fallbackDate, model: model, usage: usage) + } + } + + private static func parseStream( + data: Data, + fallbackDate: Date, + decoder: JSONDecoder) -> [ResolvedTurn] + { + var turns: [ResolvedTurn] = [] + var indicesByID: [String: Int] = [:] + var currentModel: String? + for line in data.split(separator: 0x0A, omittingEmptySubsequences: true) { + guard let message = try? decoder.decode(WireMessage.self, from: Data(line)) else { continue } + if message.type == "init" { + if let model = self.cleaned(message.model) { currentModel = model } + continue + } + guard message.type == "gemini" || message.tokens != nil else { continue } + if let model = self.cleaned(message.model) { currentModel = model } + guard let model = currentModel, + let tokens = message.tokens, + let usage = self.normalize(tokens) + else { + continue + } + // A repeated `id` is a streaming rewrite of the same turn: the later line wins. + let turn = ResolvedTurn(date: message.timestamp ?? fallbackDate, model: model, usage: usage) + if let id = self.cleaned(message.id) { + if let index = indicesByID[id] { + turns[index] = turn + } else { + indicesByID[id] = turns.count + turns.append(turn) + } + } else { + turns.append(turn) + } + } + return turns + } + + private static func normalize(_ tokens: WireTokens) -> NormalizedUsage? { + guard tokens.input >= 0, tokens.output >= 0, tokens.cached >= 0, + tokens.thoughts >= 0, tokens.tool >= 0, tokens.total ?? 0 >= 0 + else { + return nil + } + + var input = tokens.input + if let total = tokens.total, tokens.cached > 0, + let inclusive = self.sum([tokens.input, tokens.output, tokens.thoughts, tokens.tool]), + inclusive == total + { + // `total` leaving out the cached count proves the input count was cache-inclusive. + let excludesCache = self.adding(inclusive, tokens.cached).map { $0 != total } ?? true + if excludesCache { + input -= min(tokens.cached, input) + } + } + + guard let finalInput = self.adding(input, tokens.tool), + let finalOutput = self.adding(tokens.output, tokens.thoughts) + else { + return nil + } + return NormalizedUsage( + input: finalInput, + output: finalOutput, + cacheRead: tokens.cached, + reasoning: tokens.thoughts) + } + + private static func timestampTextDate(_ text: String) -> Date? { + guard let trimmed = self.cleaned(text) else { return nil } + if let date = CostUsageDateParser.parse(trimmed) { return date } + // Timezone-less ISO-8601 datetimes carry no offset; interpret them as UTC (tokscale rule). + for format in [ + "yyyy-MM-dd'T'HH:mm:ss.SSSSSS", + "yyyy-MM-dd'T'HH:mm:ss.SSS", + "yyyy-MM-dd'T'HH:mm:ss", + "yyyy-MM-dd HH:mm:ss", + ] { + if let date = self.utcFormatter(format: format).date(from: trimmed) { return date } + } + return nil + } + + private static func utcFormatter(format: String) -> DateFormatter { + let key = "GeminiSessionScanner.utcFormatter.\(format)" + let threadDictionary = Thread.current.threadDictionary + if let cached = threadDictionary[key] as? DateFormatter { return cached } + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "UTC") + formatter.dateFormat = format + formatter.isLenient = false + threadDictionary[key] = formatter + return formatter + } + + // MARK: - Paths + + public static func geminiTmpURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + if let override = self.cleaned(environment[self.cliHomeEnvironmentKey]) { + return URL(fileURLWithPath: override, isDirectory: true) + .appendingPathComponent("tmp", isDirectory: true) + } + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".gemini", isDirectory: true) + .appendingPathComponent("tmp", isDirectory: true) + } + + /// Mirrors tokscale's layout filter: every `.jsonl` under `tmp` is a candidate chat stream, + /// while `.json` files need the legacy `session-` prefix or the `tmp//chats/` layout. + private static func isChatTranscript(_ url: URL, under tmp: URL) -> Bool { + if url.pathExtension.lowercased() == "jsonl" { return true } + if url.lastPathComponent.hasPrefix("session-") { return true } + guard let components = self.relativeComponents(of: url, under: tmp) else { return false } + return components.count == 3 && components[1] == "chats" + } + + private static func relativeComponents(of url: URL, under base: URL) -> [String]? { + let baseComponents = base.standardizedFileURL.pathComponents + let urlComponents = url.standardizedFileURL.pathComponents + guard urlComponents.count > baseComponents.count, + urlComponents.prefix(baseComponents.count).elementsEqual(baseComponents) + else { + return nil + } + return Array(urlComponents.dropFirst(baseComponents.count)) + } + + private static func cleaned(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private static func adding(_ lhs: Int, _ rhs: Int) -> Int? { + let result = lhs.addingReportingOverflow(rhs) + return result.overflow ? nil : result.partialValue + } + + private static func sum(_ values: [Int]) -> Int? { + var result = 0 + for value in values { + let addition = result.addingReportingOverflow(value) + guard !addition.overflow else { return nil } + result = addition.partialValue + } + return result + } +} diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSessionScanner.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSessionScanner.swift new file mode 100644 index 0000000000..0f553a5fcd --- /dev/null +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSessionScanner.swift @@ -0,0 +1,334 @@ +import Foundation + +#if canImport(SQLite3) +import SQLite3 +#elseif canImport(CSQLite3) +import CSQLite3 +#endif + +// Reads MiniMax local assistant-turn token usage from the MiniMax desktop runtime database +// (`~/.minimax/v2/sqlite/runtime-state.sqlite`, table `local_runtime_token_usage`) and folds it +// into a token-only `CostUsageTokenSnapshot` for the Usage & Spend dashboard. Same output shape as +// `KimiCodeSessionScanner`, but sourced from SQLite rather than JSONL. +// +// The runtime table already stores one row per billable turn with explicit buckets: +// `input_tokens`, `output_tokens`, `reasoning_tokens`, `cache_read_tokens`, `cache_write_tokens`, +// plus `model` (e.g. `minimax/MiniMax-M3`) and `ts` (epoch milliseconds). `reasoning` is part of +// billing output, so it stays folded into `outputTokens` and is additionally surfaced as the +// `reasoningTokens` sub-bucket (never added on top). `cache_write` maps to `cacheCreationTokens`. +// `cost_usd` is reported by the provider; when present it is summed into the day/model cost, +// otherwise the snapshot stays token-only (`costUSD: nil`). +#if canImport(SQLite3) || canImport(CSQLite3) +public enum MiniMaxSessionScanner { + public static let defaultHistoryDays = 30 + + /// Environment override for the MiniMax home directory, resolved directly here so this scanner + /// stays self-contained (mirrors how `KimiCodeSessionScanner` honors `KIMI_CODE_HOME`). + public static let homeEnvironmentKey = "MINIMAX_HOME" + + private struct DayModelKey: Hashable { + let day: String + let model: String + } + + private struct TokenAccumulator { + var input = 0 + var cacheRead = 0 + var cacheCreation = 0 + var output = 0 + var reasoning = 0 + var requests = 0 + var cost = 0.0 + var sawCost = false + + mutating func add(_ row: UsageRow) -> Bool { + guard let nextInput = Self.adding(self.input, row.input), + let nextCacheRead = Self.adding(self.cacheRead, row.cacheRead), + let nextCacheCreation = Self.adding(self.cacheCreation, row.cacheCreation), + let nextOutput = Self.adding(self.output, row.output), + let nextReasoning = Self.adding(self.reasoning, row.reasoning), + let nextRequests = Self.adding(self.requests, 1) + else { + return false + } + self.input = nextInput + self.cacheRead = nextCacheRead + self.cacheCreation = nextCacheCreation + self.output = nextOutput + self.reasoning = nextReasoning + self.requests = nextRequests + if let cost = row.cost { + self.cost += cost + self.sawCost = true + } + return true + } + + mutating func merge(_ other: TokenAccumulator) -> Bool { + guard let nextInput = Self.adding(self.input, other.input), + let nextCacheRead = Self.adding(self.cacheRead, other.cacheRead), + let nextCacheCreation = Self.adding(self.cacheCreation, other.cacheCreation), + let nextOutput = Self.adding(self.output, other.output), + let nextReasoning = Self.adding(self.reasoning, other.reasoning), + let nextRequests = Self.adding(self.requests, other.requests) + else { + return false + } + self.input = nextInput + self.cacheRead = nextCacheRead + self.cacheCreation = nextCacheCreation + self.output = nextOutput + self.reasoning = nextReasoning + self.requests = nextRequests + if other.sawCost { + self.cost += other.cost + self.sawCost = true + } + return true + } + + var total: Int? { + guard let inputAndCacheRead = Self.adding(self.input, self.cacheRead), + let withCacheCreation = Self.adding(inputAndCacheRead, self.cacheCreation) + else { + return nil + } + return Self.adding(withCacheCreation, self.output) + } + + private static func adding(_ lhs: Int, _ rhs: Int) -> Int? { + let result = lhs.addingReportingOverflow(rhs) + return result.overflow ? nil : result.partialValue + } + } + + private struct UsageRow { + let model: String + let createdMs: Int64 + let input: Int + let output: Int + let reasoning: Int + let cacheRead: Int + let cacheCreation: Int + let cost: Double? + } + + public static func scan( + environment: [String: String] = ProcessInfo.processInfo.environment, + historyDays: Int = defaultHistoryDays, + now: Date = Date(), + calendar: Calendar = .current) -> CostUsageTokenSnapshot? + { + let days = max(1, historyDays) + let databaseURL = self.runtimeDatabaseURL(environment: environment) + guard FileManager.default.fileExists(atPath: databaseURL.path) else { return nil } + guard let rows = self.readRows(databaseURL: databaseURL), !rows.isEmpty else { return nil } + + let end = calendar.startOfDay(for: now) + let start = calendar.date(byAdding: .day, value: -(days - 1), to: end) ?? end + var values: [DayModelKey: TokenAccumulator] = [:] + + for row in rows { + let date = Date(timeIntervalSince1970: TimeInterval(row.createdMs) / 1000) + let day = calendar.startOfDay(for: date) + guard day >= start, day <= end else { continue } + let key = DayModelKey(day: CostUsageLocalDay.key(from: day, calendar: calendar), model: row.model) + var value = values[key] ?? TokenAccumulator() + guard value.add(row) else { continue } + values[key] = value + } + + guard !values.isEmpty else { return nil } + let byDay = Dictionary(grouping: values, by: \.key.day) + let daily = byDay.keys.sorted().compactMap { day -> CostUsageDailyReport.Entry? in + let models = (byDay[day] ?? []).sorted { lhs, rhs in + lhs.key.model.localizedCaseInsensitiveCompare(rhs.key.model) == .orderedAscending + } + var total = TokenAccumulator() + var modelBreakdowns: [CostUsageDailyReport.ModelBreakdown] = [] + var dayCost = 0.0 + var daySawCost = false + for (key, value) in models { + guard let modelTotal = value.total else { return nil } + guard total.merge(value) else { return nil } + modelBreakdowns.append(CostUsageDailyReport.ModelBreakdown( + modelName: key.model, + costUSD: value.sawCost ? value.cost : nil, + totalTokens: modelTotal, + inputTokens: value.input, + cacheReadTokens: value.cacheRead, + cacheCreationTokens: value.cacheCreation, + outputTokens: value.output, + reasoningTokens: value.reasoning > 0 ? value.reasoning : nil, + requestCount: value.requests)) + if value.sawCost { + dayCost += value.cost + daySawCost = true + } + } + guard let totalTokens = total.total else { return nil } + return CostUsageDailyReport.Entry( + date: day, + inputTokens: total.input, + outputTokens: total.output, + cacheReadTokens: total.cacheRead, + cacheCreationTokens: total.cacheCreation, + totalTokens: totalTokens, + requestCount: total.requests, + costUSD: daySawCost ? dayCost : nil, + modelsUsed: modelBreakdowns.map(\.modelName), + modelBreakdowns: modelBreakdowns) + } + let totalTokens = self.sum(daily.compactMap(\.totalTokens)) + let totalRequests = self.sum(daily.compactMap(\.requestCount)) + let totalCost = daily.compactMap(\.costUSD).reduce(0, +) + let sawCost = daily.contains { $0.costUSD != nil } + guard let totalTokens, let totalRequests else { return nil } + + return CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + sessionRequests: nil, + last30DaysTokens: totalTokens, + last30DaysCostUSD: sawCost ? totalCost : nil, + last30DaysRequests: totalRequests, + currencyCode: sawCost ? "USD" : "XXX", + historyDays: days, + historyCoverageIsEstablished: true, + historyLabel: "MiniMax", + daily: daily, + updatedAt: now) + } + + // MARK: - Paths + + public static func minimaxHomeURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + if let override = environment[self.homeEnvironmentKey]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !override.isEmpty + { + return URL(fileURLWithPath: override, isDirectory: true) + } + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".minimax", isDirectory: true) + } + + public static func runtimeDatabaseURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + self.minimaxHomeURL(environment: environment) + .appendingPathComponent("v2", isDirectory: true) + .appendingPathComponent("sqlite", isDirectory: true) + .appendingPathComponent("runtime-state.sqlite", isDirectory: false) + } + + // MARK: - SQLite + + private static func readRows(databaseURL: URL) -> [UsageRow]? { + var db: OpaquePointer? + // The runtime database is WAL-journaled. A plain read-only open cannot create the shared + // -shm/-wal files and may surface an empty or stale snapshot; `immutable=1` tells SQLite the + // file won't change under us, so it reads the main database file directly without WAL setup. + let uri = "file://\(databaseURL.path)?immutable=1" + guard sqlite3_open_v2(uri, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_URI, nil) == SQLITE_OK else { + sqlite3_close(db) + return nil + } + defer { sqlite3_close(db) } + sqlite3_busy_timeout(db, 250) + + guard self.hasTable(named: "local_runtime_token_usage", db: db) else { return nil } + + let sql = """ + SELECT + COALESCE(model, ''), + ts, + input_tokens, + output_tokens, + reasoning_tokens, + cache_read_tokens, + cache_write_tokens, + cost_usd + FROM local_runtime_token_usage + """ + + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return nil } + defer { sqlite3_finalize(stmt) } + + var rows: [UsageRow] = [] + while true { + let step = sqlite3_step(stmt) + if step == SQLITE_DONE { break } + guard step == SQLITE_ROW else { return nil } + + let model = self.columnText(stmt, 0) ?? "" + let createdMs = sqlite3_column_int64(stmt, 1) + let input = Int(sqlite3_column_int64(stmt, 2)) + let output = Int(sqlite3_column_int64(stmt, 3)) + let reasoning = Int(sqlite3_column_int64(stmt, 4)) + let cacheRead = Int(sqlite3_column_int64(stmt, 5)) + let cacheCreation = Int(sqlite3_column_int64(stmt, 6)) + let cost: Double? = sqlite3_column_type(stmt, 7) == SQLITE_NULL + ? nil + : sqlite3_column_double(stmt, 7) + + guard createdMs > 0, + input >= 0, output >= 0, reasoning >= 0, cacheRead >= 0, cacheCreation >= 0 + else { + continue + } + rows.append(UsageRow( + model: model.isEmpty ? "minimax" : model, + createdMs: createdMs, + input: input, + output: output, + reasoning: reasoning, + cacheRead: cacheRead, + cacheCreation: cacheCreation, + cost: cost)) + } + return rows + } + + private static func hasTable(named name: String, db: OpaquePointer?) -> Bool { + var stmt: OpaquePointer? + guard sqlite3_prepare_v2( + db, + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1", + -1, + &stmt, + nil) == SQLITE_OK + else { + return false + } + defer { sqlite3_finalize(stmt) } + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(stmt, 1, name, -1, transient) + return sqlite3_step(stmt) == SQLITE_ROW + } + + private static func columnText(_ stmt: OpaquePointer?, _ index: Int32) -> String? { + guard sqlite3_column_type(stmt, index) != SQLITE_NULL, + let cString = sqlite3_column_text(stmt, index) + else { + return nil + } + return String(cString: cString) + } + + // MARK: - Helpers + + private static func sum(_ values: [Int]) -> Int? { + var result = 0 + for value in values { + let addition = result.addingReportingOverflow(value) + guard !addition.overflow else { return nil } + result = addition.partialValue + } + return result + } +} +#endif diff --git a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeSessionScanner.swift b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeSessionScanner.swift new file mode 100644 index 0000000000..7b6a3a1967 --- /dev/null +++ b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeSessionScanner.swift @@ -0,0 +1,568 @@ +import Foundation + +#if canImport(SQLite3) +import SQLite3 +#elseif canImport(CSQLite3) +import CSQLite3 +#endif + +/// Scans local OpenCode message storage and folds assistant-turn token usage into a token-only +/// `CostUsageTokenSnapshot` for the Usage & Spend dashboard. Same shape as +/// `KimiCodeSessionScanner`; the on-disk semantics mirror tokscale's `sessions/opencode.rs`: +/// +/// - Root: `$XDG_DATA_HOME/opencode` (default `~/.local/share/opencode`). +/// - Messages: `storage/message//*.json`, one message per file, with `role`, +/// `modelID`/`providerID` (v2 payloads may nest the model under `model.id`), a required +/// `tokens {input, output, reasoning?, cache {read, write}}` object, and `time.created` / +/// `time.completed` as epoch milliseconds (float allowed). +/// +/// Only `role == "assistant"` files carry billable usage; role-less files are skipped on purpose +/// (the missing-role shortcut in tokscale applies solely to its type-filtered SQLite query). +/// `cache.write` maps to `cacheCreationTokens`. `reasoning` stays folded into billing +/// `outputTokens` (reasoning is part of output for billing) and is additionally surfaced as the +/// `reasoningTokens` sub-bucket. Negative values mark the record corrupt (skipped) +/// rather than clamped, matching this repo's `KimiCodeSessionScanner` robustness style. +/// +/// Deduplication mirrors tokscale: a message's dedup key is its embedded `id`, falling back to +/// the file stem, and repeated keys collapse into one record — this absorbs forked-session +/// copies, which keep the same message id (and usually the same filename) in a second session +/// directory. +/// +/// Future work — SQLite storage: OpenCode 1.2+ also keeps messages in `opencode.db` (`message` +/// table, role inside the JSON `data` column) and newer channel builds in +/// `opencode-.db` (`session_message` table, model nested under `$.model`). The package +/// already links the SQLite3 C module (see `OpenCodeGoLocalUsageReader`), so support can be +/// added without new dependencies. When it lands, the same dedup keys must be shared with this +/// JSON pass so messages present in both stores collapse (tokscale dedups JSON vs DB the same +/// way); DB rows additionally collapse on a full-field fingerprint — created/completed +/// timestamps, model/provider, token counts, cost, agent — whenever the embedded ids do not +/// conflict. +public enum OpenCodeSessionScanner { + public static let defaultHistoryDays = 30 + + /// Environment override for the XDG data home, resolved directly here (the same way + /// `KimiSettingsReader` honors `KIMI_CODE_HOME`) so this scanner stays self-contained. + public static let dataHomeEnvironmentKey = "XDG_DATA_HOME" + + // MARK: - Wire models + + private struct WireMessage: Decodable { + struct Tokens: Decodable { + struct Cache: Decodable { + let read: Int + let write: Int + } + + let input: Int + let output: Int + let reasoning: Int? + let cache: Cache + } + + struct Model: Decodable { + let id: String? + } + + struct Time: Decodable { + let created: Double + } + + let id: String? + let role: String? + let modelID: String? + let model: Model? + let tokens: Tokens? + let time: Time + } + + // MARK: - Aggregation + + private struct NormalizedUsage { + let input: Int + let output: Int + let cacheRead: Int + let cacheCreation: Int + /// `reasoning` tokens; already included in `output` (billing-inclusive), tracked separately. + let reasoning: Int + } + + private struct DayModelKey: Hashable { + let day: String + let model: String + } + + /// One assistant-turn usage record, normalized from either a JSON message file or an + /// `opencode.db` row so both storage passes share the aggregation path. + private struct UsageRecord { + let dedupKey: String + let day: String + let model: String + let usage: NormalizedUsage + /// Provider-reported cost (only present for `opencode.db` rows; JSON files carry none). + let cost: Double? + /// Full-field fingerprint used to collapse JSON-vs-DB duplicates when ids don't conflict. + let fingerprint: String + } + + private struct TokenAccumulator { + var input = 0 + var cacheRead = 0 + var cacheCreation = 0 + var output = 0 + var reasoning = 0 + var requests = 0 + + mutating func add(_ usage: NormalizedUsage) -> Bool { + guard let nextInput = Self.adding(self.input, usage.input), + let nextCacheRead = Self.adding(self.cacheRead, usage.cacheRead), + let nextCacheCreation = Self.adding(self.cacheCreation, usage.cacheCreation), + let nextOutput = Self.adding(self.output, usage.output), + let nextReasoning = Self.adding(self.reasoning, usage.reasoning), + let nextRequests = Self.adding(self.requests, 1) + else { + return false + } + self.input = nextInput + self.cacheRead = nextCacheRead + self.cacheCreation = nextCacheCreation + self.output = nextOutput + self.reasoning = nextReasoning + self.requests = nextRequests + return true + } + + mutating func merge(_ other: TokenAccumulator) -> Bool { + guard let nextInput = Self.adding(self.input, other.input), + let nextCacheRead = Self.adding(self.cacheRead, other.cacheRead), + let nextCacheCreation = Self.adding(self.cacheCreation, other.cacheCreation), + let nextOutput = Self.adding(self.output, other.output), + let nextReasoning = Self.adding(self.reasoning, other.reasoning), + let nextRequests = Self.adding(self.requests, other.requests) + else { + return false + } + self.input = nextInput + self.cacheRead = nextCacheRead + self.cacheCreation = nextCacheCreation + self.output = nextOutput + self.reasoning = nextReasoning + self.requests = nextRequests + return true + } + + var total: Int? { + guard let inputAndCacheRead = Self.adding(self.input, self.cacheRead), + let withCacheCreation = Self.adding(inputAndCacheRead, self.cacheCreation) + else { + return nil + } + return Self.adding(withCacheCreation, self.output) + } + + private static func adding(_ lhs: Int, _ rhs: Int) -> Int? { + let result = lhs.addingReportingOverflow(rhs) + return result.overflow ? nil : result.partialValue + } + } + + // MARK: - Scanning + + public static func scan( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default, + historyDays: Int = defaultHistoryDays, + now: Date = Date(), + calendar: Calendar = .current) -> CostUsageTokenSnapshot? + { + let days = max(1, historyDays) + let end = calendar.startOfDay(for: now) + let start = calendar.date(byAdding: .day, value: -(days - 1), to: end) ?? end + + var context = ScanContext(start: start, end: end, calendar: calendar) + var records: [UsageRecord] = [] + records.append(contentsOf: self.scanJSONMessages( + environment: environment, + fileManager: fileManager, + context: &context)) + records.append(contentsOf: self.scanDatabaseMessages( + environment: environment, + context: &context)) + + var values: [DayModelKey: TokenAccumulator] = [:] + var costs: [DayModelKey: Double] = [:] + for record in records { + let key = DayModelKey(day: record.day, model: record.model) + var value = values[key] ?? TokenAccumulator() + guard value.add(record.usage) else { continue } + values[key] = value + if let cost = record.cost, cost.isFinite, cost >= 0 { + costs[key] = (costs[key] ?? 0) + cost + } + } + + guard !values.isEmpty else { return nil } + let byDay = Dictionary(grouping: values, by: \.key.day) + let daily = byDay.keys.sorted().compactMap { day -> CostUsageDailyReport.Entry? in + let models = (byDay[day] ?? []).sorted { lhs, rhs in + lhs.key.model.localizedCaseInsensitiveCompare(rhs.key.model) == .orderedAscending + } + var total = TokenAccumulator() + var dayCost = 0.0 + var dayCostSeen = false + var modelBreakdowns: [CostUsageDailyReport.ModelBreakdown] = [] + for (key, value) in models { + guard let modelTotal = value.total else { return nil } + guard total.merge(value) else { return nil } + let modelCost = costs[key] + if let modelCost { dayCost += modelCost; dayCostSeen = true } + modelBreakdowns.append(CostUsageDailyReport.ModelBreakdown( + modelName: key.model, + costUSD: modelCost, + totalTokens: modelTotal, + inputTokens: value.input, + cacheReadTokens: value.cacheRead, + cacheCreationTokens: value.cacheCreation, + outputTokens: value.output, + reasoningTokens: value.reasoning > 0 ? value.reasoning : nil, + requestCount: value.requests)) + } + guard let totalTokens = total.total else { return nil } + return CostUsageDailyReport.Entry( + date: day, + inputTokens: total.input, + outputTokens: total.output, + cacheReadTokens: total.cacheRead, + cacheCreationTokens: total.cacheCreation, + totalTokens: totalTokens, + requestCount: total.requests, + costUSD: dayCostSeen ? dayCost : nil, + modelsUsed: modelBreakdowns.map(\.modelName), + modelBreakdowns: modelBreakdowns) + } + let totalTokens = self.sum(daily.compactMap(\.totalTokens)) + let totalRequests = self.sum(daily.compactMap(\.requestCount)) + guard let totalTokens, let totalRequests else { return nil } + // Cost only exists for `opencode.db` rows (JSON files carry none). When nothing was priced, + // stay token-only ("XXX"/nil) so the dashboard does not show a phantom zero spend. + let totalCost = self.sum(daily.compactMap(\.costUSD)) + + return CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + sessionRequests: nil, + last30DaysTokens: totalTokens, + last30DaysCostUSD: totalCost, + last30DaysRequests: totalRequests, + currencyCode: totalCost != nil ? "USD" : "XXX", + historyDays: days, + historyCoverageIsEstablished: true, + historyLabel: "OpenCode", + daily: daily, + updatedAt: now) + } + + /// JSON message files (OpenCode < 1.2). Only assistant turns bill tokens. + /// Shared scan window and cross-source dedup state threaded through the JSON and SQLite scanners. + private struct ScanContext { + let start: Date + let end: Date + let calendar: Calendar + var seenMessageIDs: Set = [] + var seenFingerprints: Set = [] + } + + private static func scanJSONMessages( + environment: [String: String], + fileManager: FileManager, + context: inout ScanContext) -> [UsageRecord] + { + let start = context.start + let end = context.end + let calendar = context.calendar + let storage = self.opencodeMessageStorageURL(environment: environment) + guard let enumerator = fileManager.enumerator( + at: storage, + includingPropertiesForKeys: [.isRegularFileKey, .contentModificationDateKey], + options: [.skipsHiddenFiles]) + else { + return [] + } + + let decoder = JSONDecoder() + var records: [UsageRecord] = [] + while let url = enumerator.nextObject() as? URL { + guard url.pathExtension.lowercased() == "json" else { continue } + if let modificationDate = try? url.resourceValues(forKeys: [.contentModificationDateKey]) + .contentModificationDate, + modificationDate < start + { + continue + } + guard let data = try? Data(contentsOf: url), + let message = try? decoder.decode(WireMessage.self, from: data) + else { + continue + } + guard message.role == "assistant", + let model = self.cleaned(message.modelID ?? message.model?.id), + let tokens = message.tokens, + let usage = self.normalize(tokens), + message.time.created.isFinite + else { + continue + } + let date = Date(timeIntervalSince1970: message.time.created / 1000) + let day = calendar.startOfDay(for: date) + guard day >= start, day <= end else { continue } + let dedupKey = self.cleaned(message.id) ?? url.deletingPathExtension().lastPathComponent + let fingerprint = self.fingerprint( + createdMs: Int64(message.time.created.rounded()), + model: model, + usage: usage, + cost: nil) + guard context.seenMessageIDs.insert(dedupKey).inserted, + context.seenFingerprints.insert(fingerprint).inserted + else { continue } + records.append(UsageRecord( + dedupKey: dedupKey, + day: CostUsageLocalDay.key(from: day, calendar: calendar), + model: model, + usage: usage, + cost: nil, + fingerprint: fingerprint)) + } + return records + } + + /// `opencode.db` (OpenCode 1.2+). The `message` table keeps each message as a JSON `data` + /// blob; assistant rows carry `modelID`/`model.id`, `tokens{input,output,reasoning,cache{read,write}}`, + /// `cost` (provider-reported USD), and `time.created` (epoch ms). `json_extract` reads them in + /// SQL so we never materialize the blob. + private static func scanDatabaseMessages( + environment: [String: String], + context: inout ScanContext) -> [UsageRecord] + { + let start = context.start + let end = context.end + let calendar = context.calendar + let dbURL = self.opencodeDatabaseURL(environment: environment) + guard FileManager.default.fileExists(atPath: dbURL.path) else { return [] } + + var db: OpaquePointer? + // WAL-journaled: `immutable=1` reads the main file directly without -shm/-wal setup. + let uri = "file://\(dbURL.path)?immutable=1" + guard sqlite3_open_v2(uri, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_URI, nil) == SQLITE_OK else { + sqlite3_close(db) + return [] + } + defer { sqlite3_close(db) } + sqlite3_busy_timeout(db, 250) + + guard self.hasTable(named: "message", db: db) else { return [] } + + let sql = """ + SELECT + COALESCE(NULLIF(json_extract(data, '$.id'), ''), ''), + COALESCE( + NULLIF(json_extract(data, '$.modelID'), ''), + json_extract(data, '$.model.id')), + json_extract(data, '$.time.created'), + json_extract(data, '$.tokens.input'), + json_extract(data, '$.tokens.output'), + json_extract(data, '$.tokens.reasoning'), + json_extract(data, '$.tokens.cache.read'), + json_extract(data, '$.tokens.cache.write'), + json_extract(data, '$.cost') + FROM message + WHERE json_extract(data, '$.role') = 'assistant' + """ + + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] } + defer { sqlite3_finalize(stmt) } + + var records: [UsageRecord] = [] + while true { + let step = sqlite3_step(stmt) + if step == SQLITE_DONE { break } + guard step == SQLITE_ROW else { return [] } + + let messageID = self.columnText(stmt, 0) + guard let model = self.cleaned(self.columnText(stmt, 1)) else { continue } + let createdMs = sqlite3_column_type(stmt, 2) == SQLITE_NULL + ? 0 + : Int64(sqlite3_column_double(stmt, 2)) + guard createdMs > 0 else { continue } + let input = Int(sqlite3_column_int64(stmt, 3)) + let output = Int(sqlite3_column_int64(stmt, 4)) + let reasoning = sqlite3_column_type(stmt, 5) == SQLITE_NULL ? 0 : Int(sqlite3_column_int64(stmt, 5)) + let cacheRead = sqlite3_column_type(stmt, 6) == SQLITE_NULL ? 0 : Int(sqlite3_column_int64(stmt, 6)) + let cacheCreation = sqlite3_column_type(stmt, 7) == SQLITE_NULL ? 0 : Int(sqlite3_column_int64(stmt, 7)) + let cost: Double? = sqlite3_column_type(stmt, 8) == SQLITE_NULL + ? nil + : sqlite3_column_double(stmt, 8) + + guard input >= 0, output >= 0, reasoning >= 0, cacheRead >= 0, cacheCreation >= 0, + let foldedOutput = self.adding(output, reasoning) + else { + continue + } + let usage = NormalizedUsage( + input: input, + output: foldedOutput, + cacheRead: cacheRead, + cacheCreation: cacheCreation, + reasoning: reasoning) + + let date = Date(timeIntervalSince1970: Double(createdMs) / 1000) + let day = calendar.startOfDay(for: date) + guard day >= start, day <= end else { continue } + + let fingerprint = self.fingerprint(createdMs: createdMs, model: model, usage: usage, cost: cost) + // tokscale dedups JSON vs DB by embedded id, then by full-field fingerprint when ids + // don't conflict — so a message present in both stores collapses to one record. + if let messageID, !messageID.isEmpty { + guard context.seenMessageIDs.insert(messageID).inserted else { continue } + } + guard context.seenFingerprints.insert(fingerprint).inserted else { continue } + + records.append(UsageRecord( + dedupKey: messageID ?? fingerprint, + day: CostUsageLocalDay.key(from: day, calendar: calendar), + model: model, + usage: usage, + cost: cost, + fingerprint: fingerprint)) + } + return records + } + + // MARK: - Paths + + public static func opencodeMessageStorageURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + self.opencodeDataRootURL(environment: environment) + .appendingPathComponent("storage", isDirectory: true) + .appendingPathComponent("message", isDirectory: true) + } + + public static func opencodeDatabaseURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + self.opencodeDataRootURL(environment: environment) + .appendingPathComponent("opencode.db", isDirectory: false) + } + + private static func opencodeDataRootURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + let dataHome = if let override = self.cleaned(environment[self.dataHomeEnvironmentKey]) { + URL(fileURLWithPath: override, isDirectory: true) + } else { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".local", isDirectory: true) + .appendingPathComponent("share", isDirectory: true) + } + return dataHome.appendingPathComponent("opencode", isDirectory: true) + } + + // MARK: - Helpers + + private static func normalize(_ tokens: WireMessage.Tokens) -> NormalizedUsage? { + let reasoning = tokens.reasoning ?? 0 + guard tokens.input >= 0, tokens.output >= 0, reasoning >= 0, + tokens.cache.read >= 0, tokens.cache.write >= 0 + else { + return nil + } + // Reasoning is part of billing output, so it stays folded into `output`; the + // `reasoning` bucket surfaces the same count separately (never add it on top). + guard let output = self.adding(tokens.output, reasoning) else { return nil } + return NormalizedUsage( + input: tokens.input, + output: output, + cacheRead: tokens.cache.read, + cacheCreation: tokens.cache.write, + reasoning: reasoning) + } + + private static func cleaned(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private static func adding(_ lhs: Int, _ rhs: Int) -> Int? { + let result = lhs.addingReportingOverflow(rhs) + return result.overflow ? nil : result.partialValue + } + + private static func sum(_ values: [Int]) -> Int? { + var result = 0 + for value in values { + let addition = result.addingReportingOverflow(value) + guard !addition.overflow else { return nil } + result = addition.partialValue + } + return result + } + + private static func sum(_ values: [Double]) -> Double? { + guard !values.isEmpty else { return nil } + var result = 0.0 + for value in values { + result += value + guard result.isFinite else { return nil } + } + return result + } + + /// Full-field fingerprint for cross-store dedup (created ts, model, token counts, cost). + private static func fingerprint( + createdMs: Int64, + model: String, + usage: NormalizedUsage, + cost: Double?) -> String + { + let costText = cost.map { String(format: "%.10f", $0) } ?? "nil" + return [ + String(createdMs), + model, + String(usage.input), + String(usage.output), + String(usage.cacheRead), + String(usage.cacheCreation), + String(usage.reasoning), + costText, + ].joined(separator: "|") + } + + // MARK: - SQLite helpers + + private static func hasTable(named name: String, db: OpaquePointer?) -> Bool { + var stmt: OpaquePointer? + guard sqlite3_prepare_v2( + db, + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1", + -1, + &stmt, + nil) == SQLITE_OK + else { + return false + } + defer { sqlite3_finalize(stmt) } + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(stmt, 1, name, -1, transient) + return sqlite3_step(stmt) == SQLITE_ROW + } + + private static func columnText(_ stmt: OpaquePointer?, _ index: Int32) -> String? { + guard sqlite3_column_type(stmt, index) != SQLITE_NULL, + let cString = sqlite3_column_text(stmt, index) + else { + return nil + } + return String(cString: cString) + } +} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CodexSubagentRolloutShape.swift b/Sources/CodexBarCore/Vendored/CostUsage/CodexSubagentRolloutShape.swift index 56f5608a56..f0f6792650 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CodexSubagentRolloutShape.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CodexSubagentRolloutShape.swift @@ -187,14 +187,16 @@ extension CostUsageScanner { private static func totalsEqual(_ lhs: CostUsageCodexTotals, _ rhs: CostUsageCodexTotals) -> Bool { lhs.input == rhs.input && lhs.cached == rhs.cached && lhs.output == rhs.output + && lhs.reasoning == rhs.reasoning } private static func totalsAtLeast(_ lhs: CostUsageCodexTotals, _ rhs: CostUsageCodexTotals) -> Bool { lhs.input >= rhs.input && lhs.cached >= rhs.cached && lhs.output >= rhs.output + && lhs.reasoning >= rhs.reasoning } private static func totalsContainUsage(_ totals: CostUsageCodexTotals) -> Bool { - totals.input > 0 || totals.cached > 0 || totals.output > 0 + totals.input > 0 || totals.cached > 0 || totals.output > 0 || totals.reasoning > 0 } private static func normalizedSessionID(_ value: String?) -> String? { diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index 7323a07c43..50438faa24 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -130,6 +130,9 @@ struct CostUsageCache: Codable { struct CostUsageFileUsage: Codable { var mtimeUnixMs: Int64 var size: Int64 + /// Sampling fingerprint recorded at scan time; nil on pre-fingerprint cache entries, + /// which are treated as stale and rescanned once. + var fingerprint: CostUsageSourceFingerprint? var days: [String: [String: [Int]]] var parsedBytes: Int64? var lastModel: String? @@ -162,4 +165,7 @@ struct CostUsageCodexTotals: Codable, Equatable { var input: Int var cached: Int var output: Int + /// Reasoning output tokens (`reasoning_output_tokens`); a sub-bucket of `output`, mirroring + /// tokscale's `CodexTotals`. Component-wise delta math carries it exactly like `output`. + var reasoning: Int = 0 } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+ThirdParty.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+ThirdParty.swift new file mode 100644 index 0000000000..328ad7af78 --- /dev/null +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+ThirdParty.swift @@ -0,0 +1,72 @@ +import Foundation + +extension CostUsagePricing { + /// Resolves pricing for third-party models that are routed through the Claude-compatible + /// endpoint (DeepSeek, Kimi/Moonshot, MiniMax) via the models.dev catalog. + static func thirdPartyClaudeLookup( + model: String, + catalog: ModelsDevCatalog?, + cacheRoot: URL?) -> ClaudePricing? + { + let trimmed = model.trimmingCharacters(in: .whitespacesAndNewlines) + let lower = trimmed.lowercased() + + let candidates: [(providerID: String, modelID: String)] + if lower.hasPrefix("deepseek-") { + candidates = [("deepseek", trimmed)] + } else if lower == "kimi-for-coding" { + candidates = [ + ("kimi-for-coding", trimmed), + ("moonshotai", "kimi-k2.6"), + ("moonshotai-cn", "kimi-k2.6"), + ] + } else if lower.hasPrefix("kimi-") { + candidates = [("moonshotai", trimmed), ("moonshotai-cn", trimmed)] + } else if lower.hasPrefix("minimax-") { + candidates = [("minimax", trimmed), ("minimax-cn", trimmed)] + } else { + return nil + } + + for candidate in candidates { + if let lookup = self.modelsDevLookup( + providerID: candidate.providerID, + model: candidate.modelID, + catalog: catalog, + cacheRoot: cacheRoot) + { + return ClaudePricing( + inputCostPerToken: lookup.pricing.inputCostPerToken, + outputCostPerToken: lookup.pricing.outputCostPerToken, + cacheCreationInputCostPerToken: lookup.pricing.cacheCreationInputCostPerToken + ?? lookup.pricing.inputCostPerToken, + cacheReadInputCostPerToken: lookup.pricing.cacheReadInputCostPerToken + ?? lookup.pricing.inputCostPerToken, + thresholdTokens: lookup.pricing.thresholdTokens, + inputCostPerTokenAboveThreshold: lookup.pricing.inputCostPerTokenAboveThreshold, + outputCostPerTokenAboveThreshold: lookup.pricing.outputCostPerTokenAboveThreshold, + cacheCreationInputCostPerTokenAboveThreshold: lookup.pricing + .cacheCreationInputCostPerTokenAboveThreshold, + cacheReadInputCostPerTokenAboveThreshold: lookup.pricing + .cacheReadInputCostPerTokenAboveThreshold) + } + } + return nil + } + + static func modelsDevLookup( + providerID: String, + model: String, + catalog: ModelsDevCatalog?, + cacheRoot: URL?) -> ModelsDevPricingLookup? + { + if let catalog { + return catalog.pricing(providerID: providerID, modelID: model) + } + + return ModelsDevPricingPipeline.lookup( + providerID: providerID, + modelID: model, + cacheRoot: cacheRoot) + } +} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift index 3727bf2ce2..ed2df0007f 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift @@ -148,6 +148,15 @@ enum CostUsagePricing { outputCostPerToken: 0, cacheReadInputCostPerToken: 0, displayLabel: "Research Preview"), + // Auto-review turns are an internal Codex feature billed within the subscription, not per + // token, so they carry no price. Pricing them at zero (like spark above) keeps those days + // "proven zero-cost" instead of unpriced, which would otherwise void the whole provider's + // cost history via the all-or-nothing completeness check. + "codex-auto-review": CodexPricing( + inputCostPerToken: 0, + outputCostPerToken: 0, + cacheReadInputCostPerToken: 0, + displayLabel: "Included"), "gpt-5.4": CodexPricing( inputCostPerToken: 2.5e-6, outputCostPerToken: 1.5e-5, @@ -576,13 +585,34 @@ enum CostUsagePricing { outputTokens: outputTokens) } - guard let pricing = self.codex[key] else { return nil } - return self.codexCostUSD( - pricing: pricing, - inputTokens: inputTokens, - cachedInputTokens: cachedInputTokens, - cacheWriteInputTokens: cacheWriteInputTokens, - outputTokens: outputTokens) + if let pricing = self.codex[key] { + return self.codexCostUSD( + pricing: pricing, + inputTokens: inputTokens, + cachedInputTokens: cachedInputTokens, + cacheWriteInputTokens: cacheWriteInputTokens, + outputTokens: outputTokens) + } + + // Non-OpenAI models routed through a Codex-compatible endpoint (e.g. MiniMax / DeepSeek / + // Kimi). Price them at the vendor's official per-token rates so a single third-party day + // does not void the provider's whole cost history via the all-or-nothing check. + if let thirdParty = self.thirdPartyClaudeLookup( + model: model, + catalog: modelsDevCatalog, + cacheRoot: modelsDevCacheRoot) + { + return self.claudeCostUSD( + pricing: thirdParty, + tokens: ClaudeCostTokens( + input: inputTokens, + cacheRead: cachedInputTokens, + cacheCreation: cacheWriteInputTokens, + cacheCreation1h: 0, + output: outputTokens)) + } + + return nil } static func codexPriorityCostUSD( @@ -733,12 +763,26 @@ enum CostUsagePricing { tokens: tokens) } + // Non-Anthropic models routed through a Claude-compatible endpoint (Kimi / DeepSeek / + // MiniMax coding plans): price them at the vendor's official per-token rates so the spend + // dashboard shows an equivalent-cost estimate instead of "unavailable". + if let thirdParty = self.thirdPartyClaudeLookup( + model: model, + catalog: modelsDevCatalog, + cacheRoot: modelsDevCacheRoot) + { + return self.claudeCostUSD(pricing: thirdParty, tokens: tokens) + } + guard let pricing = self.claude[key] else { return nil } return self.claudeCostUSD( pricing: pricing, tokens: tokens) } + /// Maps a non-Anthropic model seen on the Claude endpoint to its vendor's official models.dev + /// pricing. `kimi-for-coding` is a subscription alias; it is priced as Moonshot's kimi-k2.6, + /// the current default coding model. Returns nil when no official rate is known. private static func claudeCostUSD( pricing: ClaudePricing, tokens: ClaudeCostTokens) -> Double @@ -792,20 +836,4 @@ enum CostUsagePricing { static func modelsDevCatalog(now: Date = Date(), cacheRoot: URL? = nil) -> ModelsDevCatalog? { ModelsDevCache.load(now: now, cacheRoot: cacheRoot).artifact?.catalog } - - private static func modelsDevLookup( - providerID: String, - model: String, - catalog: ModelsDevCatalog?, - cacheRoot: URL?) -> ModelsDevPricingLookup? - { - if let catalog { - return catalog.pricing(providerID: providerID, modelID: model) - } - - return ModelsDevPricingPipeline.lookup( - providerID: providerID, - modelID: model, - cacheRoot: cacheRoot) - } } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricingKey.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricingKey.swift index 1e3e8d565e..8e90918fd3 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricingKey.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricingKey.swift @@ -10,7 +10,17 @@ enum CostUsagePricingKey { modelsDevArtifact: ModelsDevCacheArtifact?, formulaVersion: Int, parserHash: String? = nil, - modelsDevProviderIDs: Set = ["openai"]) -> String + modelsDevProviderIDs: Set = [ + "openai", + // Third-party models routed through a Codex-compatible endpoint are priced via + // `thirdPartyClaudeLookup`, so their catalog entries must also bust the cost cache. + "deepseek", + "minimax", + "minimax-cn", + "moonshotai", + "moonshotai-cn", + "kimi-for-coding", + ]) -> String { var parts = [ "costFormulaVersion=\(formulaVersion)", diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift index 1cf784ff34..eb644bd660 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift @@ -274,6 +274,7 @@ extension CostUsageScanner { static func makeFileUsage( mtimeUnixMs: Int64, size: Int64, + fingerprint: CostUsageSourceFingerprint? = nil, days: [String: [String: [Int]]], parsedBytes: Int64?, lastModel: String? = nil, @@ -304,6 +305,7 @@ extension CostUsageScanner { CostUsageFileUsage( mtimeUnixMs: mtimeUnixMs, size: size, + fingerprint: fingerprint, days: days, parsedBytes: parsedBytes, lastModel: lastModel, @@ -625,6 +627,7 @@ extension CostUsageScanner { String(row.input), String(row.cached), String(row.output), + String(row.reasoning), ].joined(separator: "\u{1F}") } @@ -665,9 +668,13 @@ extension CostUsageScanner { var days: [String: [String: [Int]]] = [:] for row in rows { let packed = days[row.day]?[row.model] ?? [] + // Sparse layout matching the live-scan packing: no reasoning slot when it is zero. + let delta = row.reasoning == 0 + ? [row.input, row.cached, row.output] + : [row.input, row.cached, row.output, row.reasoning] days[row.day, default: [:]][row.model] = Self.addPacked( a: packed, - b: [row.input, row.cached, row.output], + b: delta, sign: 1) } return days @@ -696,6 +703,7 @@ extension CostUsageScanner { return Self.makeFileUsage( mtimeUnixMs: usage.mtimeUnixMs, size: usage.size, + fingerprint: usage.fingerprint, days: days, parsedBytes: usage.parsedBytes, lastModel: usage.lastModel, @@ -820,6 +828,7 @@ extension CostUsageScanner { struct CodexFileMetadata { let path: String let mtimeUnixMs: Int64 + let mtimeUnixNs: Int64 let size: Int64 let fileId: String? } @@ -834,7 +843,7 @@ extension CostUsageScanner { let path = fileURL.path var info = stat() guard path.withCString({ fstatat(AT_FDCWD, $0, &info, 0) }) == 0 else { - return CodexFileMetadata(path: path, mtimeUnixMs: 0, size: 0, fileId: nil) + return CodexFileMetadata(path: path, mtimeUnixMs: 0, mtimeUnixNs: 0, size: 0, fileId: nil) } #if os(Linux) let modifiedSeconds = Int64(info.st_mtim.tv_sec) @@ -846,6 +855,7 @@ extension CostUsageScanner { return CodexFileMetadata( path: path, mtimeUnixMs: modifiedSeconds * 1000 + modifiedNanoseconds / 1_000_000, + mtimeUnixNs: modifiedSeconds * 1_000_000_000 + modifiedNanoseconds, size: Int64(info.st_size), fileId: "\(info.st_dev):\(info.st_ino)") } @@ -886,18 +896,31 @@ extension CostUsageScanner { static func keepCachedCodexFileIfFresh( input: CodexFileScanInput, + freshness: CostUsageSourceFingerprint.Freshness, context: CodexFileScanContext, cache: inout CostUsageCache, state: inout CodexScanState) throws -> Bool { - guard let cached = input.cached else { return false } - let needsSessionId = cached.sessionId == nil - guard cached.mtimeUnixMs == input.metadata.mtimeUnixMs, - cached.size == input.metadata.size, - !needsSessionId, + guard let cachedEntry = input.cached else { return false } + let needsSessionId = cachedEntry.sessionId == nil + guard !needsSessionId, !context.forceFullScan else { return false } + // Sampling fingerprint freshness (tokscale-style): an exact size+mtime+samples match is + // a hit; a metadata-only touch (same whole-file hash) is a hit with a refreshed + // fingerprint; anything else falls through to incremental/full parsing. + var cached = cachedEntry + switch freshness { + case .unchanged: + break + case let .touched(fresh): + cached.fingerprint = fresh + cached.mtimeUnixMs = input.metadata.mtimeUnixMs + case .changed: + return false + } + guard !Self.cachedCodexFileNeedsPriorityRescan(cached, context: context) else { return false } let sessionAlreadyContributed = cached.sessionId.map { state.contributingSessionIds.contains($0) } ?? false @@ -966,6 +989,7 @@ extension CostUsageScanner { static func appendCodexFileIncrementIfPossible( input: CodexFileScanInput, + freshness: CostUsageSourceFingerprint.Freshness, context: CodexFileScanContext, cache: inout CostUsageCache, state: inout CodexScanState) throws -> Bool @@ -1001,6 +1025,14 @@ extension CostUsageScanner { && !hasIncompleteInterleaveState guard canIncremental else { return false } + // Fingerprint the post-append content before parsing the delta so a concurrent rewrite + // invalidates the entry on the next scan instead of being absorbed silently. Reuse the + // fingerprint already computed during freshness validation when available. + let fingerprint = freshness.freshFingerprint ?? CostUsageSourceFingerprint.make( + fileURL: input.fileURL, + size: input.metadata.size, + mtimeUnixNs: input.metadata.mtimeUnixNs) + let delta = try Self.parseCodexFileCancellable( fileURL: input.fileURL, range: context.range, @@ -1075,6 +1107,7 @@ extension CostUsageScanner { cache.files[input.metadata.path] = Self.makeFileUsage( mtimeUnixMs: input.metadata.mtimeUnixMs, size: input.metadata.size, + fingerprint: fingerprint, days: mergedDays, parsedBytes: delta.parsedBytes, lastModel: delta.lastModel, @@ -1123,6 +1156,7 @@ extension CostUsageScanner { static func rescanCodexFile( input: CodexFileScanInput, + freshness: CostUsageSourceFingerprint.Freshness, context: CodexFileScanContext, cache: inout CostUsageCache, state: inout CodexScanState) throws @@ -1136,6 +1170,14 @@ extension CostUsageScanner { ? [:] : Self.fileDaysOutsideScanWindow(migratedCached?.days ?? [:], range: context.range) + // Fingerprint before the full parse: any concurrent rewrite then invalidates the entry + // on the next scan instead of leaving a stale fingerprint behind. Reuse the fingerprint + // already computed during freshness validation when available. + let fingerprint = freshness.freshFingerprint ?? CostUsageSourceFingerprint.make( + fileURL: input.fileURL, + size: input.metadata.size, + mtimeUnixNs: input.metadata.mtimeUnixNs) + let parsed = try Self.parseCodexFileCancellable( fileURL: input.fileURL, range: context.range, @@ -1176,6 +1218,7 @@ extension CostUsageScanner { cache.files[input.metadata.path] = Self.makeFileUsage( mtimeUnixMs: input.metadata.mtimeUnixMs, size: input.metadata.size, + fingerprint: fingerprint, days: usageDays, parsedBytes: parsed.parsedBytes, lastModel: parsed.lastModel, @@ -1405,6 +1448,9 @@ extension CostUsageScanner { let input = packed[safe: 0] ?? 0 let cached = packed[safe: 1] ?? 0 let output = packed[safe: 2] ?? 0 + // Reasoning is a sub-bucket of output (billing-inclusive), surfaced separately. + // A zero reads as "none recorded" and stays nil, matching the day-entry cache style. + let reasoning = packed[safe: 3] ?? 0 let totalTokens = input + output dayInput += input @@ -1451,6 +1497,7 @@ extension CostUsageScanner { inputTokens: input, cacheReadTokens: cached, outputTokens: output, + reasoningTokens: reasoning > 0 ? reasoning : nil, standardCostUSD: hasModeSplit ? standardCost : nil, priorityCostUSD: hasModeSplit ? priorityCost : nil, standardTokens: hasModeSplit ? cachedStandardTokens : nil, diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index 8d57a87c8f..8a6673b9b5 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -369,12 +369,14 @@ extension CostUsageScanner { private static func makeClaudeFileUsage( mtimeMs: Int64, size: Int64, + fingerprint: CostUsageSourceFingerprint? = nil, rows: [ClaudeUsageRow], parsedBytes: Int64?) -> CostUsageFileUsage { makeFileUsage( mtimeUnixMs: mtimeMs, size: size, + fingerprint: fingerprint, days: [:], parsedBytes: parsedBytes, claudeRows: rows) @@ -536,25 +538,47 @@ extension CostUsageScanner { url: URL, size: Int64, mtimeMs: Int64, + mtimeUnixNs: Int64, state: ClaudeScanState) throws { try state.checkCancellation?() let path = url.path state.touched.insert(path) - if let cached = state.cache.files[path], - cached.mtimeUnixMs == mtimeMs, - cached.size == size, - !state.forceFullScan - { - return - } - + // Reused by the incremental/full parse paths so a changed file is hashed only once. + var freshFingerprint: CostUsageSourceFingerprint? if let cached = state.cache.files[path], !state.forceFullScan { + // Sampling fingerprint freshness (tokscale-style): an exact size+mtime+samples match + // is a hit; a metadata-only touch (same whole-file hash) is a hit with a refreshed + // fingerprint; anything else falls through to incremental/full parsing. + switch CostUsageSourceFingerprint.check( + fileURL: url, + size: size, + mtimeUnixNs: mtimeUnixNs, + cached: cached.fingerprint) + { + case .unchanged: + return + case let .touched(fresh): + var updated = cached + updated.fingerprint = fresh + updated.mtimeUnixMs = mtimeMs + state.cache.files[path] = updated + return + case let .changed(fresh): + freshFingerprint = fresh + } + let startOffset = cached.parsedBytes ?? cached.size let canIncremental = size > cached.size && startOffset > 0 && startOffset <= size && cached.claudeRows != nil if canIncremental { + // Fingerprint the post-append content before parsing the delta so a concurrent + // rewrite invalidates the entry on the next scan. + let fingerprint = freshFingerprint ?? CostUsageSourceFingerprint.make( + fileURL: url, + size: size, + mtimeUnixNs: mtimeUnixNs) let delta = try Self.parseClaudeFileCancellable( fileURL: url, range: state.range, @@ -567,12 +591,19 @@ extension CostUsageScanner { state.cache.files[path] = Self.makeClaudeFileUsage( mtimeMs: mtimeMs, size: size, + fingerprint: fingerprint, rows: mergedRows, parsedBytes: delta.parsedBytes) return } } + // Fingerprint before the full parse: any concurrent rewrite then invalidates the entry + // on the next scan instead of leaving a stale fingerprint behind. + let fingerprint = freshFingerprint ?? CostUsageSourceFingerprint.make( + fileURL: url, + size: size, + mtimeUnixNs: mtimeUnixNs) let parsed = try Self.parseClaudeFileCancellable( fileURL: url, range: state.range, @@ -583,6 +614,7 @@ extension CostUsageScanner { let usage = Self.makeClaudeFileUsage( mtimeMs: mtimeMs, size: size, + fingerprint: fingerprint, rows: parsed.rows, parsedBytes: parsed.parsedBytes) state.cache.files[path] = usage @@ -639,10 +671,14 @@ extension CostUsageScanner { let mtime = values.contentModificationDate?.timeIntervalSince1970 ?? 0 let mtimeMs = Int64(mtime * 1000) + // Derived from the same resource value on every scan, so the conversion is + // deterministic for unchanged files even though Date carries sub-ms precision. + let mtimeNs = Int64((mtime * 1_000_000_000).rounded()) try Self.processClaudeFile( url: url, size: size, mtimeMs: mtimeMs, + mtimeUnixNs: mtimeNs, state: state) } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 7ff583f33d..931c791ba9 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -78,6 +78,28 @@ enum CostUsageScanner { let input: Int let cached: Int let output: Int + /// Reasoning output tokens; a sub-bucket of `output` (already included in it). + let reasoning: Int + + init( + day: String, + model: String, + turnID: String?, + eventIndex: Int?, + input: Int, + cached: Int, + output: Int, + reasoning: Int = 0) + { + self.day = day + self.model = model + self.turnID = turnID + self.eventIndex = eventIndex + self.input = input + self.cached = cached + self.output = output + self.reasoning = reasoning + } } struct CodexScanState { @@ -109,14 +131,17 @@ enum CostUsageScanner { private static func codexTotalsEqual(_ lhs: CostUsageCodexTotals?, _ rhs: CostUsageCodexTotals?) -> Bool { lhs?.input == rhs?.input && lhs?.cached == rhs?.cached && lhs?.output == rhs?.output + && lhs?.reasoning == rhs?.reasoning } private static func codexTotalsAtLeast(_ lhs: CostUsageCodexTotals, _ rhs: CostUsageCodexTotals) -> Bool { lhs.input >= rhs.input && lhs.cached >= rhs.cached && lhs.output >= rhs.output + && lhs.reasoning >= rhs.reasoning } private static func codexTotalsAtMost(_ lhs: CostUsageCodexTotals, _ rhs: CostUsageCodexTotals) -> Bool { lhs.input <= rhs.input && lhs.cached <= rhs.cached && lhs.output <= rhs.output + && lhs.reasoning <= rhs.reasoning } private static func codexShouldPreferTotalDelta( @@ -138,7 +163,8 @@ enum CostUsageScanner { CostUsageCodexTotals( input: lhs.input + rhs.input, cached: lhs.cached + rhs.cached, - output: lhs.output + rhs.output) + output: lhs.output + rhs.output, + reasoning: lhs.reasoning + rhs.reasoning) } private static func codexMinTotals( @@ -148,18 +174,20 @@ enum CostUsageScanner { CostUsageCodexTotals( input: min(lhs.input, rhs.input), cached: min(lhs.cached, rhs.cached), - output: min(lhs.output, rhs.output)) + output: min(lhs.output, rhs.output), + reasoning: min(lhs.reasoning, rhs.reasoning)) } private static func codexTotalDelta( from baseline: CostUsageCodexTotals?, to current: CostUsageCodexTotals) -> CostUsageCodexTotals { - let baseline = baseline ?? .init(input: 0, cached: 0, output: 0) + let baseline = baseline ?? .init(input: 0, cached: 0, output: 0, reasoning: 0) return CostUsageCodexTotals( input: max(0, current.input - baseline.input), cached: max(0, current.cached - baseline.cached), - output: max(0, current.output - baseline.output)) + output: max(0, current.output - baseline.output), + reasoning: max(0, current.reasoning - baseline.reasoning)) } private static func codexDivergentTotalDelta( @@ -167,8 +195,8 @@ enum CostUsageScanner { countedBaseline: CostUsageCodexTotals?, current: CostUsageCodexTotals) -> CostUsageCodexTotals { - let rawBaseline = rawBaseline ?? .init(input: 0, cached: 0, output: 0) - let countedBaseline = countedBaseline ?? .init(input: 0, cached: 0, output: 0) + let rawBaseline = rawBaseline ?? .init(input: 0, cached: 0, output: 0, reasoning: 0) + let countedBaseline = countedBaseline ?? .init(input: 0, cached: 0, output: 0, reasoning: 0) func delta(raw: Int, counted: Int, current: Int) -> Int { if current >= raw { @@ -180,7 +208,11 @@ enum CostUsageScanner { return CostUsageCodexTotals( input: delta(raw: rawBaseline.input, counted: countedBaseline.input, current: current.input), cached: delta(raw: rawBaseline.cached, counted: countedBaseline.cached, current: current.cached), - output: delta(raw: rawBaseline.output, counted: countedBaseline.output, current: current.output)) + output: delta(raw: rawBaseline.output, counted: countedBaseline.output, current: current.output), + reasoning: delta( + raw: rawBaseline.reasoning, + counted: countedBaseline.reasoning, + current: current.reasoning)) } private static func codexMaxTotals( @@ -191,7 +223,8 @@ enum CostUsageScanner { return CostUsageCodexTotals( input: max(lhs.input, rhs.input), cached: max(lhs.cached, rhs.cached), - output: max(lhs.output, rhs.output)) + output: max(lhs.output, rhs.output), + reasoning: max(lhs.reasoning, rhs.reasoning)) } /// Post-latch totals containment for interleaved cumulative counters (issue #2037 Phase 1). @@ -205,8 +238,8 @@ enum CostUsageScanner { counted: CostUsageCodexTotals?, current: CostUsageCodexTotals) -> CostUsageCodexTotals { - let watermark = watermark ?? .init(input: 0, cached: 0, output: 0) - let counted = counted ?? .init(input: 0, cached: 0, output: 0) + let watermark = watermark ?? .init(input: 0, cached: 0, output: 0, reasoning: 0) + let counted = counted ?? .init(input: 0, cached: 0, output: 0, reasoning: 0) func component(water: Int, counted: Int, current: Int) -> Int { if current >= water { @@ -218,7 +251,8 @@ enum CostUsageScanner { return CostUsageCodexTotals( input: component(water: watermark.input, counted: counted.input, current: current.input), cached: component(water: watermark.cached, counted: counted.cached, current: current.cached), - output: component(water: watermark.output, counted: counted.output, current: current.output)) + output: component(water: watermark.output, counted: counted.output, current: current.output), + reasoning: component(water: watermark.reasoning, counted: counted.reasoning, current: current.reasoning)) } /// Post-latch event delta: contained totals growth, optionally capped by `last`. @@ -275,6 +309,7 @@ enum CostUsageScanner { if totals.input < watermark.input || totals.cached < watermark.cached || totals.output < watermark.output + || totals.reasoning < watermark.reasoning { self.sawInterleavedTotals = true } @@ -313,7 +348,7 @@ enum CostUsageScanner { last: CostUsageCodexTotals?, total: CostUsageCodexTotals?) -> CostUsageCodexTotals { - let base = self.countedTotals ?? .init(input: 0, cached: 0, output: 0) + let base = self.countedTotals ?? .init(input: 0, cached: 0, output: 0, reasoning: 0) if let total { // Best-effort exact re-emission suppression (precision only; containment is load-bearing). if self.tracker.isSeen(total) { @@ -1340,6 +1375,7 @@ enum CostUsageScanner { private static let codexJSONFieldModel = Array("model".utf8) private static let codexJSONFieldModelName = Array("model_name".utf8) private static let codexJSONFieldOutputTokens = Array("output_tokens".utf8) + private static let codexJSONFieldReasoningOutputTokens = Array("reasoning_output_tokens".utf8) private static let codexJSONFieldParentSessionId = Array("parent_session_id".utf8) private static let codexJSONFieldParentSessionIdCamel = Array("parentSessionId".utf8) private static let codexJSONFieldPayload = Array("payload".utf8) @@ -1533,7 +1569,14 @@ enum CostUsageScanner { Self .extractJSONByteIntField(Self.codexJSONFieldOutputTokens, from: bytes, in: objectRange, atDepth: 1) ?? 0) - return CostUsageCodexTotals(input: input, cached: cached, output: output) + let reasoning = max( + 0, + Self.extractJSONByteIntField( + Self.codexJSONFieldReasoningOutputTokens, + from: bytes, + in: objectRange, + atDepth: 1) ?? 0) + return CostUsageCodexTotals(input: input, cached: cached, output: output, reasoning: reasoning) } private static func codexInterAgentCommunication( @@ -1944,13 +1987,15 @@ enum CostUsageScanner { CostUsageCodexTotals( input: toInt($0["input_tokens"]), cached: toInt($0["cached_input_tokens"] ?? $0["cache_read_input_tokens"]), - output: toInt($0["output_tokens"])) + output: toInt($0["output_tokens"]), + reasoning: toInt($0["reasoning_output_tokens"])) } let last = (info["last_token_usage"] as? [String: Any]).map { CostUsageCodexTotals( input: max(0, toInt($0["input_tokens"])), cached: max(0, toInt($0["cached_input_tokens"] ?? $0["cache_read_input_tokens"])), - output: max(0, toInt($0["output_tokens"]))) + output: max(0, toInt($0["output_tokens"])), + reasoning: max(0, toInt($0["reasoning_output_tokens"]))) } appendSnapshot(timestamp: timestamp, last: last, total: total) } @@ -2013,6 +2058,33 @@ enum CostUsageScanner { rows: []) } + /// Records Codex session-file paths handed to the parser so refresh-path tests can prove a + /// warm refresh over an unchanged corpus performs no re-parsing (regression gates for #1387 + /// and #1392). Paths (not a bare count) keep the assertion robust when unrelated suites + /// parse their own fixtures concurrently. nil until a test arms it via the reset, so the + /// production path never allocates; while armed it grows unbounded (test processes only). + /// Reads copy a consistent snapshot (Set is a value type); only mutations take the lock. + private(set) nonisolated(unsafe) static var _test_codexParsedFilePaths: Set? + private static let testParsedFilePathsLock = NSLock() + + static func _test_resetCodexParsedFilePaths() { + self.testParsedFilePathsLock.lock() + defer { self.testParsedFilePathsLock.unlock() } + self._test_codexParsedFilePaths = [] + } + + private static func recordTestParsedFilePath(_ path: String) { + self.testParsedFilePathsLock.lock() + defer { self.testParsedFilePathsLock.unlock() } + // Directory enumeration resolves /var -> /private/var on macOS, while tests build fixture + // URLs directly (no /private). Normalize so both sides compare equal. + var normalized = URL(fileURLWithPath: path).standardizedFileURL.path + if normalized.hasPrefix("/private/var/") { + normalized = String(normalized.dropFirst("/private".count)) + } + self._test_codexParsedFilePaths?.insert(normalized) + } + // swiftlint:disable:next cyclomatic_complexity function_body_length static func parseCodexFileCancellable( fileURL: URL, @@ -2030,6 +2102,7 @@ enum CostUsageScanner { inheritedTotalsResolver: ((String, String) throws -> CodexForkBaseline)? = nil, checkCancellation: CancellationCheck? = nil) throws -> CodexParseResult { + self.recordTestParsedFilePath(fileURL.path) var currentModel = initialModel var previousTotals = initialTotals var sessionId: String? @@ -2061,16 +2134,22 @@ enum CostUsageScanner { var days: [String: [String: [Int]]] = [:] var rows: [CodexUsageRow] = [] - func add(dayKey: String, model: String, input: Int, cached: Int, output: Int) { + func add(dayKey: String, model: String, usage: CostUsageCodexTotals) { guard CostUsageDayRange.isInRange(dayKey: dayKey, since: range.scanSinceKey, until: range.scanUntilKey) else { return } let normModel = CostUsagePricing.normalizeCodexModel(model) var dayModels = days[dayKey] ?? [:] + // Sparse layout: the reasoning slot only exists when a session actually reported + // reasoning tokens, keeping packed arrays comparable across schema versions. var packed = dayModels[normModel] ?? [0, 0, 0] - packed[0] = (packed[safe: 0] ?? 0) + input - packed[1] = (packed[safe: 1] ?? 0) + cached - packed[2] = (packed[safe: 2] ?? 0) + output + packed[0] = (packed[safe: 0] ?? 0) + usage.input + packed[1] = (packed[safe: 1] ?? 0) + usage.cached + packed[2] = (packed[safe: 2] ?? 0) + usage.output + if usage.reasoning != 0 || packed.count > 3 { + if packed.count < 4 { packed.append(0) } + packed[3] = (packed[safe: 3] ?? 0) + usage.reasoning + } dayModels[normModel] = packed days[dayKey] = dayModels } @@ -2144,9 +2223,7 @@ enum CostUsageScanner { let total = record.total let last = record.last - var deltaInput = 0 - var deltaCached = 0 - var deltaOutput = 0 + var deltaUsage = CostUsageCodexTotals(input: 0, cached: 0, output: 0, reasoning: 0) func adjustedLastDelta(_ rawDelta: CostUsageCodexTotals) -> CostUsageCodexTotals { guard var remaining = remainingInheritedTotals else { return rawDelta } @@ -2154,13 +2231,15 @@ enum CostUsageScanner { let adjusted = CostUsageCodexTotals( input: max(0, rawDelta.input - remaining.input), cached: max(0, rawDelta.cached - remaining.cached), - output: max(0, rawDelta.output - remaining.output)) + output: max(0, rawDelta.output - remaining.output), + reasoning: max(0, rawDelta.reasoning - remaining.reasoning)) remaining.input = max(0, remaining.input - rawDelta.input) remaining.cached = max(0, remaining.cached - rawDelta.cached) remaining.output = max(0, remaining.output - rawDelta.output) + remaining.reasoning = max(0, remaining.reasoning - rawDelta.reasoning) remainingInheritedTotals = if remaining.input == 0, remaining.cached == 0, - remaining.output == 0 + remaining.output == 0, remaining.reasoning == 0 { nil } else { @@ -2177,7 +2256,8 @@ enum CostUsageScanner { return CostUsageCodexTotals( input: max(0, rawTotals.input - inheritedTotals.input), cached: max(0, rawTotals.cached - inheritedTotals.cached), - output: max(0, rawTotals.output - inheritedTotals.output)) + output: max(0, rawTotals.output - inheritedTotals.output), + reasoning: max(0, rawTotals.reasoning - inheritedTotals.reasoning)) } if let adjustedTotal { @@ -2213,10 +2293,8 @@ enum CostUsageScanner { } func commitDelta(_ delta: CostUsageCodexTotals, rawBaseline: CostUsageCodexTotals) { - deltaInput = delta.input - deltaCached = delta.cached - deltaOutput = delta.output - let prev = previousTotals ?? .init(input: 0, cached: 0, output: 0) + deltaUsage = delta + let prev = previousTotals ?? .init(input: 0, cached: 0, output: 0, reasoning: 0) previousTotals = Self.codexAddTotals(prev, delta) rawTotalsBaseline = rawBaseline if !Self.codexTotalsEqual(rawTotalsBaseline, previousTotals) { @@ -2241,10 +2319,8 @@ enum CostUsageScanner { let adjustedDelta = Self.codexMinTotals( last, Self.codexTotalDelta(from: watermarkBaseline, to: currentRawTotals)) - deltaInput = adjustedDelta.input - deltaCached = adjustedDelta.cached - deltaOutput = adjustedDelta.output - let prev = previousTotals ?? .init(input: 0, cached: 0, output: 0) + deltaUsage = adjustedDelta + let prev = previousTotals ?? .init(input: 0, cached: 0, output: 0, reasoning: 0) previousTotals = Self.codexAddTotals(prev, adjustedDelta) rawTotalsBaseline = previousTotals } @@ -2271,7 +2347,7 @@ enum CostUsageScanner { let rawDelta = last let hadRemainingInheritedTotals = remainingInheritedTotals != nil var adjustedDelta = adjustedLastDelta(rawDelta) - let prev = previousTotals ?? .init(input: 0, cached: 0, output: 0) + let prev = previousTotals ?? .init(input: 0, cached: 0, output: 0, reasoning: 0) if let currentTotals = adjustedTotal, !hasUnresolvedForkBaseline { if tracker.sawInterleavedTotals { @@ -2298,9 +2374,7 @@ enum CostUsageScanner { commitDelta(adjustedDelta, rawBaseline: currentTotals) } else { let countedTotals = Self.codexAddTotals(prev, adjustedDelta) - deltaInput = adjustedDelta.input - deltaCached = adjustedDelta.cached - deltaOutput = adjustedDelta.output + deltaUsage = adjustedDelta previousTotals = countedTotals rawTotalsBaseline = countedTotals tracker.raiseWatermark(to: countedTotals) @@ -2312,7 +2386,7 @@ enum CostUsageScanner { return } - if deltaInput == 0, deltaCached == 0, deltaOutput == 0 { + if deltaUsage.input == 0, deltaUsage.cached == 0, deltaUsage.output == 0, deltaUsage.reasoning == 0 { return } let eventIndex = codexUsageRowIndex @@ -2321,9 +2395,7 @@ enum CostUsageScanner { add( dayKey: dayKey, model: normModel, - input: deltaInput, - cached: deltaCached, - output: deltaOutput) + usage: deltaUsage) if CostUsageDayRange.isInRange( dayKey: dayKey, since: range.scanSinceKey, @@ -2334,9 +2406,10 @@ enum CostUsageScanner { model: normModel, turnID: record.turnID ?? currentTurnID, eventIndex: eventIndex, - input: deltaInput, - cached: deltaCached, - output: deltaOutput)) + input: deltaUsage.input, + cached: deltaUsage.cached, + output: deltaUsage.output, + reasoning: deltaUsage.reasoning)) } } @@ -2548,7 +2621,8 @@ enum CostUsageScanner { CostUsageCodexTotals( input: max(0, toInt(usage["input_tokens"])), cached: max(0, toInt(usage["cached_input_tokens"] ?? usage["cache_read_input_tokens"])), - output: max(0, toInt(usage["output_tokens"]))) + output: max(0, toInt(usage["output_tokens"])), + reasoning: max(0, toInt(usage["reasoning_output_tokens"]))) } let record = CodexTokenCountRecord( @@ -2727,15 +2801,34 @@ enum CostUsageScanner { } let cached = cache.files[metadata.path] + // Validated once per file per scan; the result (and any freshly computed fingerprint) + // is threaded through keep/append/rescan so a changed file is hashed only once. + let freshness = CostUsageSourceFingerprint.check( + fileURL: fileURL, + size: metadata.size, + mtimeUnixNs: metadata.mtimeUnixNs, + cached: cached?.fingerprint) let input = CodexFileScanInput(fileURL: fileURL, metadata: metadata, cached: cached) - if try Self.keepCachedCodexFileIfFresh(input: input, context: context, cache: &cache, state: &state) { + if try Self.keepCachedCodexFileIfFresh( + input: input, + freshness: freshness, + context: context, + cache: &cache, + state: &state) + { return } - if try Self.appendCodexFileIncrementIfPossible(input: input, context: context, cache: &cache, state: &state) { + if try Self.appendCodexFileIncrementIfPossible( + input: input, + freshness: freshness, + context: context, + cache: &cache, + state: &state) + { return } - try Self.rescanCodexFile(input: input, context: context, cache: &cache, state: &state) + try Self.rescanCodexFile(input: input, freshness: freshness, context: context, cache: &cache, state: &state) } private static func makeCodexRefreshPlan( diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageSourceFingerprint.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageSourceFingerprint.swift new file mode 100644 index 0000000000..340e32205c --- /dev/null +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageSourceFingerprint.swift @@ -0,0 +1,179 @@ +import Foundation +#if canImport(CryptoKit) +import CryptoKit +#else +import Crypto +#endif + +/// One bounded content sample: FNV-1a (64-bit) over up to `sampleBytes` read at a fixed offset. +struct CostUsageFileSampleHash: Codable, Equatable { + var offset: Int64 + var length: Int64 + var fnv1a: UInt64 +} + +/// tokscale-style sampling fingerprint for per-file cache freshness (mirrors `SourceFingerprint` +/// in tokscale-core's message_cache.rs): size + nanosecond mtime + FNV-1a over 5 fixed sample +/// points x 4 KiB, plus a whole-file SHA-256 used to tell a pure `touch` apart from a real edit. +struct CostUsageSourceFingerprint: Codable, Equatable { + static let sampleBytes: Int64 = 4096 + static let samplePoints = 5 + private static let hashBufferBytes = 64 * 1024 + + var size: Int64 + var mtimeUnixNs: Int64 + var samples: [CostUsageFileSampleHash] + /// Whole-file SHA-256 (hex). Compared only when size/mtime moved, so the hot path + /// never pays for a full read. + var contentSHA256: String? + + enum Freshness: Equatable { + /// Size, mtime and all bounded samples match; validation read at most + /// `samplePoints x sampleBytes` and no full-file hash was computed. + case unchanged + /// Metadata moved but the whole-file hash still matches: a pure touch. + /// Carries the refreshed fingerprint the caller should store. + case touched(CostUsageSourceFingerprint) + /// Real content change, an unreadable file, or no usable cached fingerprint. + /// Carries the fresh fingerprint describing the current file. + case changed(CostUsageSourceFingerprint) + + /// The freshly computed fingerprint carried by touched/changed results, reusable by + /// rescan paths so a changed file is hashed only once per scan. + var freshFingerprint: CostUsageSourceFingerprint? { + switch self { + case .unchanged: + nil + case let .touched(fresh), let .changed(fresh): + fresh + } + } + } + + // MARK: - Validation + + /// Cheap metadata first: when size + mtime match, only the bounded samples are recomputed + /// (<=20 KiB). When metadata moved, rebuild the complete fingerprint so a touch (same + /// content hash) can be distinguished from a real modification. + static func check( + fileURL: URL, + size: Int64, + mtimeUnixNs: Int64, + cached: CostUsageSourceFingerprint?) -> Freshness + { + if let cached, + cached.size == size, + cached.mtimeUnixNs == mtimeUnixNs, + let samples = self.sampleHashes(fileURL: fileURL, size: size), + samples == cached.samples + { + return .unchanged + } + + guard let fresh = self.make(fileURL: fileURL, size: size, mtimeUnixNs: mtimeUnixNs) else { + return .changed(CostUsageSourceFingerprint( + size: size, + mtimeUnixNs: mtimeUnixNs, + samples: [], + contentSHA256: nil)) + } + if let cached, + let cachedHash = cached.contentSHA256, + cachedHash == fresh.contentSHA256 + { + return .touched(fresh) + } + return .changed(fresh) + } + + /// Builds a complete fingerprint (samples + whole-file hash) for the current file content. + /// Returns nil when the file cannot be read consistently (e.g. truncated mid-read). + static func make( + fileURL: URL, + size: Int64, + mtimeUnixNs: Int64) -> CostUsageSourceFingerprint? + { + guard let samples = self.sampleHashes(fileURL: fileURL, size: size), + let contentSHA256 = self.contentHash(fileURL: fileURL, expectedSize: size) + else { return nil } + return CostUsageSourceFingerprint( + size: size, + mtimeUnixNs: mtimeUnixNs, + samples: samples, + contentSHA256: contentSHA256) + } + + // MARK: - Sampling + + /// Fixed sample offsets matching tokscale: start / quarter / half / three-quarter / final + /// window, sorted and deduplicated, capped at `samplePoints`. + static func sampleOffsets(size: Int64) -> [(offset: Int64, length: Int64)] { + let sampleLength = min(size, self.sampleBytes) + guard sampleLength > 0 else { return [] } + let maxOffset = size - sampleLength + let raw: [Int64] = maxOffset == 0 + ? [0] + : [0, maxOffset / 4, maxOffset / 2, maxOffset / 4 * 3, maxOffset] + var seen: Set = [] + var offsets: [Int64] = [] + for offset in raw.sorted() where !seen.contains(offset) { + seen.insert(offset) + offsets.append(offset) + } + return offsets.prefix(self.samplePoints).map { (offset: $0, length: sampleLength) } + } + + static func sampleHashes(fileURL: URL, size: Int64) -> [CostUsageFileSampleHash]? { + let offsets = self.sampleOffsets(size: size) + guard !offsets.isEmpty else { return [] } + guard let handle = try? FileHandle(forReadingFrom: fileURL) else { return nil } + defer { try? handle.close() } + var samples: [CostUsageFileSampleHash] = [] + for (offset, length) in offsets { + do { + try handle.seek(toOffset: UInt64(offset)) + guard let data = try handle.read(upToCount: Int(length)), data.count == length + else { return nil } + samples.append(CostUsageFileSampleHash( + offset: offset, + length: length, + fnv1a: self.fnv1a(data))) + } catch { + return nil + } + } + return samples + } + + static func fnv1a(_ data: Data) -> UInt64 { + var hash: UInt64 = 0xCBF2_9CE4_8422_2325 + for byte in data { + hash ^= UInt64(byte) + hash = hash &* 0x0000_0100_0000_01B3 + } + return hash + } + + // MARK: - Whole-file hash + + /// Streams exactly `expectedSize` bytes through SHA-256. Reading a fixed size keeps the + /// hash tied to the stat snapshot the caller already validated against. + private static func contentHash(fileURL: URL, expectedSize: Int64) -> String? { + guard expectedSize >= 0 else { return nil } + guard let handle = try? FileHandle(forReadingFrom: fileURL) else { return nil } + defer { try? handle.close() } + var hasher = SHA256() + var remaining = expectedSize + while remaining > 0 { + let chunk = Int(min(remaining, Int64(self.hashBufferBytes))) + do { + guard let data = try handle.read(upToCount: chunk), !data.isEmpty else { return nil } + hasher.update(data: data) + remaining -= Int64(data.count) + } catch { + return nil + } + } + return hasher.finalize().map { String(format: "%02x", $0) }.joined() + } +} diff --git a/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift b/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift index 23a34fc44d..37af3ce96f 100644 --- a/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift +++ b/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift @@ -173,4 +173,59 @@ struct CostUsageDailyReportMergeTests { #expect(merged.summary?.totalTokens == 120) #expect(abs((merged.data.first?.costUSD ?? 0) - 1.25) < 0.000001) } + + @Test + func `merged report sums reasoning tokens and drops the bucket when any source misses it`() { + func report(day: String, reasoning: Int?) -> CostUsageDailyReport { + CostUsageDailyReport( + data: [ + CostUsageDailyReport.Entry( + date: day, + inputTokens: 100, + outputTokens: 30, + totalTokens: 130, + costUSD: 1.0, + modelsUsed: ["gpt-5.4"], + modelBreakdowns: [ + CostUsageDailyReport.ModelBreakdown( + modelName: "gpt-5.4", + costUSD: 1.0, + totalTokens: 130, + inputTokens: 100, + outputTokens: 30, + reasoningTokens: reasoning), + ]), + ], + summary: nil) + } + + let both = report(day: "2026-04-04", reasoning: 12) + .merged(with: report(day: "2026-04-04", reasoning: 8)) + #expect(both.data.first?.modelBreakdowns?.first?.outputTokens == 60) + #expect(both.data.first?.modelBreakdowns?.first?.reasoningTokens == 20) + + // Reasoning is a sub-bucket of output: merging must never change the output total. + let missing = report(day: "2026-04-04", reasoning: 12) + .merged(with: report(day: "2026-04-04", reasoning: nil)) + #expect(missing.data.first?.modelBreakdowns?.first?.outputTokens == 60) + #expect(missing.data.first?.modelBreakdowns?.first?.reasoningTokens == nil) + } + + @Test + func `model breakdown decodes reasoning tokens from camel and snake case keys`() throws { + let camel = try JSONDecoder().decode( + CostUsageDailyReport.ModelBreakdown.self, + from: Data(#"{"modelName":"gpt-5.4","reasoningTokens":7}"#.utf8)) + #expect(camel.reasoningTokens == 7) + + let snake = try JSONDecoder().decode( + CostUsageDailyReport.ModelBreakdown.self, + from: Data(#"{"modelName":"gpt-5.4","reasoning_output_tokens":9}"#.utf8)) + #expect(snake.reasoningTokens == 9) + + let absent = try JSONDecoder().decode( + CostUsageDailyReport.ModelBreakdown.self, + from: Data(#"{"modelName":"gpt-5.4"}"#.utf8)) + #expect(absent.reasoningTokens == nil) + } } diff --git a/Tests/CodexBarTests/CostUsagePerformanceGateTests.swift b/Tests/CodexBarTests/CostUsagePerformanceGateTests.swift index b5295f1cd6..dd6a3cecdc 100644 --- a/Tests/CodexBarTests/CostUsagePerformanceGateTests.swift +++ b/Tests/CodexBarTests/CostUsagePerformanceGateTests.swift @@ -29,20 +29,57 @@ struct CostUsagePerformanceGateTests { until: day, now: day, options: options) + CostUsageScanner._test_resetCodexParsedFilePaths() + let warm = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + + // Other suites may parse their own fixtures concurrently, so assert on this corpus. + let corpusPaths = fileURLs.map(\.standardizedFileURL.path) + #expect((CostUsageScanner._test_codexParsedFilePaths ?? []).isDisjoint(with: corpusPaths)) + #expect(cold.data.count == 1) + #expect(warm.data.first?.totalTokens == cold.data.first?.totalTokens) + } + + @Test + func `in-place same-size session rewrite invalidates the sampling fingerprint cache`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + let fileURLs = try Self.writeSyntheticCodexCorpus(env: env, day: day, files: 2, turnsPerFile: 4) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing.sqlite")) + options.refreshMinIntervalSeconds = 0 + + let cold = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + + // Rewrite one file in place: identical byte length, original mtime restored. Only the + // sampling fingerprint can see this edit, and it must invalidate exactly that file. let changedFile = try #require(fileURLs.first) - let originalAttributes = try FileManager.default.attributesOfItem(atPath: changedFile.path) - let originalModificationDate = try #require(originalAttributes[.modificationDate] as? Date) + let originalModificationDateNs = Self.statModificationUnixNs(of: changedFile) let original = try String(contentsOf: changedFile, encoding: .utf8) let modified = original.replacingOccurrences( - of: #""input_tokens":100,"#, + of: #""input_tokens":400,"#, with: #""input_tokens":900,"#) #expect(modified != original) #expect(modified.utf8.count == original.utf8.count) try modified.write(to: changedFile, atomically: false, encoding: .utf8) - try FileManager.default.setAttributes( - [.modificationDate: originalModificationDate], - ofItemAtPath: changedFile.path) + try Self.setModificationUnixNs(originalModificationDateNs, of: changedFile) + #expect(Self.statModificationUnixNs(of: changedFile) == originalModificationDateNs) + CostUsageScanner._test_resetCodexParsedFilePaths() let warm = CostUsageScanner.loadDailyReport( provider: .codex, @@ -51,8 +88,14 @@ struct CostUsagePerformanceGateTests { now: day, options: options) - #expect(cold.data.count == 1) - #expect(warm.data.first?.totalTokens == cold.data.first?.totalTokens) + // Only the rewritten file is re-parsed; the edited final turn (input 400 -> 900) lifts + // that file's cumulative total from 440 to 940 tokens. Other suites may parse their own + // fixtures concurrently, so assert on this corpus only. + let parsedCorpusPaths = (CostUsageScanner._test_codexParsedFilePaths ?? []) + .intersection(fileURLs.map(\.standardizedFileURL.path)) + #expect(parsedCorpusPaths == [changedFile.standardizedFileURL.path]) + #expect(cold.data.first?.totalTokens == 880) + #expect(warm.data.first?.totalTokens == 1380) } @Test @@ -318,6 +361,30 @@ struct CostUsagePerformanceGateTests { return fileURLs } + /// stat-level mtime in nanoseconds. `FileManager.attributesOfItem` round-trips through + /// `Date` (Double seconds, µs granularity beyond ~±128 years), silently truncating the + /// nanosecond tail — which would make an in-place rewrite fixture look mtime-changed even + /// after a "restore". These helpers keep the whole round-trip in integer nanoseconds. + private static func statModificationUnixNs(of fileURL: URL) -> Int64 { + var info = stat() + guard fileURL.path.withCString({ fstatat(AT_FDCWD, $0, &info, 0) }) == 0 else { return 0 } + #if os(Linux) + return Int64(info.st_mtim.tv_sec) * 1_000_000_000 + Int64(info.st_mtim.tv_nsec) + #else + return Int64(info.st_mtimespec.tv_sec) * 1_000_000_000 + Int64(info.st_mtimespec.tv_nsec) + #endif + } + + private static func setModificationUnixNs(_ ns: Int64, of fileURL: URL) throws { + var times = [ + timespec(tv_sec: 0, tv_nsec: Int(UTIME_OMIT)), + timespec(tv_sec: Int(ns / 1_000_000_000), tv_nsec: Int(ns % 1_000_000_000)), + ] + guard fileURL.path.withCString({ utimensat(AT_FDCWD, $0, ×, 0) }) == 0 else { + throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno)) + } + } + private static func replaceTraceBody(dbURL: URL, rowID: Int64, body: String) throws { var db: OpaquePointer? guard sqlite3_open(dbURL.path, &db) == SQLITE_OK else { diff --git a/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift b/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift index c33a31764d..510b92a34b 100644 --- a/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift @@ -238,6 +238,101 @@ struct CostUsageScannerBreakdownTests { #expect((second.data[0].costUSD ?? 0) > (first.data[0].costUSD ?? 0)) } + @Test + func `codex daily report surfaces reasoning output tokens without changing billing output`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 11) + let iso0 = env.isoString(for: day) + let iso1 = env.isoString(for: day.addingTimeInterval(1)) + let iso2 = env.isoString(for: day.addingTimeInterval(2)) + let model = "openai/gpt-5.2-codex" + + func tokenCount( + timestamp: String, + total: (input: Int, cached: Int, output: Int, reasoning: Int), + last: (input: Int, cached: Int, output: Int, reasoning: Int)) -> [String: Any] + { + func usage(_ value: (input: Int, cached: Int, output: Int, reasoning: Int)) -> [String: Any] { + [ + "input_tokens": value.input, + "cached_input_tokens": value.cached, + "output_tokens": value.output, + "reasoning_output_tokens": value.reasoning, + ] + } + return [ + "type": "event_msg", + "timestamp": timestamp, + "payload": [ + "type": "token_count", + "info": [ + "model": model, + "total_token_usage": usage(total), + "last_token_usage": usage(last), + ], + ], + ] + } + + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "session.jsonl", + contents: env.jsonl([ + self.codexTurnContext(timestamp: iso0, model: model), + tokenCount( + timestamp: iso1, + total: (input: 100, cached: 20, output: 10, reasoning: 6), + last: (input: 100, cached: 20, output: 10, reasoning: 6)), + ])) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite")) + options.refreshMinIntervalSeconds = 0 + + let first = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let firstBreakdown = try #require(first.data.first?.modelBreakdowns?.first) + // Reasoning is a sub-bucket of billing output: output and totals stay as before. + #expect(firstBreakdown.outputTokens == 10) + #expect(firstBreakdown.reasoningTokens == 6) + #expect(firstBreakdown.totalTokens == 110) + #expect(first.data.first?.totalTokens == 110) + + // The incremental rescan rides the cached row path; reasoning must survive the roundtrip. + try env.jsonl([ + self.codexTurnContext(timestamp: iso0, model: model), + tokenCount( + timestamp: iso1, + total: (input: 100, cached: 20, output: 10, reasoning: 6), + last: (input: 100, cached: 20, output: 10, reasoning: 6)), + tokenCount( + timestamp: iso2, + total: (input: 160, cached: 40, output: 16, reasoning: 9), + last: (input: 60, cached: 20, output: 6, reasoning: 3)), + ]) + .write(to: fileURL, atomically: true, encoding: .utf8) + + let second = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let secondBreakdown = try #require(second.data.first?.modelBreakdowns?.first) + #expect(secondBreakdown.outputTokens == 16) + #expect(secondBreakdown.reasoningTokens == 9) + #expect(secondBreakdown.totalTokens == 176) + } + @Test func `codex project breakdowns group by cwd and preserve daily totals`() throws { let env = try CostUsageTestEnvironment() diff --git a/Tests/CodexBarTests/GeminiSessionScannerTests.swift b/Tests/CodexBarTests/GeminiSessionScannerTests.swift new file mode 100644 index 0000000000..6a760e56be --- /dev/null +++ b/Tests/CodexBarTests/GeminiSessionScannerTests.swift @@ -0,0 +1,330 @@ +import CodexBarCore +import Foundation +import Testing + +struct GeminiSessionScannerTests { + @Test + func `scanner aggregates chat recordings across projects layouts and models`() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let chatsA = try Self.makeChatsDir(root, "hash-a") + let chatsB = try Self.makeChatsDir(root, "hash-b") + + try Self.write(Self.chatRecording(messages: [ + #"{"id":"m1","timestamp":"2026-07-10T09:00:00Z","type":"user"}"#, + Self.modelTurn( + id: "m2", + timestamp: "2026-07-10T09:01:00Z", + tokens: #"{"input":100,"output":20,"cached":10,"thoughts":5,"tool":3,"total":138}"#), + Self.modelTurn( + id: "m3", + timestamp: "2026-07-10T09:02:00Z", + tokens: #"{"input":15,"output":20,"cached":5,"thoughts":2,"tool":0,"total":37}"#), + ]), to: chatsA.appendingPathComponent("session-one.json")) + try Self.write(Self.chatRecording(messages: [ + Self.modelTurn( + id: "m1", + timestamp: "2026-07-11T10:00:00Z", + model: "gemini-2.0-flash", + tokens: #"{"input":8,"output":9,"total":17}"#), + ]), to: chatsB.appendingPathComponent("session-two.json")) + // Legacy layout: `session-` prefix accepted anywhere under `tmp`. + try Self.write(Self.chatRecording(messages: [ + Self.modelTurn( + id: "m1", + timestamp: "2026-07-11T11:00:00Z", + model: "gemini-2.0-flash", + tokens: #"{"input":1,"output":2,"total":3}"#), + ]), to: root.appendingPathComponent("tmp/session-legacy.json")) + // Decoys: wrong extension, and a `.json` outside the expected layouts. + try Self.write("not json", to: chatsB.appendingPathComponent("notes.txt")) + try Self.write( + #"{"messages":[{"id":"x","model":"gemini-2.0-flash","tokens":{"input":99,"output":99}}]}"#, + to: root.appendingPathComponent("tmp/hash-b/other.json")) + + let snapshot = try #require(Self.scan(root: root, now: Self.date("2026-07-12T12:00:00Z"))) + + #expect(snapshot.currencyCode == "XXX") + #expect(snapshot.historyLabel == "Gemini CLI") + #expect(snapshot.historyDays == 30) + #expect(snapshot.last30DaysTokens == 195) + #expect(snapshot.last30DaysRequests == 4) + #expect(snapshot.last30DaysCostUSD == nil) + #expect(snapshot.sessionTokens == nil) + #expect(snapshot.daily.map(\.date) == ["2026-07-10", "2026-07-11"]) + + let first = snapshot.daily[0] + #expect(first.inputTokens == 113) + #expect(first.outputTokens == 47) + #expect(first.cacheReadTokens == 15) + #expect(first.cacheCreationTokens == nil) + #expect(first.totalTokens == 175) + #expect(first.requestCount == 2) + #expect(first.costUSD == nil) + #expect(first.modelsUsed == ["gemini-2.5-pro"]) + let firstBreakdown = try #require(first.modelBreakdowns?.first) + #expect(firstBreakdown.modelName == "gemini-2.5-pro") + #expect(firstBreakdown.inputTokens == 113) + #expect(firstBreakdown.outputTokens == 47) + #expect(firstBreakdown.cacheReadTokens == 15) + #expect(firstBreakdown.cacheCreationTokens == nil) + // `thoughts` stays folded into billing output and is also surfaced as the reasoning bucket. + #expect(firstBreakdown.reasoningTokens == 7) + #expect(firstBreakdown.totalTokens == 175) + #expect(firstBreakdown.requestCount == 2) + #expect(firstBreakdown.costUSD == nil) + + let second = snapshot.daily[1] + #expect(second.inputTokens == 9) + #expect(second.outputTokens == 11) + #expect(second.cacheReadTokens == 0) + #expect(second.totalTokens == 20) + #expect(second.requestCount == 2) + #expect(second.modelsUsed == ["gemini-2.0-flash"]) + #expect(second.modelBreakdowns?.first?.reasoningTokens == nil) + } + + @Test + func `scanner buckets usage by local day across midnight`() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let chats = try Self.makeChatsDir(root, "hash-a") + try Self.write(Self.chatRecording(messages: [ + // 23:30 at GMT+8 on 2026-07-10. + Self.modelTurn(id: "m1", timestamp: "2026-07-10T15:30:00Z", tokens: #"{"input":1,"output":2}"#), + // 00:30 at GMT+8 on 2026-07-11. + Self.modelTurn(id: "m2", timestamp: "2026-07-10T16:30:00Z", tokens: #"{"input":3,"output":4}"#), + ]), to: chats.appendingPathComponent("session-one.json")) + + let snapshot = try #require(Self.scan( + root: root, + now: Self.date("2026-07-12T12:00:00Z"), + calendar: Self.gmtPlus8Calendar)) + + #expect(snapshot.daily.map(\.date) == ["2026-07-10", "2026-07-11"]) + #expect(snapshot.daily.map(\.requestCount) == [1, 1]) + #expect(snapshot.daily.map(\.totalTokens) == [3, 7]) + } + + @Test + func `scanner prefilters by file mtime and falls back to mtime for missing timestamps`() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let chats = try Self.makeChatsDir(root, "hash-a") + + // mtime older than the window: skipped without parsing, even for in-window timestamps. + let oldFile = chats.appendingPathComponent("session-old.json") + try Self.write(Self.chatRecording(messages: [ + Self.modelTurn( + id: "m1", + timestamp: "2026-07-12T09:00:00Z", + tokens: #"{"input":100,"output":100}"#), + ]), to: oldFile) + try FileManager.default.setAttributes( + [.modificationDate: Self.date("2026-06-02T12:00:00Z")], + ofItemAtPath: oldFile.path) + // Fresh mtime but a record older than the window: dropped by the day filter. + try Self.write(Self.chatRecording(messages: [ + Self.modelTurn( + id: "m1", + timestamp: "2026-06-02T09:00:00Z", + tokens: #"{"input":200,"output":200}"#), + ]), to: chats.appendingPathComponent("session-stale.json")) + // No timestamp at all: bucketed by the file mtime, pinned inside the window here. + let fallbackFile = chats.appendingPathComponent("session-fallback.json") + try Self.write(Self.chatRecording(messages: [ + #"{"id":"m1","type":"gemini","model":"gemini-2.5-pro","tokens":{"input":5,"output":6}}"#, + ]), to: fallbackFile) + try FileManager.default.setAttributes( + [.modificationDate: Self.date("2026-07-12T09:00:00Z")], + ofItemAtPath: fallbackFile.path) + + let snapshot = try #require(Self.scan(root: root, now: Self.date("2026-07-12T12:00:00Z"))) + + #expect(snapshot.daily.map(\.date) == ["2026-07-12"]) + #expect(snapshot.last30DaysRequests == 1) + #expect(snapshot.last30DaysTokens == 11) + #expect(snapshot.daily.first?.inputTokens == 5) + #expect(snapshot.daily.first?.outputTokens == 6) + } + + @Test + func `scanner skips corrupt files records and stream lines`() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let chats = try Self.makeChatsDir(root, "hash-a") + + try Self.write("this is not json", to: chats.appendingPathComponent("session-garbage.json")) + try Self.write(#"{"foo":1}"#, to: chats.appendingPathComponent("session-shape.json")) + try Self.write(Self.chatRecording(messages: [ + #"{"id":"m1","timestamp":"2026-07-10T09:00:00Z","type":"gemini","tokens":{"input":1,"output":1}}"#, + #"{"id":"m2","timestamp":"2026-07-10T09:01:00Z","type":"gemini","model":"gemini-2.5-pro"}"#, + ]), to: chats.appendingPathComponent("session-incomplete.json")) + try Self.write("", to: chats.appendingPathComponent("session-empty.jsonl")) + try Self.write([ + "not json at all", + #"{"type":"init","model":"gemini-2.5-pro","session_id":"s1"}"#, + #"{"type":"gemini","id":"x1","timestamp":"2026-07-10T09:00:00.000Z","tokens":{"input":10,"output":5}}"#, + ].joined(separator: "\n"), to: chats.appendingPathComponent("session-mixed.jsonl")) + + let snapshot = try #require(Self.scan(root: root, now: Self.date("2026-07-12T12:00:00Z"))) + + #expect(snapshot.daily.map(\.date) == ["2026-07-10"]) + #expect(snapshot.last30DaysRequests == 1) + #expect(snapshot.last30DaysTokens == 15) + #expect(snapshot.daily.first?.modelsUsed == ["gemini-2.5-pro"]) + } + + @Test + func `scanner skips negative token values and overflowing accumulations`() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let chats = try Self.makeChatsDir(root, "hash-a") + try Self.write(Self.chatRecording(messages: [ + Self.modelTurn( + id: "m1", + timestamp: "2026-07-10T09:00:00Z", + tokens: #"{"input":-1,"output":2,"total":1}"#), + Self.modelTurn( + id: "m2", + timestamp: "2026-07-10T09:01:00Z", + tokens: #"{"input":1,"output":2,"total":-3}"#), + Self.modelTurn( + id: "m3", + timestamp: "2026-07-10T09:02:00Z", + tokens: #"{"input":9223372036854775807,"output":0}"#), + Self.modelTurn( + id: "m4", + timestamp: "2026-07-10T09:03:00Z", + tokens: #"{"input":10,"output":0}"#), + ]), to: chats.appendingPathComponent("session-one.json")) + + let snapshot = try #require(Self.scan(root: root, now: Self.date("2026-07-12T12:00:00Z"))) + + // Only the Int.max record survives; the follow-up record overflows the accumulator and + // is dropped without poisoning the bucket. + #expect(snapshot.last30DaysRequests == 1) + #expect(snapshot.last30DaysTokens == Int.max) + #expect(snapshot.daily.first?.inputTokens == Int.max) + } + + @Test + func `scanner parses chat streams with model hints and replaces duplicate message ids`() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let chats = try Self.makeChatsDir(root, "hash-a") + try Self.write([ + // swiftlint:disable:next line_length + #"{"sessionId":"s1","projectHash":"hash-a","startTime":"2026-07-10T00:00:00.000Z","lastUpdated":"2026-07-10T00:01:00.000Z"}"#, + #"{"type":"init","model":"gemini-2.5-pro","session_id":"s1"}"#, + // swiftlint:disable:next line_length + #"{"id":"m1","timestamp":"2026-07-10T09:00:00.000Z","type":"gemini","tokens":{"input":10,"output":1,"total":11}}"#, + // swiftlint:disable:next line_length + #"{"id":"m1","timestamp":"2026-07-10T09:01:00.000Z","type":"gemini","tokens":{"input":20,"output":2,"cached":5,"thoughts":3,"total":25}}"#, + #"{"timestamp":"2026-07-10T09:02:00.000Z","type":"gemini","tokens":{"input":7,"output":8}}"#, + #"{"timestamp":"2026-07-10T09:03:00.000Z","type":"user"}"#, + ].joined(separator: "\n"), to: chats.appendingPathComponent("session-stream.jsonl")) + + let snapshot = try #require(Self.scan(root: root, now: Self.date("2026-07-12T12:00:00Z"))) + + // The second "m1" line replaces the first (cache-inclusive input 20 normalizes to 15, + // thoughts fold into output), and the id-less line inherits the `init` model. + #expect(snapshot.daily.map(\.date) == ["2026-07-10"]) + #expect(snapshot.last30DaysRequests == 2) + #expect(snapshot.last30DaysTokens == 40) + let entry = snapshot.daily[0] + #expect(entry.inputTokens == 22) + #expect(entry.outputTokens == 13) + #expect(entry.cacheReadTokens == 5) + #expect(entry.modelsUsed == ["gemini-2.5-pro"]) + #expect(entry.modelBreakdowns?.first?.reasoningTokens == 3) + } + + @Test + func `scanner honors GEMINI_CLI_HOME override and returns nil for empty directories`() throws { + let emptyRoot = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: emptyRoot) } + + #expect(Self.scan(root: emptyRoot, now: Self.date("2026-07-12T12:00:00Z")) == nil) + + let tmp = emptyRoot.appendingPathComponent("tmp", isDirectory: true) + try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + #expect(Self.scan(root: emptyRoot, now: Self.date("2026-07-12T12:00:00Z")) == nil) + + let fixtureRoot = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: fixtureRoot) } + let chats = try Self.makeChatsDir(fixtureRoot, "hash-a") + try Self.write(Self.chatRecording(messages: [ + Self.modelTurn(id: "m1", timestamp: "2026-07-10T09:00:00Z", tokens: #"{"input":1,"output":2}"#), + ]), to: chats.appendingPathComponent("session-one.json")) + + let snapshot = try #require(Self.scan(root: fixtureRoot, now: Self.date("2026-07-12T12:00:00Z"))) + #expect(snapshot.last30DaysTokens == 3) + #expect(snapshot.historyLabel == "Gemini CLI") + } + + // MARK: - Fixtures + + private static let utcCalendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + }() + + private static let gmtPlus8Calendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 8 * 3600)! + return calendar + }() + + private static func scan( + root: URL, + now: Date, + calendar: Calendar = Self.utcCalendar) -> CostUsageTokenSnapshot? + { + GeminiSessionScanner.scan( + environment: [GeminiSessionScanner.cliHomeEnvironmentKey: root.path], + historyDays: 30, + now: now, + calendar: calendar) + } + + private static func makeRoot() throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root + } + + private static func makeChatsDir(_ root: URL, _ projectHash: String) throws -> URL { + let dir = root.appendingPathComponent("tmp/\(projectHash)/chats", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + private static func chatRecording(messages: [String]) -> String { + """ + {"sessionId":"ses","projectHash":"hash","startTime":"2026-07-10T09:00:00Z",\ + "lastUpdated":"2026-07-10T09:05:00Z","messages":[\(messages.joined(separator: ","))]} + """ + } + + private static func modelTurn( + id: String, + timestamp: String, + model: String = "gemini-2.5-pro", + tokens: String) -> String + { + #"{"id":"\#(id)","timestamp":"\#(timestamp)","type":"gemini","model":"\#(model)","tokens":\#(tokens)}"# + } + + private static func date(_ iso: String) -> Date { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: iso) ?? Date(timeIntervalSince1970: 0) + } + + private static func write(_ content: String, to url: URL) throws { + try (content + "\n").write(to: url, atomically: true, encoding: .utf8) + } +} diff --git a/Tests/CodexBarTests/KimiCodeSessionScannerTests.swift b/Tests/CodexBarTests/KimiCodeSessionScannerTests.swift index 4fa04b4aea..882b474c2c 100644 --- a/Tests/CodexBarTests/KimiCodeSessionScannerTests.swift +++ b/Tests/CodexBarTests/KimiCodeSessionScannerTests.swift @@ -63,6 +63,8 @@ struct KimiCodeSessionScannerTests { #expect(snapshot.daily.flatMap { $0.modelBreakdowns ?? [] }.map(\.cacheReadTokens) == [25, 9]) #expect(snapshot.daily.flatMap { $0.modelBreakdowns ?? [] }.map(\.cacheCreationTokens) == [6, 0]) #expect(snapshot.daily.flatMap { $0.modelBreakdowns ?? [] }.map(\.outputTokens) == [10, 10]) + // Kimi wire.jsonl carries no reasoning field, so the reasoning bucket stays nil. + #expect(snapshot.daily.flatMap { $0.modelBreakdowns ?? [] }.map(\.reasoningTokens) == [nil, nil]) } @Test diff --git a/Tests/CodexBarTests/OpenCodeSessionScannerTests.swift b/Tests/CodexBarTests/OpenCodeSessionScannerTests.swift new file mode 100644 index 0000000000..01981c6a6a --- /dev/null +++ b/Tests/CodexBarTests/OpenCodeSessionScannerTests.swift @@ -0,0 +1,399 @@ +import CodexBarCore +import Foundation +import Testing + +struct OpenCodeSessionScannerTests { + @Test + func `scanner aggregates assistant messages across sessions and models`() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let sessionA = try Self.makeSessionDir(root, "ses_a") + let sessionB = try Self.makeSessionDir(root, "ses_b") + + try Self.write(Self.assistantMessage( + id: "msg_001", + modelID: "claude-sonnet-4", + created: "2026-07-10T09:00:00Z", + tokens: #"{"input":100,"output":50,"reasoning":10,"cache":{"read":20,"write":5}}"#, + completed: true), to: sessionA.appendingPathComponent("msg_001.json")) + try Self.write( + Self.assistantMessage( + id: "msg_002", + modelID: "gpt-5", + created: "2026-07-11T10:00:00Z", + tokens: #"{"input":3,"output":4,"cache":{"read":1,"write":2}}"#), + to: sessionA.appendingPathComponent("msg_002.json")) + try Self.write( + Self.assistantMessage( + id: "msg_003", + modelID: "claude-sonnet-4", + created: "2026-07-10T11:00:00Z", + tokens: #"{"input":7,"output":8,"reasoning":1,"cache":{"read":0,"write":0}}"#), + to: sessionB.appendingPathComponent("msg_003.json")) + // Non-JSON files in the storage tree are ignored. + try Self.write("not json", to: sessionB.appendingPathComponent("notes.txt")) + + let snapshot = try #require(Self.scan(root: root, now: Self.date("2026-07-12T12:00:00Z"))) + + #expect(snapshot.currencyCode == "XXX") + #expect(snapshot.historyLabel == "OpenCode") + #expect(snapshot.last30DaysTokens == 211) + #expect(snapshot.last30DaysRequests == 3) + #expect(snapshot.last30DaysCostUSD == nil) + #expect(snapshot.daily.map(\.date) == ["2026-07-10", "2026-07-11"]) + + // Reasoning stays folded into billing output and is also surfaced as the reasoning + // bucket; cache.write maps to cacheCreationTokens. + let first = snapshot.daily[0] + #expect(first.inputTokens == 107) + #expect(first.outputTokens == 69) + #expect(first.cacheReadTokens == 20) + #expect(first.cacheCreationTokens == 5) + #expect(first.totalTokens == 201) + #expect(first.requestCount == 2) + #expect(first.costUSD == nil) + #expect(first.modelsUsed == ["claude-sonnet-4"]) + let firstBreakdown = try #require(first.modelBreakdowns?.first) + #expect(firstBreakdown.modelName == "claude-sonnet-4") + #expect(firstBreakdown.inputTokens == 107) + #expect(firstBreakdown.outputTokens == 69) + #expect(firstBreakdown.cacheReadTokens == 20) + #expect(firstBreakdown.cacheCreationTokens == 5) + #expect(firstBreakdown.reasoningTokens == 11) + #expect(firstBreakdown.totalTokens == 201) + #expect(firstBreakdown.requestCount == 2) + #expect(firstBreakdown.costUSD == nil) + + let second = snapshot.daily[1] + #expect(second.inputTokens == 3) + #expect(second.outputTokens == 4) + #expect(second.cacheReadTokens == 1) + #expect(second.cacheCreationTokens == 2) + #expect(second.totalTokens == 10) + #expect(second.requestCount == 1) + #expect(second.modelsUsed == ["gpt-5"]) + // A message without the optional `reasoning` key reports no reasoning bucket. + #expect(second.modelBreakdowns?.first?.reasoningTokens == nil) + } + + @Test + func `scanner buckets usage by local day across midnight`() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let session = try Self.makeSessionDir(root, "ses_a") + // 23:30 at GMT+8 on 2026-07-10. + try Self.write( + Self.assistantMessage( + id: "msg_001", + modelID: "claude-sonnet-4", + created: "2026-07-10T15:30:00Z", + tokens: #"{"input":1,"output":2,"cache":{"read":0,"write":0}}"#), + to: session.appendingPathComponent("msg_001.json")) + // 00:30 at GMT+8 on 2026-07-11. + try Self.write( + Self.assistantMessage( + id: "msg_002", + modelID: "claude-sonnet-4", + created: "2026-07-10T16:30:00Z", + tokens: #"{"input":3,"output":4,"cache":{"read":0,"write":0}}"#), + to: session.appendingPathComponent("msg_002.json")) + + let snapshot = try #require(Self.scan( + root: root, + now: Self.date("2026-07-12T12:00:00Z"), + calendar: Self.gmtPlus8Calendar)) + + #expect(snapshot.daily.map(\.date) == ["2026-07-10", "2026-07-11"]) + #expect(snapshot.daily.map(\.requestCount) == [1, 1]) + #expect(snapshot.daily.map(\.totalTokens) == [3, 7]) + } + + @Test + func `scanner prefilters by file mtime and drops records outside the window`() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let session = try Self.makeSessionDir(root, "ses_a") + + // mtime older than the window: skipped without parsing, even for in-window timestamps. + let oldFile = session.appendingPathComponent("msg_old.json") + try Self.write( + Self.assistantMessage( + id: "msg_old", + modelID: "claude-sonnet-4", + created: "2026-07-12T09:00:00Z", + tokens: #"{"input":100,"output":100,"cache":{"read":0,"write":0}}"#), + to: oldFile) + try FileManager.default.setAttributes( + [.modificationDate: Self.date("2026-06-02T12:00:00Z")], + ofItemAtPath: oldFile.path) + // Fresh mtime but a record older than the window: dropped by the day filter. + try Self.write( + Self.assistantMessage( + id: "msg_stale", + modelID: "claude-sonnet-4", + created: "2026-06-02T09:00:00Z", + tokens: #"{"input":200,"output":200,"cache":{"read":0,"write":0}}"#), + to: session.appendingPathComponent("msg_stale.json")) + try Self.write( + Self.assistantMessage( + id: "msg_ok", + modelID: "claude-sonnet-4", + created: "2026-07-12T09:00:00Z", + tokens: #"{"input":5,"output":6,"cache":{"read":0,"write":0}}"#), + to: session.appendingPathComponent("msg_ok.json")) + + let snapshot = try #require(Self.scan(root: root, now: Self.date("2026-07-12T12:00:00Z"))) + + #expect(snapshot.daily.map(\.date) == ["2026-07-12"]) + #expect(snapshot.last30DaysRequests == 1) + #expect(snapshot.last30DaysTokens == 11) + } + + @Test + func `scanner skips corrupt files and non assistant or model less messages`() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let session = try Self.makeSessionDir(root, "ses_a") + + try Self.write("this is not json", to: session.appendingPathComponent("msg_garbage.json")) + try Self.write(Self.assistantMessage( + id: "msg_user", + modelID: "claude-sonnet-4", + created: "2026-07-10T09:00:00Z", + tokens: #"{"input":10,"output":5,"cache":{"read":0,"write":0}}"#, + role: "user"), to: session.appendingPathComponent("msg_user.json")) + // Role-less payloads are v2 SQLite rows, never valid JSON message files. + try Self.write(Self.assistantMessage( + id: "msg_norole", + modelID: "claude-sonnet-4", + created: "2026-07-10T09:00:00Z", + tokens: #"{"input":10,"output":5,"cache":{"read":0,"write":0}}"#, + role: nil), to: session.appendingPathComponent("msg_norole.json")) + try Self.write( + Self.assistantMessage( + id: "msg_nomodel", + modelID: nil, + created: "2026-07-10T09:00:00Z", + tokens: #"{"input":10,"output":5,"cache":{"read":0,"write":0}}"#), + to: session.appendingPathComponent("msg_nomodel.json")) + // v2-style nested model object resolves when the top-level modelID is absent. + try Self.write( + Self.assistantMessage( + id: "msg_nested", + modelID: nil, + nestedModelID: "claude-sonnet-4", + created: "2026-07-10T09:00:00Z", + tokens: #"{"input":10,"output":5,"cache":{"read":1,"write":2}}"#), + to: session.appendingPathComponent("msg_nested.json")) + + let snapshot = try #require(Self.scan(root: root, now: Self.date("2026-07-12T12:00:00Z"))) + + #expect(snapshot.daily.map(\.date) == ["2026-07-10"]) + #expect(snapshot.last30DaysRequests == 1) + #expect(snapshot.last30DaysTokens == 18) + #expect(snapshot.daily.first?.modelsUsed == ["claude-sonnet-4"]) + #expect(snapshot.daily.first?.cacheReadTokens == 1) + #expect(snapshot.daily.first?.cacheCreationTokens == 2) + } + + @Test + func `scanner skips negative token values and overflowing accumulations`() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let session = try Self.makeSessionDir(root, "ses_a") + + try Self.write( + Self.assistantMessage( + id: "msg_neg", + modelID: "claude-sonnet-4", + created: "2026-07-10T09:00:00Z", + tokens: #"{"input":10,"output":5,"cache":{"read":-5,"write":0}}"#), + to: session.appendingPathComponent("msg_neg.json")) + try Self.write( + Self.assistantMessage( + id: "msg_neg_reasoning", + modelID: "claude-sonnet-4", + created: "2026-07-10T09:01:00Z", + tokens: #"{"input":10,"output":5,"reasoning":-1,"cache":{"read":0,"write":0}}"#), + to: session.appendingPathComponent("msg_neg_reasoning.json")) + try Self.write( + Self.assistantMessage( + id: "msg_huge", + modelID: "claude-sonnet-4", + created: "2026-07-10T09:02:00Z", + tokens: #"{"input":9223372036854775807,"output":0,"cache":{"read":0,"write":0}}"#), + to: session.appendingPathComponent("msg_huge.json")) + try Self.write( + Self.assistantMessage( + id: "msg_small", + modelID: "claude-sonnet-4", + created: "2026-07-10T09:03:00Z", + tokens: #"{"input":10,"output":0,"cache":{"read":0,"write":0}}"#), + to: session.appendingPathComponent("msg_small.json")) + + let snapshot = try #require(Self.scan(root: root, now: Self.date("2026-07-12T12:00:00Z"))) + + // Only the Int.max record survives; the follow-up record overflows the accumulator and + // is dropped without poisoning the bucket. + #expect(snapshot.last30DaysRequests == 1) + #expect(snapshot.last30DaysTokens == Int.max) + #expect(snapshot.daily.first?.inputTokens == Int.max) + } + + @Test + func `scanner deduplicates forked message copies by id then file stem`() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let sessionA = try Self.makeSessionDir(root, "ses_a") + let sessionB = try Self.makeSessionDir(root, "ses_b") + let sessionFork = try Self.makeSessionDir(root, "ses_fork") + + try Self.write( + Self.assistantMessage( + id: "msg_shared", + modelID: "claude-sonnet-4", + created: "2026-07-10T09:00:00Z", + tokens: #"{"input":10,"output":1,"cache":{"read":0,"write":0}}"#), + to: sessionA.appendingPathComponent("msg_a.json")) + // Same embedded id under a forked session directory collapses into the first copy. + try Self.write( + Self.assistantMessage( + id: "msg_shared", + modelID: "claude-sonnet-4", + created: "2026-07-10T09:00:00Z", + tokens: #"{"input":10,"output":1,"cache":{"read":0,"write":0}}"#), + to: sessionFork.appendingPathComponent("msg_copy.json")) + // Without an embedded id the file stem is the dedup key, so the fork's copy collapses too. + try Self.write( + Self.assistantMessage( + id: nil, + modelID: "claude-sonnet-4", + created: "2026-07-10T09:00:00Z", + tokens: #"{"input":5,"output":5,"cache":{"read":0,"write":0}}"#), + to: sessionA.appendingPathComponent("msg_stem.json")) + try Self.write( + Self.assistantMessage( + id: nil, + modelID: "claude-sonnet-4", + created: "2026-07-10T09:00:00Z", + tokens: #"{"input":5,"output":5,"cache":{"read":0,"write":0}}"#), + to: sessionB.appendingPathComponent("msg_stem.json")) + try Self.write( + Self.assistantMessage( + id: "msg_distinct", + modelID: "claude-sonnet-4", + created: "2026-07-10T09:00:00Z", + tokens: #"{"input":2,"output":2,"cache":{"read":0,"write":0}}"#), + to: sessionB.appendingPathComponent("msg_distinct.json")) + + let snapshot = try #require(Self.scan(root: root, now: Self.date("2026-07-12T12:00:00Z"))) + + #expect(snapshot.daily.map(\.date) == ["2026-07-10"]) + #expect(snapshot.last30DaysRequests == 3) + #expect(snapshot.last30DaysTokens == 25) + #expect(snapshot.daily.first?.inputTokens == 17) + #expect(snapshot.daily.first?.outputTokens == 8) + } + + @Test + func `scanner honors XDG_DATA_HOME override and returns nil for empty directories`() throws { + let emptyRoot = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: emptyRoot) } + + #expect(Self.scan(root: emptyRoot, now: Self.date("2026-07-12T12:00:00Z")) == nil) + + let dataDir = emptyRoot.appendingPathComponent("opencode/storage/message", isDirectory: true) + try FileManager.default.createDirectory(at: dataDir, withIntermediateDirectories: true) + #expect(Self.scan(root: emptyRoot, now: Self.date("2026-07-12T12:00:00Z")) == nil) + + let fixtureRoot = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: fixtureRoot) } + let session = try Self.makeSessionDir(fixtureRoot, "ses_a") + try Self.write( + Self.assistantMessage( + id: "msg_001", + modelID: "claude-sonnet-4", + created: "2026-07-10T09:00:00Z", + tokens: #"{"input":1,"output":2,"cache":{"read":0,"write":0}}"#), + to: session.appendingPathComponent("msg_001.json")) + + let snapshot = try #require(Self.scan(root: fixtureRoot, now: Self.date("2026-07-12T12:00:00Z"))) + #expect(snapshot.last30DaysTokens == 3) + #expect(snapshot.historyLabel == "OpenCode") + } + + // MARK: - Fixtures + + private static let utcCalendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + }() + + private static let gmtPlus8Calendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 8 * 3600)! + return calendar + }() + + private static func scan( + root: URL, + now: Date, + calendar: Calendar = Self.utcCalendar) -> CostUsageTokenSnapshot? + { + OpenCodeSessionScanner.scan( + environment: [OpenCodeSessionScanner.dataHomeEnvironmentKey: root.path], + historyDays: 30, + now: now, + calendar: calendar) + } + + private static func makeRoot() throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root + } + + private static func makeSessionDir(_ root: URL, _ sessionID: String) throws -> URL { + let dir = root.appendingPathComponent("opencode/storage/message/\(sessionID)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + private static func assistantMessage( + id: String?, + modelID: String?, + nestedModelID: String? = nil, + created: String, + tokens: String, + role: String?? = "assistant", + completed: Bool = false) -> String + { + var fields: [String] = [] + if let id { fields.append(#""id":"\#(id)""#) } + fields.append(#""sessionID":"ses""#) + if let role = role.flatMap(\.self) { fields.append(#""role":"\#(role)""#) } + if let modelID { fields.append(#""modelID":"\#(modelID)""#) } + if let nestedModelID { fields.append(#""model":{"id":"\#(nestedModelID)","providerID":"test"}"#) } + fields.append(#""tokens":"# + tokens) + var time = #""created":"# + "\(Self.ms(created))" + if completed { time += #","completed":"# + "\(Self.ms(created) + 1000)" } + fields.append(#""time":{"# + time + "}") + return "{" + fields.joined(separator: ",") + "}" + } + + private static func ms(_ iso: String) -> Int { + Int(self.date(iso).timeIntervalSince1970 * 1000) + } + + private static func date(_ iso: String) -> Date { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: iso) ?? Date(timeIntervalSince1970: 0) + } + + private static func write(_ content: String, to url: URL) throws { + try (content + "\n").write(to: url, atomically: true, encoding: .utf8) + } +} diff --git a/Tests/CodexBarTests/SpendDashboardControllerTests.swift b/Tests/CodexBarTests/SpendDashboardControllerTests.swift index a8d451e79d..eee2ce8a0d 100644 --- a/Tests/CodexBarTests/SpendDashboardControllerTests.swift +++ b/Tests/CodexBarTests/SpendDashboardControllerTests.swift @@ -80,6 +80,7 @@ struct SpendDashboardControllerTests { codexAccountDisplayNames: ["codex:account": "Codex"], sourceOwnershipFingerprints: ["openai:stable"]) let controller = SpendDashboardController( + userDefaults: dashboardDefaults(), requestBuilder: { mode in SpendDashboardLoadRequest( configuration: configuration, @@ -288,7 +289,7 @@ struct SpendDashboardControllerTests { let snapshot = Self.input(id: "claude", provider: .claude, cost: 3).snapshot store._setTokenSnapshotForTesting(snapshot, provider: .claude) store._test_tokenUsageRefreshOverride = { _, _ in } - let controller = SpendDashboardController(requestBuilder: { mode in + let controller = SpendDashboardController(userDefaults: dashboardDefaults(), requestBuilder: { mode in await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) }) @@ -408,7 +409,7 @@ struct SpendDashboardControllerTests { environmentBase: [:]) store._setTokenSnapshotForTesting(Self.input(provider: .claude, cost: 3).snapshot, provider: .claude) store._test_tokenUsageRefreshOverride = { _, _ in } - let controller = SpendDashboardController(requestBuilder: { mode in + let controller = SpendDashboardController(userDefaults: dashboardDefaults(), requestBuilder: { mode in await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) }) @@ -431,7 +432,7 @@ struct SpendDashboardControllerTests { #expect(controller.failedSourceCount == 1) #expect(store.tokenSnapshot(for: .claude)?.last30DaysCostUSD == 3) - let reopenedController = SpendDashboardController(requestBuilder: { mode in + let reopenedController = SpendDashboardController(userDefaults: dashboardDefaults(), requestBuilder: { mode in await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) }) reopenedController.update(configuration: replacementConfiguration) @@ -487,7 +488,7 @@ struct SpendDashboardControllerTests { store._setTokenSnapshotForTesting(Self.input(provider: .mistral, cost: 3).snapshot, provider: .mistral) store._test_providerRefreshOverride = { _ in } - let controller = SpendDashboardController(requestBuilder: { mode in + let controller = SpendDashboardController(userDefaults: dashboardDefaults(), requestBuilder: { mode in await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) }) controller.update(configuration: selectedBackupConfiguration) @@ -527,7 +528,7 @@ struct SpendDashboardControllerTests { environmentBase: [:]) store._setTokenSnapshotForTesting(Self.input(provider: .claude, cost: 4).snapshot, provider: .claude) store._test_tokenUsageRefreshOverride = { _, _ in } - let controller = SpendDashboardController(requestBuilder: { mode in + let controller = SpendDashboardController(userDefaults: dashboardDefaults(), requestBuilder: { mode in await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) }) controller.update(configuration: SpendDashboardSource.configuration(settings: settings, store: store)) @@ -557,7 +558,7 @@ struct SpendDashboardControllerTests { environmentBase: [:]) store._setTokenSnapshotForTesting(Self.input(provider: .claude, cost: 5).snapshot, provider: .claude) store._test_tokenUsageRefreshOverride = { _, _ in } - let controller = SpendDashboardController(requestBuilder: { mode in + let controller = SpendDashboardController(userDefaults: dashboardDefaults(), requestBuilder: { mode in await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) }) let firstConfiguration = SpendDashboardSource.configuration(settings: settings, store: store) @@ -626,7 +627,7 @@ struct SpendDashboardControllerTests { environmentBase: [:]) store._setTokenSnapshotForTesting(Self.input(provider: .claude, cost: 5).snapshot, provider: .claude) store._test_tokenUsageRefreshOverride = { _, _ in } - let controller = SpendDashboardController(requestBuilder: { mode in + let controller = SpendDashboardController(userDefaults: dashboardDefaults(), requestBuilder: { mode in await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) }) controller.update(configuration: SpendDashboardSource.configuration(settings: settings, store: store)) @@ -644,7 +645,7 @@ struct SpendDashboardControllerTests { #expect(controller.model.groups.isEmpty) #expect(controller.failedSourceCount == 1) - let reopenedController = SpendDashboardController(requestBuilder: { mode in + let reopenedController = SpendDashboardController(userDefaults: dashboardDefaults(), requestBuilder: { mode in await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) }) reopenedController.update(configuration: reenabledConfiguration) @@ -660,6 +661,7 @@ struct SpendDashboardControllerTests { let initialConfiguration = Self.configuration(account: "same", revision: "initial") let firstProviderConfiguration = Self.configuration(account: "same", revision: "claude-fresh") let controller = SpendDashboardController( + userDefaults: dashboardDefaults(), requestBuilder: { mode in if mode == .forceRefresh { await refreshRecorder.append(.claude) @@ -699,6 +701,7 @@ struct SpendDashboardControllerTests { let initialConfiguration = Self.configuration(account: "same", revision: "initial") let latestConfiguration = Self.configuration(account: "same", revision: "latest") let controller = SpendDashboardController( + userDefaults: dashboardDefaults(), requestBuilder: { mode in await forceRecorder.append(mode) if mode == .forceRefresh { @@ -775,6 +778,7 @@ struct SpendDashboardControllerTests { let controllerBox = SpendDashboardControllerBox() let captureStore = SpendDashboardCapturedInputStore() let controller = SpendDashboardController( + userDefaults: dashboardDefaults(), requestBuilder: { mode in let configuration = controllerBox.controller?.configuration ?? Self.configuration(account: "pending") @@ -1048,6 +1052,7 @@ struct SpendDashboardControllerRevisionTests { private static func controller(gate: SpendDashboardLoaderGate) -> SpendDashboardController { let controllerBox = SpendDashboardControllerBox() let controller = SpendDashboardController( + userDefaults: dashboardDefaults(), requestBuilder: { mode in let configuration = controllerBox.controller?.configuration ?? SpendDashboardConfiguration( @@ -1262,3 +1267,13 @@ private actor SpendDashboardLoaderGate { self.continuations.remove(at: index).resume(returning: result) } } + +/// Isolated defaults for controllers under test. `UserDefaults.standard` is process-wide in the +/// test runner and other suites persist a 7-day window selection into it, which would shrink the +/// reporting window these fixtures rely on (default 30 days). +private func dashboardDefaults() -> UserDefaults { + let suite = "SpendDashboardControllerTests-controller" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + return defaults +} diff --git a/Tests/CodexBarTests/SpendDashboardDateTruthTests.swift b/Tests/CodexBarTests/SpendDashboardDateTruthTests.swift index 496b297396..201fa5a80c 100644 --- a/Tests/CodexBarTests/SpendDashboardDateTruthTests.swift +++ b/Tests/CodexBarTests/SpendDashboardDateTruthTests.swift @@ -518,11 +518,15 @@ struct SpendDashboardDateTruthTests { now: Self.now, calendar: Self.calendar).groups.first) - #expect(costGroup.totalCost == nil) + // The provider's aggregate cost figure (10) contradicts the local per-day logs (3). + // The daily-sum fallback keeps the spend figure available by summing the per-day costs + // instead of voiding the whole provider, so cost stays visible while tokens stay intact. + #expect(costGroup.totalCost == 3) #expect(costGroup.totalTokens == 30) #expect(costGroup.dailyPoints.isEmpty) - #expect(costGroup.modelHistoryCompleteness == .incomplete) - #expect(costGroup.models.isEmpty) + #expect(costGroup.modelHistoryCompleteness == .complete) + #expect(costGroup.models.map(\.totalCost) == [3]) + #expect(costGroup.models.map(\.totalTokens) == [30]) #expect(tokenGroup.totalCost == 3) #expect(tokenGroup.totalTokens == nil) diff --git a/Tests/CodexBarTests/SpendDashboardModelTests.swift b/Tests/CodexBarTests/SpendDashboardModelTests.swift index 30b7887213..2082c8e4ab 100644 --- a/Tests/CodexBarTests/SpendDashboardModelTests.swift +++ b/Tests/CodexBarTests/SpendDashboardModelTests.swift @@ -3,6 +3,7 @@ import Foundation import Testing @testable import CodexBar +// swiftlint:disable type_body_length struct SpendDashboardModelTests { @Test func `count labels avoid plural agreement and localize numbers`() { @@ -19,7 +20,7 @@ struct SpendDashboardModelTests { #expect(codexBarLocalizedInteger(12) == "۱۲") #expect(spendDashboardDayRangeText(7) == "۷ روز") #expect(spendDashboardDayRangeText(30) == "۳۰ روز") - #expect(spendDashboardDayRangeText(365) == "همه") + #expect(spendDashboardDayRangeText(365) == "تجمعی") #expect(spendDashboardRankText(1234) == "#۱٬۲۳۴") #expect(spendDashboardRefreshFailureText(2) == "\(L("Refresh failures")): ۲") #expect(spendDashboardCoverageText(covered: 3, requested: 30) == "پوشش: ۳ / ۳۰") @@ -412,6 +413,113 @@ struct SpendDashboardModelTests { #expect(group.modelAnalysis.costCoverage == .complete) } + @Test + func `model analysis merges claude spellings across providers and snapshot dates`() throws { + let claude = SpendDashboardModel.ProviderInput( + id: "claude-source", + provider: .claude, + displayName: "Claude", + snapshot: Self.snapshot(currency: "USD", entries: [ + Self.entry(day: "2026-07-16", cost: 2, tokens: 20, model: "claude-sonnet-4-5"), + ])) + let vertex = SpendDashboardModel.ProviderInput( + id: "vertex-source", + provider: .vertexai, + displayName: "Vertex AI", + snapshot: Self.snapshot(currency: "USD", entries: [ + Self.entry(day: "2026-07-16", cost: 3, tokens: 30, model: "anthropic/claude-sonnet-4-5-20250929"), + ])) + let group = try #require(SpendDashboardModel.build( + inputs: [claude, vertex], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + let row = try #require(group.modelAnalysis.rows.first) + + #expect(group.modelAnalysis.rows.count == 1) + #expect(row.id == "claude-sonnet-4-5") + #expect(row.displayName == "claude-sonnet-4-5") + #expect(row.rawModelNames == ["claude-sonnet-4-5", "anthropic/claude-sonnet-4-5-20250929"]) + #expect(row.providers == [.claude, .vertexai]) + #expect(row.providerNames == ["Claude", "Vertex AI"]) + #expect(row.totalTokens == 50) + #expect(row.estimatedCost == 5) + #expect(row.contributions.map(\.sourceID) == ["claude-source", "vertex-source"]) + #expect(group.modelAnalysis.dailyValues.map(\.totalTokens) == [50]) + } + + @Test + func `model analysis merges dated snapshots into the base model row`() throws { + let codex = SpendDashboardModel.ProviderInput( + id: "codex-source", + provider: .codex, + displayName: "Codex", + snapshot: Self.snapshot(currency: "USD", entries: [ + Self.entry(day: "2026-07-16", cost: 2, tokens: 20, model: "gpt-5"), + ])) + let openai = SpendDashboardModel.ProviderInput( + id: "openai-source", + provider: .openai, + displayName: "OpenAI", + snapshot: Self.snapshot(currency: "USD", entries: [ + Self.entry(day: "2026-07-16", cost: 3, tokens: 30, model: "gpt-5-2025-08-07"), + ])) + let group = try #require(SpendDashboardModel.build( + inputs: [codex, openai], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + let row = try #require(group.modelAnalysis.rows.first) + + #expect(group.modelAnalysis.rows.count == 1) + #expect(row.id == "gpt-5") + #expect(row.providers == [.codex, .openai]) + #expect(row.providerNames == ["Codex", "OpenAI"]) + #expect(row.totalTokens == 50) + #expect(row.estimatedCost == 5) + } + + @Test + func `model analysis keeps semantic model variants in separate rows`() throws { + let snapshot = Self.snapshot(currency: "USD", entries: [ + Self.entryWithBreakdowns( + day: "2026-07-16", + totalCost: 6, + totalTokens: 60, + breakdowns: [ + .init(modelName: "gpt-5", costUSD: 1, totalTokens: 10), + .init(modelName: "gpt-5-codex", costUSD: 2, totalTokens: 20), + .init(modelName: "gpt-5-mini", costUSD: 3, totalTokens: 30), + ]), + ]) + let group = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .codex, displayName: "Codex", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.modelAnalysis.rows.map(\.id) == ["gpt-5-mini", "gpt-5-codex", "gpt-5"]) + #expect(group.modelAnalysis.rows.map(\.totalTokens) == [30, 20, 10]) + } + + @Test + func `model analysis keeps kimi alias display names`() throws { + let snapshot = Self.snapshot(currency: "USD", entries: [ + Self.entry(day: "2026-07-16", cost: 1, tokens: 10, model: "kimi-code/kimi-for-coding"), + ]) + let group = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .kimi, displayName: "Kimi", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + let row = try #require(group.modelAnalysis.rows.first) + + #expect(group.modelAnalysis.rows.count == 1) + #expect(row.id == "kimi for coding") + #expect(row.displayName == "Kimi for Coding") + #expect(row.rawModelNames == ["kimi-code/kimi-for-coding"]) + } + @Test func `model analysis excludes incomplete source days and labels partial coverage`() throws { let snapshot = Self.snapshot(currency: "USD", entries: [ @@ -467,14 +575,243 @@ struct SpendDashboardModelTests { let totalOnly = try #require(analysis.rows.first(where: { $0.id == "total-only-model" })) let daily = try #require(analysis.dailyValues.first(where: { $0.modelID == "split-model" })) + // Explicit buckets are carried as-is: cache reads no longer fold into the input bucket. #expect(split.totalTokens == 100) - #expect(split.inputTokens == 80) + #expect(split.inputTokens == 60) #expect(split.outputTokens == 20) + #expect(split.cacheReadTokens == 20) + #expect(split.cacheCreationTokens == nil) + #expect(split.reasoningTokens == nil) #expect(totalOnly.totalTokens == 96) #expect(totalOnly.inputTokens == nil) #expect(totalOnly.outputTokens == nil) - #expect(daily.inputTokens == 80) + #expect(totalOnly.cacheReadTokens == nil) + #expect(daily.inputTokens == 60) #expect(daily.outputTokens == 20) + #expect(daily.cacheReadTokens == 20) + } + + @Test + func `model analysis carries reasoning and cache creation buckets to rows and daily values`() throws { + let snapshot = Self.snapshot(currency: "USD", entries: [ + Self.entryWithBreakdowns( + day: "2026-07-16", + totalCost: 0, + totalTokens: 120, + breakdowns: [ + .init( + modelName: "reasoning-model", + costUSD: 0, + totalTokens: 120, + inputTokens: 50, + cacheReadTokens: 10, + cacheCreationTokens: 5, + outputTokens: 55, + reasoningTokens: 30), + ]), + ]) + let analysis = SpendDashboardModel.build( + inputs: [.init(provider: .codex, displayName: "Codex", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).modelAnalysis + let row = try #require(analysis.rows.first(where: { $0.id == "reasoning-model" })) + let daily = try #require(analysis.dailyValues.first(where: { $0.modelID == "reasoning-model" })) + + #expect(row.inputTokens == 50) + #expect(row.outputTokens == 55) + #expect(row.cacheReadTokens == 10) + #expect(row.cacheCreationTokens == 5) + // Reasoning is a sub-bucket of output: 30 of the 55 output tokens, never added on top. + #expect(row.reasoningTokens == 30) + #expect(daily.reasoningTokens == 30) + #expect(daily.cacheCreationTokens == 5) + } + + @Test + func `model analysis normalizes cache inclusive input so explicit buckets sum to the total`() throws { + // Codex-shape breakdowns report cache-inclusive input (input + output == total). + let snapshot = Self.snapshot(currency: "USD", entries: [ + Self.entryWithBreakdowns( + day: "2026-07-16", + totalCost: 0, + totalTokens: 110, + breakdowns: [ + .init( + modelName: "codex-shaped-model", + costUSD: 0, + totalTokens: 110, + inputTokens: 100, + cacheReadTokens: 20, + outputTokens: 10, + reasoningTokens: 4), + ]), + ]) + let analysis = SpendDashboardModel.build( + inputs: [.init(provider: .codex, displayName: "Codex", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).modelAnalysis + let row = try #require(analysis.rows.first(where: { $0.id == "codex-shaped-model" })) + + #expect(row.inputTokens == 80) + #expect(row.outputTokens == 10) + #expect(row.cacheReadTokens == 20) + #expect(row.reasoningTokens == 4) + } + + @Test + func `model analysis falls back to inferred input for legacy breakdowns without split fields`() throws { + let snapshot = Self.snapshot(currency: "USD", entries: [ + Self.entryWithBreakdowns( + day: "2026-07-16", + totalCost: 0, + totalTokens: 100, + breakdowns: [ + .init( + modelName: "legacy-model", + costUSD: 0, + totalTokens: 100, + outputTokens: 25), + ]), + ]) + let analysis = SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).modelAnalysis + let row = try #require(analysis.rows.first(where: { $0.id == "legacy-model" })) + + // Legacy inference: everything non-output counts as input, optional buckets stay unknown. + #expect(row.inputTokens == 75) + #expect(row.outputTokens == 25) + #expect(row.cacheReadTokens == nil) + #expect(row.cacheCreationTokens == nil) + #expect(row.reasoningTokens == nil) + } + + @Test + func `model analysis degrades mixed source shapes to the legacy input inference`() throws { + // Merged-source breakdowns (e.g. cache-inclusive Codex native plus cache-exclusive Pi) + // fit neither explicit shape; the row keeps the legacy split and the shape-independent + // reasoning bucket instead of losing the split entirely. + let snapshot = Self.snapshot(currency: "USD", entries: [ + Self.entryWithBreakdowns( + day: "2026-07-16", + totalCost: 0, + totalTokens: 130, + breakdowns: [ + .init( + modelName: "merged-shape-model", + costUSD: 0, + totalTokens: 130, + inputTokens: 105, + cacheReadTokens: 20, + cacheCreationTokens: 5, + outputTokens: 20, + reasoningTokens: 8), + ]), + ]) + let analysis = SpendDashboardModel.build( + inputs: [.init(provider: .codex, displayName: "Codex", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).modelAnalysis + let row = try #require(analysis.rows.first(where: { $0.id == "merged-shape-model" })) + + #expect(row.inputTokens == 110) + #expect(row.outputTokens == 20) + #expect(row.cacheReadTokens == nil) + #expect(row.cacheCreationTokens == nil) + #expect(row.reasoningTokens == 8) + } + + @Test + func `model analysis drops optional buckets any contributing breakdown does not report`() throws { + let snapshot = Self.snapshot(currency: "USD", entries: [ + Self.entryWithBreakdowns( + day: "2026-07-16", + totalCost: 0, + totalTokens: 220, + breakdowns: [ + .init( + modelName: "mixed-model", + costUSD: 0, + totalTokens: 120, + inputTokens: 50, + cacheReadTokens: 10, + cacheCreationTokens: 5, + outputTokens: 55, + reasoningTokens: 30), + .init( + modelName: "mixed-model", + costUSD: 0, + totalTokens: 100, + inputTokens: 75, + outputTokens: 25), + ]), + ]) + let analysis = SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).modelAnalysis + let row = try #require(analysis.rows.first(where: { $0.id == "mixed-model" })) + + // input/output stay complete (both breakdowns resolve a split); the optional buckets + // vanish because the second breakdown does not report them. + #expect(row.inputTokens == 125) + #expect(row.outputTokens == 80) + #expect(row.cacheReadTokens == nil) + #expect(row.cacheCreationTokens == nil) + #expect(row.reasoningTokens == nil) + } + + @Test + func `model analysis flags rows whose cost is estimated and clears provider reported rows`() throws { + let day = "2026-07-16" + let estimated = SpendDashboardModel.ProviderInput( + id: "estimated-source", + provider: .claude, + displayName: "Claude", + snapshot: Self.snapshot(currency: "USD", entries: [ + Self.entryWithBreakdowns( + day: day, + totalCost: 4, + totalTokens: 40, + breakdowns: [.init(modelName: "shared-model", costUSD: 4, totalTokens: 40)]), + ])) + let providerReported = SpendDashboardModel.ProviderInput( + id: "billed-source", + provider: .cursor, + displayName: "Cursor", + snapshot: Self.snapshot( + currency: "USD", + entries: [ + Self.entryWithBreakdowns( + day: day, + totalCost: 6, + totalTokens: 60, + breakdowns: [.init(modelName: "shared-model", costUSD: 6, totalTokens: 60)]), + ], + costSource: .providerReported)) + let analysis = SpendDashboardModel.build( + inputs: [estimated, providerReported], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).modelAnalysis + let mixed = try #require(analysis.rows.first(where: { $0.id == "shared-model" })) + #expect(mixed.estimatedCost == 10) + #expect(mixed.costIsEstimated == true) + + let billedOnly = SpendDashboardModel.build( + inputs: [providerReported], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).modelAnalysis + let billedRow = try #require(billedOnly.rows.first(where: { $0.id == "shared-model" })) + #expect(billedRow.estimatedCost == 6) + #expect(billedRow.costIsEstimated == false) } @Test @@ -979,6 +1316,7 @@ extension SpendDashboardModelTests { currency: String, entries: [CostUsageDailyReport.Entry], historyDays: Int = 30, + costSource: CostUsageCostSource = .estimated, updatedAt: Date = now) -> CostUsageTokenSnapshot { CostUsageTokenSnapshot( @@ -988,6 +1326,7 @@ extension SpendDashboardModelTests { last30DaysCostUSD: nil, currencyCode: currency, historyDays: historyDays, + costSource: costSource, daily: entries, updatedAt: updatedAt) } @@ -1033,3 +1372,5 @@ extension SpendDashboardModelTests { return calendar } } + +// swiftlint:enable type_body_length diff --git a/Tests/CodexBarTests/SpendDashboardSourceConcurrencyTests.swift b/Tests/CodexBarTests/SpendDashboardSourceConcurrencyTests.swift index 127754b80b..844e9fc8b1 100644 --- a/Tests/CodexBarTests/SpendDashboardSourceConcurrencyTests.swift +++ b/Tests/CodexBarTests/SpendDashboardSourceConcurrencyTests.swift @@ -56,6 +56,184 @@ struct SpendDashboardSourceConcurrencyTests { #expect(result.failedSourceIDs.isEmpty) } + @Test + func `local Gemini and OpenCode histories are appended without entering the cost provider pipeline`() async throws { + let now = Date(timeIntervalSince1970: 1_784_179_200) + let geminiSnapshot = Self.localHistorySnapshot(tokens: 12, model: "gemini-test-model", now: now) + let openCodeSnapshot = Self.localHistorySnapshot(tokens: 8, model: "opencode-test-model", now: now) + let recorder = SpendDashboardLocalLoaderRecorder() + let request = SpendDashboardLoadRequest( + configuration: SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.gemini.rawValue, UsageProvider.opencode.rawValue], + codexAccountIdentities: []), + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [], + geminiCLIHomePath: "/synthetic/gemini-cli", + openCodeDataHomePath: "/synthetic/opencode-data", + now: now, + force: false) + + let result = await SpendDashboardSource.load( + request, + codexSnapshotLoader: { _ in + Issue.record("Codex loader should not run") + return geminiSnapshot + }, + kimiCodeSnapshotLoader: { _ in + Issue.record("Kimi loader should not run") + return nil + }, + geminiSnapshotLoader: { context in + await recorder.record("gemini") + #expect(context.homePath == "/synthetic/gemini-cli") + return geminiSnapshot + }, + openCodeSnapshotLoader: { context in + await recorder.record("opencode") + #expect(context.homePath == "/synthetic/opencode-data") + return openCodeSnapshot + }) + + #expect(await recorder.invokedIDs == ["gemini", "opencode"]) + let geminiInput = try #require(result.inputs.first { $0.id == "gemini:local" }) + #expect(geminiInput.provider == .gemini) + #expect(geminiInput.displayName == "Gemini CLI") + #expect(geminiInput.modelProviderName == "Gemini") + #expect(geminiInput.snapshot.currencyCode == "XXX") + let openCodeInput = try #require(result.inputs.first { $0.id == "opencode:local" }) + #expect(openCodeInput.provider == .opencode) + #expect(openCodeInput.displayName == "OpenCode") + #expect(openCodeInput.modelProviderName == "OpenCode") + #expect(openCodeInput.snapshot.currencyCode == "XXX") + #expect(result.failedSourceIDs.isEmpty) + } + + @Test + func `local history failure marks only its own source id`() async { + let now = Date(timeIntervalSince1970: 1_784_179_200) + let snapshot = Self.localHistorySnapshot(tokens: 8, model: "opencode-test-model", now: now) + let request = SpendDashboardLoadRequest( + configuration: SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.gemini.rawValue, UsageProvider.opencode.rawValue], + codexAccountIdentities: []), + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [], + geminiCLIHomePath: "/synthetic/gemini-cli", + openCodeDataHomePath: "/synthetic/opencode-data", + now: now, + force: false) + + let geminiFailed = await SpendDashboardSource.load( + request, + codexSnapshotLoader: { _ in + Issue.record("Codex loader should not run") + return snapshot + }, + geminiSnapshotLoader: { _ in + throw SpendDashboardSyntheticError.failed + }, + openCodeSnapshotLoader: { _ in snapshot }) + #expect(geminiFailed.inputs.map(\.id) == ["opencode:local"]) + #expect(geminiFailed.failedSourceIDs == ["gemini:local"]) + + let openCodeFailed = await SpendDashboardSource.load( + request, + codexSnapshotLoader: { _ in + Issue.record("Codex loader should not run") + return snapshot + }, + geminiSnapshotLoader: { _ in snapshot }, + openCodeSnapshotLoader: { _ in + throw SpendDashboardSyntheticError.failed + }) + #expect(openCodeFailed.inputs.map(\.id) == ["gemini:local"]) + #expect(openCodeFailed.failedSourceIDs == ["opencode:local"]) + } + + @Test + func `local history cancellation marks the source failed and short-circuits remaining local scans`() async { + let now = Date(timeIntervalSince1970: 1_784_179_200) + let snapshot = Self.localHistorySnapshot(tokens: 8, model: "opencode-test-model", now: now) + let recorder = SpendDashboardLocalLoaderRecorder() + let request = SpendDashboardLoadRequest( + configuration: SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.gemini.rawValue, UsageProvider.opencode.rawValue], + codexAccountIdentities: []), + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [], + geminiCLIHomePath: "/synthetic/gemini-cli", + openCodeDataHomePath: "/synthetic/opencode-data", + now: now, + force: false) + + let result = await SpendDashboardSource.load( + request, + codexSnapshotLoader: { _ in + Issue.record("Codex loader should not run") + return snapshot + }, + geminiSnapshotLoader: { _ in + await recorder.record("gemini") + throw CancellationError() + }, + openCodeSnapshotLoader: { _ in + await recorder.record("opencode") + return snapshot + }) + + #expect(result.inputs.isEmpty) + #expect(result.failedSourceIDs == ["gemini:local"]) + #expect(await recorder.invokedIDs == ["gemini"]) + } + + @Test + func `local model history providers follow the enabled provider set`() async { + let settings = testSettingsStore(suiteName: "SpendDashboardSourceConcurrencyTests-local-history") + settings.costUsageEnabled = true + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled( + provider: provider, + metadata: metadata, + enabled: provider == .kimi || provider == .gemini || provider == .opencode) + } + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + + #expect(Set(SpendDashboardSource.localModelHistoryProviders(store: store)) == [.gemini, .kimi, .opencode]) + let configuration = SpendDashboardSource.configuration(settings: settings, store: store) + #expect(configuration.providerIDs.contains(UsageProvider.gemini.rawValue)) + #expect(configuration.providerIDs.contains(UsageProvider.kimi.rawValue)) + #expect(configuration.providerIDs.contains(UsageProvider.opencode.rawValue)) + + let request = await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: .captureOnly) + #expect(request.kimiCodeHomePath != nil) + #expect(request.geminiCLIHomePath != nil) + #expect(request.openCodeDataHomePath != nil) + + if let metadata = ProviderRegistry.shared.metadata[.gemini] { + settings.setProviderEnabled(provider: .gemini, metadata: metadata, enabled: false) + } + #expect(!SpendDashboardSource.localModelHistoryProviders(store: store).contains(.gemini)) + let disabledRequest = await SpendDashboardSource.makeRequest( + settings: settings, + store: store, + mode: .captureOnly) + #expect(disabledRequest.geminiCLIHomePath == nil) + #expect(disabledRequest.kimiCodeHomePath != nil) + #expect(disabledRequest.openCodeDataHomePath != nil) + } + @Test func `Codex batch revalidates completed and failed accounts after later scans`() async throws { let root = FileManager.default.temporaryDirectory @@ -578,6 +756,30 @@ struct SpendDashboardSourceConcurrencyTests { snapshot: snapshot) } + private static func localHistorySnapshot( + tokens: Int, + model: String, + now: Date) -> CostUsageTokenSnapshot + { + let entry = CostUsageDailyReport.Entry( + date: "2026-07-16", + inputTokens: tokens, + outputTokens: 0, + totalTokens: tokens, + requestCount: 1, + costUSD: nil, + modelsUsed: [model], + modelBreakdowns: [.init(modelName: model, costUSD: nil, totalTokens: tokens)]) + return CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: tokens, + last30DaysCostUSD: nil, + currencyCode: "XXX", + daily: [entry], + updatedAt: now) + } + private static func waitForCodexGate(_ gate: SpendDashboardCodexBatchGate) async { for _ in 0..<1000 { if await gate.isSuspended { @@ -636,6 +838,14 @@ private enum SpendDashboardSyntheticError: Error { case failed } +private actor SpendDashboardLocalLoaderRecorder { + private(set) var invokedIDs: [String] = [] + + func record(_ id: String) { + self.invokedIDs.append(id) + } +} + @MainActor private final class SpendDashboardRequestSequence { struct Item { diff --git a/Tests/CodexBarTests/SpendDashboardTokenProvenanceTests.swift b/Tests/CodexBarTests/SpendDashboardTokenProvenanceTests.swift index 88e594eca4..896a51876c 100644 --- a/Tests/CodexBarTests/SpendDashboardTokenProvenanceTests.swift +++ b/Tests/CodexBarTests/SpendDashboardTokenProvenanceTests.swift @@ -124,7 +124,7 @@ struct SpendDashboardTokenProvenanceTests { store.activateCachedTokenAccountSnapshot(provider: .mistral, accountID: account.id) #expect(store.tokenSnapshotPublicationRevision(for: .mistral) == baselineRevision) store._test_providerRefreshOverride = { _ in } - let controller = SpendDashboardController(requestBuilder: { mode in + let controller = SpendDashboardController(userDefaults: dashboardDefaults(), requestBuilder: { mode in await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) }) controller.update(configuration: SpendDashboardSource.configuration(settings: settings, store: store)) @@ -148,7 +148,7 @@ struct SpendDashboardTokenProvenanceTests { return loadCount == 1 ? Self.tokenSnapshot(cost: 4) : Self.emptyTokenSnapshot() } await store.refreshTokenUsageNow(for: .bedrock, force: true) - let controller = SpendDashboardController(requestBuilder: { mode in + let controller = SpendDashboardController(userDefaults: dashboardDefaults(), requestBuilder: { mode in await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) }) controller.update(configuration: SpendDashboardSource.configuration(settings: settings, store: store)) @@ -177,7 +177,7 @@ struct SpendDashboardTokenProvenanceTests { } await store.refreshTokenUsageNow(for: .bedrock, force: true) let publicationRevision = store.tokenSnapshotPublicationRevision(for: .bedrock) - let controller = SpendDashboardController(requestBuilder: { mode in + let controller = SpendDashboardController(userDefaults: dashboardDefaults(), requestBuilder: { mode in await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) }) @@ -428,3 +428,13 @@ private actor SpendDashboardProvenanceGate { continuations.forEach { $0.resume() } } } + +/// Isolated defaults for controllers under test. `UserDefaults.standard` is process-wide in the +/// test runner and other suites persist a 7-day window selection into it, which would shrink the +/// reporting window these fixtures rely on (default 30 days). +private func dashboardDefaults() -> UserDefaults { + let suite = "SpendDashboardTokenProvenanceTests-controller" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + return defaults +} diff --git a/Tests/CodexBarTests/SpendModelIdentityTests.swift b/Tests/CodexBarTests/SpendModelIdentityTests.swift new file mode 100644 index 0000000000..754d5102ca --- /dev/null +++ b/Tests/CodexBarTests/SpendModelIdentityTests.swift @@ -0,0 +1,101 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct SpendModelIdentityTests { + @Test + func `identity trims and collapses whitespace`() { + #expect(Self.id(" claude-sonnet-4-5 ") == "claude-sonnet-4-5") + #expect(Self.id("\n\tgpt-5\n") == "gpt-5") + #expect(Self.id("test model") == "test model") + } + + @Test + func `identity merges compact and dashed snapshot dates`() { + #expect(Self.id("gpt-5-20250807") == "gpt-5") + #expect(Self.id("gpt-5-2025-08-07") == "gpt-5") + #expect(Self.id("gpt-5-2024-01-15") == Self.id("gpt-5-2025-08-07")) + #expect(Self.id("claude-sonnet-4-5-20250929") == "claude-sonnet-4-5") + } + + @Test + func `identity rejects digit suffixes that are not valid dates`() { + #expect(Self.id("gpt-5-20251345") == "gpt-5-20251345") // month 13 is not a date + #expect(Self.id("test-12345678") == "test-12345678") // year 1234 is implausible + #expect(Self.id("deepseek-v3-0324") == "deepseek-v3-0324") // short version suffix stays + #expect(Self.id("gpt-5-20251345") != Self.id("gpt-5")) + } + + @Test + func `identity strips vendor routing prefixes but keeps unknown prefixes`() { + #expect(Self.id("anthropic/claude-sonnet-4-5", provider: .vertexai) == "claude-sonnet-4-5") + #expect(Self.id("openrouter/anthropic/claude-sonnet-4-5", provider: .openrouter) == "claude-sonnet-4-5") + #expect(Self.id("openai/gpt-5", provider: .openai) == "gpt-5") + #expect(Self.id("bedrock/claude-sonnet-4-5", provider: .bedrock) == "claude-sonnet-4-5") + #expect(Self.id("acme-corp/gpt-5", provider: .openai) == "acme-corp/gpt-5") + #expect(Self.id("acme-corp/gpt-5") != Self.id("gpt-5")) + } + + @Test + func `identity uses the provider name as an extra prefix hint`() { + #expect(Self.id("cursor/gpt-5", provider: .cursor) == "gpt-5") + #expect(Self.id("cursor/gpt-5", provider: .openai) == "cursor/gpt-5") + #expect(Self.id("cursor/gpt-5") == "cursor/gpt-5") + } + + @Test + func `identity normalizes claude version punctuation and order`() { + #expect(Self.id("claude-3.5-sonnet") == "claude-sonnet-3-5") + #expect(Self.id("claude-3-5-sonnet") == "claude-sonnet-3-5") + #expect(Self.id("claude-3.5-sonnet-20241022") == Self.id("claude-sonnet-3-5")) + #expect(Self.id("claude-sonnet-4-5") == "claude-sonnet-4-5") // family-first order stays + #expect(Self.id("claude-opus-4-5") != Self.id("claude-sonnet-4-5")) + } + + @Test + func `identity leaves dots in non claude names alone`() { + #expect(Self.id("test-model-1.5-pro") == "test-model-1.5-pro") + #expect(Self.id("test-model-1.5-pro") != Self.id("test-model-1-5-pro")) + } + + @Test + func `identity keeps kimi alias display names`() { + let prefixed = SpendModelIdentity(rawName: "kimi-code/kimi-k2.5", provider: .kimi) + #expect(prefixed.displayName == "Kimi K2.5") + #expect(prefixed.id == "kimi k2.5") + #expect(SpendModelIdentity(rawName: "K2", provider: .kimi).displayName == "Kimi K2") + #expect(SpendModelIdentity(rawName: "k2.5", provider: .kimi).displayName == "Kimi K2.5") + let coding = SpendModelIdentity(rawName: "kimi-code/kimi-for-coding-highspeed", provider: .kimi) + #expect(coding.displayName == "Kimi for Coding High-Speed") + } + + @Test + func `identity merges recognized proxy reasoning tier annotations only`() { + #expect(Self.id("gpt-5(high)") == "gpt-5") + #expect(Self.id("gpt-5(xhigh)") == Self.id("gpt-5")) + #expect(Self.id("gpt-5(turbo)") == "gpt-5(turbo)") // unknown tiers stay distinct + #expect(Self.id("gpt-5 (high)") == "gpt-5 (high)") // spaced annotation stays, conservatively + } + + @Test + func `identity never strips semantic model suffixes`() { + #expect(Self.id("gpt-5-codex") != Self.id("gpt-5")) + #expect(Self.id("gpt-5-mini") != Self.id("gpt-5")) + #expect(Self.id("gpt-5-latest") != Self.id("gpt-5")) + #expect(Self.id("claude-sonnet-4-5-thinking") != Self.id("claude-sonnet-4-5")) + } + + @Test + func `identity survives empty and garbage names`() { + #expect(Self.id("").isEmpty) + #expect(Self.id(" ").isEmpty) + #expect(Self.id("///") == "///") + #expect(Self.id("anthropic/") == "anthropic") + #expect(Self.id("-20250807") == "-20250807") // a date dash with no model name stays + } + + private static func id(_ rawName: String, provider: UsageProvider? = nil) -> String { + SpendModelIdentity(rawName: rawName, provider: provider).id + } +} diff --git a/Tests/CodexBarTests/SpendModelsPresentationTests.swift b/Tests/CodexBarTests/SpendModelsPresentationTests.swift index 99ac53cede..139a55ebf0 100644 --- a/Tests/CodexBarTests/SpendModelsPresentationTests.swift +++ b/Tests/CodexBarTests/SpendModelsPresentationTests.swift @@ -4,8 +4,8 @@ import Testing struct SpendModelsPresentationTests { @Test - func `model card range labels stay English and preserve compact order`() { - #expect([7, 30, 365].map(spendModelsDayRangeText) == ["7d", "30d", "All"]) + func `dashboard range labels stay English and preserve compact order`() { + #expect([7, 30, 365].map(spendDashboardDayRangeText) == ["7d", "30d", "Cumulative"]) } @Test @@ -165,13 +165,240 @@ struct SpendModelsPresentationTests { #expect(presentation.coverage == .partial) } + @Test + func `ranking keeps every row visible at the collapsed limit`() { + let rows = Self.rankingRows(count: SpendModelsRanking.collapsedRowLimit) + + #expect(!SpendModelsRanking.showsDisclosure(rowCount: rows.count)) + #expect(SpendModelsRanking.visibleRows(rows, showsAll: false).map(\.id) == rows.map(\.id)) + #expect(SpendModelsRanking.visibleRows(rows, showsAll: true).map(\.id) == rows.map(\.id)) + } + + @Test + func `ranking truncates rows beyond the collapsed limit until expanded`() { + let rows = Self.rankingRows(count: SpendModelsRanking.collapsedRowLimit + 5) + + #expect(SpendModelsRanking.showsDisclosure(rowCount: rows.count)) + + let collapsed = SpendModelsRanking.visibleRows(rows, showsAll: false) + #expect(collapsed.count == SpendModelsRanking.collapsedRowLimit) + #expect(collapsed.map(\.id) == rows.prefix(SpendModelsRanking.collapsedRowLimit).map(\.id)) + #expect(collapsed.last?.rank == SpendModelsRanking.collapsedRowLimit) + + let expanded = SpendModelsRanking.visibleRows(rows, showsAll: true) + #expect(expanded.map(\.id) == rows.map(\.id)) + } + + @Test + func `dashboard range labels localize with the app language`() { + CodexBarLocalizationOverride.$appLanguage.withValue("zh-Hans") { + #expect([7, 30, 365].map(spendDashboardDayRangeText) == ["7 天", "30 天", "累计"]) + } + } + + @Test + func `token row detail localizes the in and out split`() { + let row = SpendModelsPresentation.Row( + source: Self.row( + id: "complete", + tokens: 100, + inputTokens: 80, + outputTokens: 20, + cost: nil, + providers: ["Codex"]), + rank: 1, + value: 100, + share: 1) + + CodexBarLocalizationOverride.$appLanguage.withValue("zh-Hans") { + #expect(spendModelsRowDetailText(row) == "80 输入 · 20 输出 · Codex") + } + } + + @Test + func `trailing average smooths each series over fewer samples at the window edge`() { + let days = (0..<4).map { Self.day.addingTimeInterval(Double($0) * 86400) } + let analysis = SpendDashboardModel.ModelAnalysis( + rows: [Self.row(id: "model-a", tokens: 100, cost: nil)], + dailyValues: zip(days, [10, 20, 30, 40]).map { day, tokens in + Self.dailyValue(modelID: "model-a", day: day, totalTokens: tokens) + }, + trackedTokenTotal: 100, + pricedCostTotal: nil, + sourceCount: 1, + tokenCoverage: .complete, + costCoverage: .partial) + + let smoothed = SpendModelsPresentation(analysis: analysis, metric: .tokens).applyingTrailingAverage() + + #expect(SpendModelsPresentation.trailingAverageWindow == 7) + #expect(smoothed.points.map(\.day) == days) + #expect(smoothed.points.map(\.value) == [10, 15, 20, 25]) + #expect(smoothed.points.map(\.stackStart) == [0, 0, 0, 0]) + #expect(smoothed.points.map(\.stackEnd) == [10, 15, 20, 25]) + // Ranking stays raw: only the chart points are smoothed. + #expect(smoothed.rows.map(\.value) == [100]) + #expect(smoothed.series.map(\.value) == [100]) + } + + @Test + func `trailing average treats days without series data as zero samples`() { + let days = (0..<4).map { Self.day.addingTimeInterval(Double($0) * 86400) } + let analysis = SpendDashboardModel.ModelAnalysis( + rows: [ + Self.row(id: "model-a", tokens: 70, cost: nil), + Self.row(id: "model-b", tokens: 40, cost: nil), + ], + dailyValues: [ + Self.dailyValue(modelID: "model-a", day: days[0], totalTokens: 10), + Self.dailyValue(modelID: "model-a", day: days[1], totalTokens: 20), + Self.dailyValue(modelID: "model-a", day: days[3], totalTokens: 40), + Self.dailyValue(modelID: "model-b", day: days[0], totalTokens: 4), + Self.dailyValue(modelID: "model-b", day: days[1], totalTokens: 8), + Self.dailyValue(modelID: "model-b", day: days[2], totalTokens: 12), + Self.dailyValue(modelID: "model-b", day: days[3], totalTokens: 16), + ], + trackedTokenTotal: 110, + pricedCostTotal: nil, + sourceCount: 1, + tokenCoverage: .complete, + costCoverage: .partial) + + let smoothed = SpendModelsPresentation(analysis: analysis, metric: .tokens) + .applyingTrailingAverage(window: 2) + + // model-a: [10, 15, 10, 20]; model-b: [4, 6, 10, 14]. Series stack in ranking order. + #expect(smoothed.points.map(\.seriesID) == [ + "model-a", "model-b", + "model-a", "model-b", + "model-a", "model-b", + "model-a", "model-b", + ]) + #expect(smoothed.points.map(\.value) == [10, 4, 15, 6, 10, 10, 20, 14]) + #expect(smoothed.points.map(\.stackStart) == [0, 10, 0, 15, 0, 10, 0, 20]) + #expect(smoothed.points.map(\.stackEnd) == [10, 14, 15, 21, 10, 20, 20, 34]) + } + + @Test + func `day detail aggregates buckets and sorts models by tokens`() throws { + let analysis = SpendDashboardModel.ModelAnalysis( + rows: [ + Self.row( + id: "model-a", + tokens: 130, + cost: 1.5, + providers: ["Codex"], + costIsEstimated: true), + Self.row(id: "model-b", tokens: 50, cost: 0.5, providers: ["Kimi"]), + ], + dailyValues: [ + Self.dailyValue( + modelID: "model-a", + day: Self.day, + totalTokens: 100, + inputTokens: 80, + outputTokens: 20, + cost: 1.5, + cacheReadTokens: 15, + cacheCreationTokens: 5, + reasoningTokens: 4), + Self.dailyValue( + modelID: "model-b", + day: Self.day, + totalTokens: 50, + inputTokens: 30, + outputTokens: 20, + cost: 0.5), + ], + trackedTokenTotal: 180, + pricedCostTotal: 2, + sourceCount: 2, + tokenCoverage: .complete, + costCoverage: .complete) + + let detail = try #require(SpendModelsDayDetailPresentation( + analysis: analysis, + day: Self.day, + metric: .estimatedSpend)) + + #expect(detail.totalTokens == 150) + #expect(detail.totalCost == 2) + #expect(detail.buckets.map(\.kind) == [.input, .output, .cacheRead, .cacheWrite, .reasoning]) + #expect(detail.buckets.map(\.tokens) == [110, 40, 15, 5, 4]) + + #expect(detail.models.map(\.id) == ["model-a", "model-b"]) + let first = try #require(detail.models.first) + #expect(first.providerNames == ["Codex"]) + #expect(first.costIsEstimated) + #expect(first.buckets.map(\.kind) == [.input, .output, .cacheRead, .cacheWrite, .reasoning]) + #expect(first.buckets.map(\.tokens) == [80, 20, 15, 5, 4]) + #expect(detail.models.last?.buckets.map(\.kind) == [.input, .output]) + } + + @Test + func `day detail hides the category bar when no bucket data exists`() throws { + let analysis = SpendDashboardModel.ModelAnalysis( + rows: [Self.row(id: "model-a", tokens: 50, cost: nil)], + dailyValues: [ + Self.dailyValue(modelID: "model-a", day: Self.day, totalTokens: 50), + ], + trackedTokenTotal: 50, + pricedCostTotal: nil, + sourceCount: 1, + tokenCoverage: .complete, + costCoverage: .partial) + + let detail = try #require(SpendModelsDayDetailPresentation( + analysis: analysis, + day: Self.day, + metric: .tokens)) + + #expect(detail.buckets.isEmpty) + let model = try #require(detail.models.first) + #expect(model.buckets.isEmpty) + #expect(spendModelsDayDetailModelSplitText(model) == "50") + } + + @Test + func `day detail is nil outside the charted range and matches inside`() { + let analysis = SpendDashboardModel.ModelAnalysis( + rows: [Self.row(id: "model-a", tokens: 10, cost: nil)], + dailyValues: [ + Self.dailyValue(modelID: "model-a", day: Self.day, totalTokens: 10), + ], + trackedTokenTotal: 10, + pricedCostTotal: nil, + sourceCount: 1, + tokenCoverage: .complete, + costCoverage: .partial) + let presentation = SpendModelsPresentation(analysis: analysis, metric: .tokens) + let outside = Self.day.addingTimeInterval(10 * 86400) + + #expect(presentation.day(matching: Self.day) == Self.day) + #expect(presentation.day(matching: outside) == nil) + #expect(SpendModelsDayDetailPresentation(analysis: analysis, day: outside, metric: .tokens) == nil) + } + + @Test + func `day detail bucket text localizes the cache read label`() { + let bucket = SpendModelsDayDetailPresentation.Bucket(kind: .cacheRead, tokens: 15) + + CodexBarLocalizationOverride.$appLanguage.withValue("zh-Hans") { + #expect(spendModelsDayDetailBucketText(bucket) == "15 缓存读取") + } + } + private static func row( id: String, tokens: Int?, inputTokens: Int? = nil, outputTokens: Int? = nil, cost: Double?, - providers: [String] = []) -> SpendDashboardModel.ModelAnalysisRow + providers: [String] = [], + cacheReadTokens: Int? = nil, + cacheCreationTokens: Int? = nil, + reasoningTokens: Int? = nil, + costIsEstimated: Bool = false) -> SpendDashboardModel.ModelAnalysisRow { SpendDashboardModel.ModelAnalysisRow( id: id, @@ -183,7 +410,45 @@ struct SpendModelsPresentationTests { totalTokens: tokens, inputTokens: inputTokens, outputTokens: outputTokens, - estimatedCost: cost) + estimatedCost: cost, + cacheReadTokens: cacheReadTokens, + cacheCreationTokens: cacheCreationTokens, + reasoningTokens: reasoningTokens, + costIsEstimated: costIsEstimated) + } + + private static func dailyValue( + modelID: String, + day: Date, + totalTokens: Int?, + inputTokens: Int? = nil, + outputTokens: Int? = nil, + cost: Double? = nil, + cacheReadTokens: Int? = nil, + cacheCreationTokens: Int? = nil, + reasoningTokens: Int? = nil) -> SpendDashboardModel.ModelDailyValue + { + SpendDashboardModel.ModelDailyValue( + modelID: modelID, + modelName: modelID, + day: day, + totalTokens: totalTokens, + inputTokens: inputTokens, + outputTokens: outputTokens, + estimatedCost: cost, + cacheReadTokens: cacheReadTokens, + cacheCreationTokens: cacheCreationTokens, + reasoningTokens: reasoningTokens) + } + + private static func rankingRows(count: Int) -> [SpendModelsPresentation.Row] { + (1...count).map { index in + SpendModelsPresentation.Row( + source: Self.row(id: "model-\(index)", tokens: index, cost: nil), + rank: index, + value: Double(index), + share: nil) + } } private static let day = Date(timeIntervalSince1970: 1_784_179_200) From 3d1b64f16f1e66c06d4d44dfa5c57e705f9cd190 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Mon, 27 Jul 2026 00:36:38 +0800 Subject: [PATCH 03/14] feat: refine usage and spend attribution --- .../PreferencesSpendDashboardPane.swift | 198 +++++- .../CodexBar/PreferencesSpendModelsView.swift | 78 +-- .../Resources/ar.lproj/Localizable.strings | 3 + .../Resources/ca.lproj/Localizable.strings | 3 + .../Resources/de.lproj/Localizable.strings | 3 + .../Resources/en.lproj/Localizable.strings | 5 +- .../Resources/es.lproj/Localizable.strings | 3 + .../Resources/fa.lproj/Localizable.strings | 3 + .../Resources/fr.lproj/Localizable.strings | 3 + .../Resources/gl.lproj/Localizable.strings | 3 + .../Resources/id.lproj/Localizable.strings | 3 + .../Resources/it.lproj/Localizable.strings | 3 + .../Resources/ja.lproj/Localizable.strings | 3 + .../Resources/ko.lproj/Localizable.strings | 3 + .../Resources/nl.lproj/Localizable.strings | 3 + .../Resources/pl.lproj/Localizable.strings | 3 + .../Resources/pt-BR.lproj/Localizable.strings | 3 + .../Resources/ru.lproj/Localizable.strings | 3 + .../Resources/sv.lproj/Localizable.strings | 3 + .../Resources/th.lproj/Localizable.strings | 3 + .../Resources/tr.lproj/Localizable.strings | 3 + .../Resources/uk.lproj/Localizable.strings | 3 + .../Resources/vi.lproj/Localizable.strings | 3 + .../zh-Hans.lproj/Localizable.strings | 5 +- .../zh-Hant.lproj/Localizable.strings | 5 +- Sources/CodexBar/ShareStatsPayload.swift | 7 +- Sources/CodexBar/SpendActivityHeatmap.swift | 174 +++++- .../CodexBar/SpendBillingAttribution.swift | 313 ++++++++++ Sources/CodexBar/SpendClientsView.swift | 83 ++- .../CodexBar/SpendDashboardController.swift | 87 ++- Sources/CodexBar/SpendDashboardModel.swift | 64 +- Sources/CodexBar/SpendModelIdentity.swift | 25 + Sources/CodexBar/SpendProviderIdentity.swift | 87 +++ Sources/CodexBar/SpendSubscriptionPlan.swift | 45 ++ .../Generated/CodexParserHash.generated.swift | 2 +- .../AntigravitySessionScanner.swift | 590 ++++++++++++++++++ .../Kimi/KimiCodeSessionScanner.swift | 89 ++- .../MiniMax/MiniMaxSessionScanner.swift | 59 +- .../CostUsagePricing+ThirdParty.swift | 41 ++ .../AntigravitySessionScannerTests.swift | 241 +++++++ .../KimiCodeSessionScannerTests.swift | 7 +- .../MiniMaxSessionScannerTests.swift | 91 +++ .../CodexBarTests/ModelsDevPricingTests.swift | 23 + Tests/CodexBarTests/ShareStatsTests.swift | 16 +- .../SpendBillingAttributionTests.swift | 272 ++++++++ .../SpendDashboardDateTruthTests.swift | 10 +- ...ndDashboardLocalHistoryRecoveryTests.swift | 117 ++++ .../SpendDashboardModelTests.swift | 59 +- .../SpendModelIdentityTests.swift | 20 + 49 files changed, 2731 insertions(+), 142 deletions(-) create mode 100644 Sources/CodexBar/SpendBillingAttribution.swift create mode 100644 Sources/CodexBar/SpendProviderIdentity.swift create mode 100644 Sources/CodexBar/SpendSubscriptionPlan.swift create mode 100644 Sources/CodexBarCore/Providers/Antigravity/AntigravitySessionScanner.swift create mode 100644 Tests/CodexBarTests/AntigravitySessionScannerTests.swift create mode 100644 Tests/CodexBarTests/MiniMaxSessionScannerTests.swift create mode 100644 Tests/CodexBarTests/SpendBillingAttributionTests.swift create mode 100644 Tests/CodexBarTests/SpendDashboardLocalHistoryRecoveryTests.swift diff --git a/Sources/CodexBar/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift index 8ee5264084..ff6bc043b7 100644 --- a/Sources/CodexBar/PreferencesSpendDashboardPane.swift +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -25,7 +25,8 @@ func spendDashboardRefreshFailureText(_ count: Int) -> String { } func spendDashboardCoverageText(covered: Int, requested: Int) -> String { - "\(L("Coverage")): \(codexBarLocalizedInteger(covered)) / \(codexBarLocalizedInteger(requested))" + "\(L("Coverage")): \(spendDashboardDayRangeText(covered)) · " + + "\(L("Time range")): \(spendDashboardDayRangeText(requested))" } enum SpendDashboardModelHistoryPresentation: Equatable { @@ -151,10 +152,12 @@ struct SpendDashboardPane: View { } } else { let modelHostGroupID = self.controller.model.groups.first?.id + let subscriptionNames = self.dashboardSubscriptionNames ForEach(self.controller.model.groups) { group in SpendCurrencySection( group: group, requestedDays: self.controller.model.requestedDays, + subscriptionNames: subscriptionNames, modelAnalysis: group.id == modelHostGroupID ? self.controller.model.modelAnalysis(for: self.selectedModelDays) : nil, @@ -168,14 +171,26 @@ struct SpendDashboardPane: View { } if self.controller.failedSourceCount > 0 { - Label( - spendDashboardRefreshFailureText(self.controller.failedSourceCount), - systemImage: "exclamationmark.triangle.fill") - .font(.caption) - .foregroundStyle(.secondary) + SpendRefreshFailureNotice( + sourceNames: self.failedSourceNames, + refresh: { self.controller.refresh() }) } } + private var failedSourceNames: [String] { + self.controller.failedSourceIDs.map { sourceID in + if let codexName = self.configuration.codexAccountDisplayNames[sourceID] { + return codexName + } + let providerID = sourceID.split(separator: ":", maxSplits: 1).first.map(String.init) ?? sourceID + if let provider = UsageProvider(rawValue: providerID) { + return self.store.metadata(for: provider).displayName + } + return sourceID + } + .sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } + } + private var provenance: some View { HStack(alignment: .top, spacing: 10) { Image(systemName: "lock.shield.fill") @@ -206,10 +221,39 @@ struct SpendDashboardPane: View { private var sharePayload: ShareStatsPayload? { ShareStatsBuilder.make( model: self.controller.model, - subscriptionNames: self.subscriptionNames) + subscriptionNames: self.shareSubscriptionNames) } - private var subscriptionNames: [String: ShareStatsSubscriptionName] { + private var dashboardSubscriptionNames: [String: String] { + var names: [String: String] = [:] + let codexRowCount = self.controller.model.groups + .flatMap(\.providers) + .count { $0.provider == .codex } + for group in self.controller.model.groups { + for row in group.providers { + let snapshots: [UsageSnapshot?] = if row.provider == .codex, + row.id.hasPrefix("codex:") + { + [ + self.store.codexAccountSnapshots.first { + row.id == "codex:\($0.id)" + }?.snapshot, + codexRowCount == 1 ? self.store.snapshot(for: .codex) : nil, + ] + } else { + [self.store.snapshot(for: row.provider)] + } + if let name = snapshots.lazy.compactMap({ + SpendSubscriptionPlan.from(snapshot: $0, provider: row.provider) + }).first { + names[row.id] = name.displayName + } + } + } + return names + } + + private var shareSubscriptionNames: [String: ShareStatsSubscriptionName] { var names: [String: ShareStatsSubscriptionName] = [:] let codexRowCount = self.controller.model.groups .flatMap(\.providers) @@ -246,6 +290,7 @@ struct SpendDashboardPane: View { private struct SpendCurrencySection: View { let group: SpendDashboardModel.CurrencyGroup let requestedDays: Int + let subscriptionNames: [String: String] let modelAnalysis: SpendDashboardModel.ModelAnalysis? let modelChartDomain: ClosedRange? let activityAnalysis: SpendDashboardModel.ModelAnalysis? @@ -288,7 +333,7 @@ private struct SpendCurrencySection: View { } } - SpendProviderPanel(group: self.group) + SpendProviderPanel(group: self.group, subscriptionNames: self.subscriptionNames) if let modelAnalysis { SpendModelsSection( analysis: modelAnalysis, @@ -323,6 +368,7 @@ private struct SpendSummaryValue: View { private struct SpendProviderPanel: View { let group: SpendDashboardModel.CurrencyGroup + let subscriptionNames: [String: String] var body: some View { SpendDashboardPanel { @@ -338,7 +384,18 @@ private struct SpendProviderPanel: View { .foregroundStyle(.tertiary) .frame(width: 26, alignment: .leading) SpendProviderIcon(provider: row.provider) - Text(row.displayName).lineLimit(1) + VStack(alignment: .leading, spacing: 2) { + Text(row.displayName) + .lineLimit(1) + if let subscriptionName = row.subscriptionName + ?? self.subscriptionNames[row.id] + { + Text(subscriptionName) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } Spacer() Text(row.totalCost.map { UsageFormatter.currencyString($0, currencyCode: self.group.currencyCode) @@ -392,8 +449,20 @@ private struct SpendDailyChart: View { dailyPoints: self.group.dailyPoints, aggregateTotal: self.group.totalCost) SpendDashboardPanel { - VStack(alignment: .leading, spacing: 12) { - Text(L("Daily estimated spend")).font(.headline) + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 3) { + Text(L("Daily estimated spend")).font(.headline) + if presentation.content == .chart { + Text( + "\(L("Active")) \(codexBarLocalizedInteger(presentation.dayCount)) · " + + "\(L("Total")) \(self.totalCostText)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + Spacer() + } if presentation.content == .unavailable { ContentUnavailableView(L("Spend unavailable"), systemImage: "chart.bar.xaxis") .frame(maxWidth: .infinity, minHeight: 170) @@ -403,21 +472,35 @@ private struct SpendDailyChart: View { x: .value(L("Day"), point.day, unit: .day), yStart: .value(L("Estimated spend"), point.stackStart), yEnd: .value(L("Estimated spend"), point.stackEnd), - width: .ratio(0.72)) + width: .ratio(0.52)) .foregroundStyle(by: .value(L("Provider"), point.providerName)) .accessibilityLabel(Text(self.pointAccessibilityLabel(point))) .accessibilityValue(Text(UsageFormatter.currencyString( point.cost, currencyCode: self.group.currencyCode))) } - .chartXScale(domain: self.group.chartDomain) + .chartXScale(domain: self.activeChartDomain) .chartForegroundStyleScale( domain: presentation.series.map(\.name), range: presentation.series.map { self.providerColor($0.provider) }) - .chartLegend(position: .bottom, alignment: .leading, spacing: 8) + .chartLegend(.hidden) + .chartXAxis { + AxisMarks(values: .automatic(desiredCount: 6)) { value in + AxisGridLine() + .foregroundStyle(Color.secondary.opacity(0.08)) + AxisTick() + .foregroundStyle(Color.secondary.opacity(0.35)) + AxisValueLabel { + if let date = value.as(Date.self) { + Text(date.formatted(self.axisDateFormat)) + } + } + } + } .chartYAxis { AxisMarks(position: .leading) { value in AxisGridLine() + .foregroundStyle(Color.secondary.opacity(0.16)) AxisValueLabel { if let amount = value.as(Double.self) { Text(UsageFormatter.compactCurrencyString( @@ -427,14 +510,61 @@ private struct SpendDailyChart: View { } } } - .frame(height: 170) + .chartPlotStyle { plotArea in + plotArea + .background(Color.primary.opacity(0.018)) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + .frame(height: 188) .accessibilityLabel(L("Daily estimated spend")) .accessibilityValue(presentation.accessibilityValue) + + LazyVGrid( + columns: [GridItem(.adaptive(minimum: 118), spacing: 12, alignment: .leading)], + alignment: .leading, + spacing: 7) + { + ForEach(presentation.series, id: \.name) { series in + HStack(spacing: 7) { + SpendProviderIcon(provider: series.provider, size: 13) + Text(series.name) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + } } } } } + private var totalCostText: String { + UsageFormatter.currencyString( + self.group.dailyPoints.reduce(0) { $0 + $1.cost }, + currencyCode: self.group.currencyCode) + } + + private var activeChartDomain: ClosedRange { + guard let firstDay = self.group.dailyPoints.map(\.day).min(), + let lastDay = self.group.dailyPoints.map(\.day).max() + else { return self.group.chartDomain } + let calendar = Calendar.current + let paddedStart = calendar.date(byAdding: .day, value: -1, to: firstDay) ?? firstDay + let paddedEnd = calendar.date(byAdding: .day, value: 2, to: lastDay) ?? lastDay + let start = max(self.group.chartDomain.lowerBound, paddedStart) + let end = min(self.group.chartDomain.upperBound, max(paddedEnd, start)) + return start...end + } + + private var axisDateFormat: Date.FormatStyle { + let interval = self.activeChartDomain.upperBound.timeIntervalSince(self.activeChartDomain.lowerBound) + if interval <= 90 * 24 * 60 * 60 { + return .dateTime.month(.abbreviated).day().locale(codexBarLocalizedLocale()) + } + return .dateTime.year().month(.abbreviated).locale(codexBarLocalizedLocale()) + } + private func pointAccessibilityLabel(_ point: SpendDashboardModel.DailyPoint) -> String { let day = point.day.formatted( .dateTime.month(.abbreviated).day().locale(codexBarLocalizedLocale())) @@ -447,8 +577,40 @@ private struct SpendDailyChart: View { } } -private struct SpendProviderIcon: View { +private struct SpendRefreshFailureNotice: View { + let sourceNames: [String] + let refresh: () -> Void + + var body: some View { + HStack(spacing: 10) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + VStack(alignment: .leading, spacing: 2) { + Text(spendDashboardRefreshFailureText(self.sourceNames.count)) + .font(.caption.weight(.semibold)) + if !self.sourceNames.isEmpty { + Text(self.sourceNames.joined(separator: " · ")) + .font(.caption) + .foregroundStyle(.secondary) + } + } + Spacer() + Button(L("Refresh"), action: self.refresh) + .controlSize(.small) + } + .padding(.horizontal, 12) + .padding(.vertical, 9) + .background(.orange.opacity(0.08), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(.orange.opacity(0.18)) + } + } +} + +struct SpendProviderIcon: View { let provider: UsageProvider + var size: CGFloat = 20 var body: some View { Group { @@ -458,7 +620,7 @@ private struct SpendProviderIcon: View { Image(systemName: "circle.dotted") } } - .frame(width: 20, height: 20) + .frame(width: self.size, height: self.size) .accessibilityHidden(true) } } diff --git a/Sources/CodexBar/PreferencesSpendModelsView.swift b/Sources/CodexBar/PreferencesSpendModelsView.swift index c29360b7f0..40be4dae01 100644 --- a/Sources/CodexBar/PreferencesSpendModelsView.swift +++ b/Sources/CodexBar/PreferencesSpendModelsView.swift @@ -530,7 +530,7 @@ struct SpendModelsSection: View { x: .value(L("Day"), point.day, unit: .day), yStart: .value(self.chartPresentation.metric.title, point.stackStart), yEnd: .value(self.chartPresentation.metric.title, point.stackEnd), - width: .ratio(0.68)) + width: .ratio(0.56)) .foregroundStyle(by: .value(L("Models"), point.seriesName)) .accessibilityLabel(Text("\(point.seriesName), \(self.dayText(point.day))")) .accessibilityValue(Text(self.metricText(point.value))) @@ -556,9 +556,7 @@ struct SpendModelsSection: View { range: .plotDimension(startPadding: 10, endPadding: 30)) .chartForegroundStyleScale( domain: self.presentation.series.map(\.name), - range: self.presentation.series.indices.map { index in - self.color(for: index) - }) + range: self.presentation.series.map { self.modelColor(for: $0.id) }) .chartLegend(.hidden) .chartXAxis { AxisMarks(values: self.xAxisDates) { value in @@ -572,6 +570,8 @@ struct SpendModelsSection: View { } .chartYAxis { AxisMarks(position: .leading, values: .automatic(desiredCount: 4)) { value in + AxisGridLine() + .foregroundStyle(Color.secondary.opacity(0.10)) AxisValueLabel { if let amount = value.as(Double.self) { Text(self.axisMetricText(amount)) @@ -580,6 +580,11 @@ struct SpendModelsSection: View { } } } + .chartPlotStyle { plotArea in + plotArea + .background(Color.primary.opacity(0.018)) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + } .frame(height: 220) .accessibilityLabel(L("Models")) .accessibilityValue(self.chartAccessibilityValue) @@ -607,14 +612,22 @@ struct SpendModelsSection: View { private var rankingContent: some View { VStack(alignment: .leading, spacing: 8) { ForEach(SpendModelsRanking.visibleRows(self.presentation.rows, showsAll: self.showsAllModels)) { row in - HStack(spacing: 9) { - RoundedRectangle(cornerRadius: 2, style: .continuous) - .fill(self.color(for: row)) - .frame(width: 10, height: 10) - .accessibilityHidden(true) - Text(row.source.displayName) - .font(.body) - .lineLimit(1) + HStack(spacing: 10) { + ZStack { + Circle() + .fill(self.color(for: row).opacity(0.13)) + SpendProviderIcon(provider: row.source.modelProvider, size: 14) + } + .frame(width: 26, height: 26) + VStack(alignment: .leading, spacing: 1) { + Text(row.source.displayName) + .font(.body) + .lineLimit(1) + Text(SpendProviderIdentity.displayName(for: row.source.modelProvider)) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } Spacer() Text(self.rowDetail(row)) .font(.body) @@ -646,13 +659,22 @@ struct SpendModelsSection: View { private func rowDetail(_ row: SpendModelsPresentation.Row) -> String { guard self.presentation.metric == .tokens else { guard let value = row.value else { return "—" } - let providers = row.source.providerNames.joined(separator: " · ") var parts = [self.metricText(value)] - if !providers.isEmpty { parts.append(providers) } if row.source.costIsEstimated { parts.append(L("estimated")) } return parts.joined(separator: " · ") } - return spendModelsRowDetailText(row) + guard let value = row.value else { return "—" } + if row.source.inputTokens != nil, + row.source.outputTokens != nil, + let inputTokens = row.source.inputTokens, + let outputTokens = row.source.outputTokens + { + return L( + "%@ in · %@ out", + UsageFormatter.tokenCountString(inputTokens), + UsageFormatter.tokenCountString(outputTokens)) + } + return UsageFormatter.tokenCountString(Int(value.rounded())) } private func shareText(_ value: Double?) -> String { @@ -670,7 +692,7 @@ struct SpendModelsSection: View { ForEach(points) { point in HStack(spacing: 7) { Circle() - .fill(self.color(for: self.seriesIndex(point.seriesID))) + .fill(self.modelColor(for: point.seriesID)) .frame(width: 8, height: 8) Text(point.seriesName) Spacer(minLength: 12) @@ -732,32 +754,16 @@ struct SpendModelsSection: View { SpendModelsEnglishFormatter.dayText(day) } - private func color(for index: Int) -> Color { - let accentOpacities = [0.95, 0.76, 0.58, 0.42, 0.30] - if index < accentOpacities.count { - return Color.accentColor.opacity(accentOpacities[index]) - } - let neutralOpacities = [0.30, 0.40, 0.50, 0.60, 0.70] - return Color(nsColor: .secondaryLabelColor) - .opacity(neutralOpacities[(index - accentOpacities.count) % neutralOpacities.count]) - } - + /// Brand color of the model's vendor, independent of whichever harness recorded the usage. private func color(for row: SpendModelsPresentation.Row) -> Color { - guard let index = self.presentation.series.firstIndex(where: { $0.id == row.id }) else { - return Color(nsColor: .tertiaryLabelColor).opacity(0.55) - } - return self.color(for: index) + SpendProviderColor.color(for: row.source.modelProvider) } private func modelColor(for id: String) -> Color { - guard let index = self.presentation.series.firstIndex(where: { $0.id == id }) else { + guard let row = self.presentation.rows.first(where: { $0.id == id }) else { return Color(nsColor: .tertiaryLabelColor).opacity(0.55) } - return self.color(for: index) - } - - private func seriesIndex(_ id: String) -> Int { - self.presentation.series.firstIndex(where: { $0.id == id }) ?? 0 + return self.color(for: row) } private func updateSelectedDay(location: CGPoint?, proxy: ChartProxy, geo: GeometryProxy) { diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index 2b1a78020b..9384a5a896 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -1390,3 +1390,6 @@ "Fr" = "جم"; "Estimated" = "تقديري"; "No per-client model history" = "لا يوجد سجل نماذج لكل عميل"; + +"Cumulative %@ tokens as of %@" = "%@ رمزًا تراكميًا حتى %@"; +"%@ tokens in the week of %@" = "%@ رمزًا في أسبوع %@"; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index b095351bd1..2a89c1d356 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -1389,3 +1389,6 @@ "Fr" = "dv"; "Estimated" = "Estimat"; "No per-client model history" = "Sense historial de models per client"; + +"Cumulative %@ tokens as of %@" = "%@ tokens acumulats fins al %@"; +"%@ tokens in the week of %@" = "%@ tokens la setmana del %@"; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index 868dc294e3..5e973bd3cd 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -1387,3 +1387,6 @@ "Fr" = "Fr"; "Estimated" = "Geschätzt"; "No per-client model history" = "Keinen Modellverlauf pro Client"; + +"Cumulative %@ tokens as of %@" = "Kumulativ %@ Token bis %@"; +"%@ tokens in the week of %@" = "%@ Token in der Woche vom %@"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index cdd6afa12d..f8b56f67e5 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -1266,7 +1266,7 @@ "Spend unavailable" = "Spend unavailable"; "Model breakdown unavailable" = "Model breakdown unavailable"; "Local estimated history" = "Local estimated history"; -"Coverage" = "Coverage"; +"Coverage" = "Common complete coverage"; "Estimated spend" = "Estimated spend"; "Tracked tokens" = "Tracked tokens"; "Subscriptions" = "Subscriptions"; @@ -1392,3 +1392,6 @@ "Fr" = "Fr"; "Estimated" = "Estimated"; "No per-client model history" = "No per-client model history"; + +"Cumulative %@ tokens as of %@" = "Cumulative %@ tokens as of %@"; +"%@ tokens in the week of %@" = "%@ tokens in the week of %@"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 1310b56d97..4dceef36aa 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -1385,3 +1385,6 @@ "Fr" = "vi"; "Estimated" = "Estimado"; "No per-client model history" = "Sin historial de modelos por cliente"; + +"Cumulative %@ tokens as of %@" = "%@ tokens acumulados hasta el %@"; +"%@ tokens in the week of %@" = "%@ tokens en la semana del %@"; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index 55428b7961..44d6dc1628 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -1390,3 +1390,6 @@ "Fr" = "جم"; "Estimated" = "تخمینی"; "No per-client model history" = "بدون سابقه مدل برای هر کلاینت"; + +"Cumulative %@ tokens as of %@" = "%@ توکن تجمعی تا %@"; +"%@ tokens in the week of %@" = "%@ توکن در هفته %@"; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index 8369255654..8f84aaa2dc 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -1386,3 +1386,6 @@ "Fr" = "ve"; "Estimated" = "Estimé"; "No per-client model history" = "Aucun historique de modèle par client"; + +"Cumulative %@ tokens as of %@" = "%@ tokens cumulés au %@"; +"%@ tokens in the week of %@" = "%@ tokens sur la semaine du %@"; diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings index 40d3bbbc93..7883bdab5c 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -1386,3 +1386,6 @@ "Fr" = "ve"; "Estimated" = "Estimado"; "No per-client model history" = "Sen historial de modelos por cliente"; + +"Cumulative %@ tokens as of %@" = "%@ tokens acumulados ata %@"; +"%@ tokens in the week of %@" = "%@ tokens na semana do %@"; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index d128f51b33..5b26543c10 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -1390,3 +1390,6 @@ "Fr" = "Jum"; "Estimated" = "Perkiraan"; "No per-client model history" = "Tidak ada riwayat model per klien"; + +"Cumulative %@ tokens as of %@" = "Kumulatif %@ token hingga %@"; +"%@ tokens in the week of %@" = "%@ token dalam minggu %@"; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index 7141c4a2c6..895ada7916 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -1390,3 +1390,6 @@ "Fr" = "ve"; "Estimated" = "Stimato"; "No per-client model history" = "Nessuna cronologia modelli per client"; + +"Cumulative %@ tokens as of %@" = "%@ token cumulativi al %@"; +"%@ tokens in the week of %@" = "%@ token nella settimana del %@"; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index 0435b993d6..146f0aaf79 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -1387,3 +1387,6 @@ "Fr" = "金"; "Estimated" = "推定"; "No per-client model history" = "クライアント別のモデル履歴なし"; + +"Cumulative %@ tokens as of %@" = "%2$@ までの累計 %1$@ トークン"; +"%@ tokens in the week of %@" = "%2$@ の週に %1$@ トークン"; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index 7de240d82d..7f631ddcab 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -1354,3 +1354,6 @@ "Fr" = "금"; "Estimated" = "추정"; "No per-client model history" = "클이언트별 모델 기록 없음"; + +"Cumulative %@ tokens as of %@" = "%2$@ 기준 누적 %1$@ 토큰"; +"%@ tokens in the week of %@" = "%2$@ 주에 %1$@ 토큰"; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index ecda921645..1aeec7e912 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -1386,3 +1386,6 @@ "Fr" = "vr"; "Estimated" = "Geschat"; "No per-client model history" = "Geen modelgeschiedenis per client"; + +"Cumulative %@ tokens as of %@" = "Cumulatief %@ tokens tot %@"; +"%@ tokens in the week of %@" = "%@ tokens in de week van %@"; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index 20973a0c1b..b03911f7f2 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -1390,3 +1390,6 @@ "Fr" = "pt"; "Estimated" = "Szacunkowo"; "No per-client model history" = "Brak historii modeli na klienta"; + +"Cumulative %@ tokens as of %@" = "Łącznie %@ tokenów do %@"; +"%@ tokens in the week of %@" = "%@ tokenów w tygodniu od %@"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index 4a5b7cc88a..7b541ea1df 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -1387,3 +1387,6 @@ "Fr" = "sex"; "Estimated" = "Estimado"; "No per-client model history" = "Sem histórico de modelos por cliente"; + +"Cumulative %@ tokens as of %@" = "%@ tokens acumulados até %@"; +"%@ tokens in the week of %@" = "%@ tokens na semana de %@"; diff --git a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings index e829715503..3f6993085a 100644 --- a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings @@ -1388,3 +1388,6 @@ "Fr" = "пт"; "Estimated" = "Оценочно"; "No per-client model history" = "Нет истории моделей по клиентам"; + +"Cumulative %@ tokens as of %@" = "Всего %@ токенов на %@"; +"%@ tokens in the week of %@" = "%@ токенов за неделю от %@"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index 255f7e1a67..91124e64f5 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -1385,3 +1385,6 @@ "Fr" = "fr"; "Estimated" = "Uppskattat"; "No per-client model history" = "Ingen modellhistorik per klient"; + +"Cumulative %@ tokens as of %@" = "Kumulativt %@ token till och med %@"; +"%@ tokens in the week of %@" = "%@ token under veckan från %@"; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index bf1760cf12..4adbe95f5b 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -1390,3 +1390,6 @@ "Fr" = "ศ"; "Estimated" = "โดยประมาณ"; "No per-client model history" = "ไม่มีประวัติโมเดลต่อไคลเอนต์"; + +"Cumulative %@ tokens as of %@" = "%@ โทเทนสะสมถึง %@"; +"%@ tokens in the week of %@" = "%@ โทเทนในสัปดาห์ของ %@"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index d46544e2bf..ac3fdecf6f 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -1388,3 +1388,6 @@ "Fr" = "Cu"; "Estimated" = "Tahmini"; "No per-client model history" = "İstemci başına model geçmişi yok"; + +"Cumulative %@ tokens as of %@" = "%@ tarihine kadar toplam %@ token"; +"%@ tokens in the week of %@" = "%@ haftasında %@ token"; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index 082956ac86..e1cd0c4e20 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -1386,3 +1386,6 @@ "Fr" = "пт"; "Estimated" = "Оціночно"; "No per-client model history" = "Немає історії моделей за клієнтами"; + +"Cumulative %@ tokens as of %@" = "Усього %@ токенів на %@"; +"%@ tokens in the week of %@" = "%@ токенів за тиждень від %@"; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index 729e9acd2b..0b2bdddc02 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -1387,3 +1387,6 @@ "Fr" = "T6"; "Estimated" = "Ước tính"; "No per-client model history" = "Không có lịch sử mô hình theo ứng dụng"; + +"Cumulative %@ tokens as of %@" = "Tích lũy %@ token tính đến %@"; +"%@ tokens in the week of %@" = "%@ token trong tuần từ %@"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 022a584b0c..2d3acffc84 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -1237,7 +1237,7 @@ "Spend unavailable" = "支出数据不可用"; "Model breakdown unavailable" = "模型明细不可用"; "Local estimated history" = "本地估算历史"; -"Coverage" = "覆盖范围"; +"Coverage" = "共同完整覆盖"; "Estimated spend" = "估算支出"; "Tracked tokens" = "已跟踪 token"; "Subscriptions" = "订阅"; @@ -1363,3 +1363,6 @@ "Fr" = "五"; "Estimated" = "估算"; "No per-client model history" = "暂无按工具划分的模型历史"; + +"Cumulative %@ tokens as of %@" = "截至 %2$@ 累计 %1$@ 个 Token"; +"%@ tokens in the week of %@" = "%2$@ 当周使用了 %1$@ 个 Token"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index 0bd07b5385..c232b70aec 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -1292,7 +1292,7 @@ "Spend unavailable" = "無法取得支出資料"; "Model breakdown unavailable" = "無法取得模型明細"; "Local estimated history" = "本機預估歷史"; -"Coverage" = "涵蓋範圍"; +"Coverage" = "共同完整涵蓋"; "Estimated spend" = "預估支出"; "Tracked tokens" = "已追蹤 token"; "Subscriptions" = "訂閱"; @@ -1418,3 +1418,6 @@ "Fr" = "五"; "Estimated" = "估算"; "No per-client model history" = "暫無按工具劃分的模型歷史"; + +"Cumulative %@ tokens as of %@" = "截至 %2$@ 累計 %1$@ 個 Token"; +"%@ tokens in the week of %@" = "%2$@ 當週使用了 %1$@ 個 Token"; diff --git a/Sources/CodexBar/ShareStatsPayload.swift b/Sources/CodexBar/ShareStatsPayload.swift index 744f41ee17..f7d29d78ae 100644 --- a/Sources/CodexBar/ShareStatsPayload.swift +++ b/Sources/CodexBar/ShareStatsPayload.swift @@ -115,8 +115,10 @@ struct ShareStatsSubscriptionName: Sendable, Equatable { private static let labelsByProvider: [String: [String: String]] = [ UsageProvider.codex.rawValue: [ - "guest": "Guest", "free": "Free", "go": "Go", "plus": "Plus", "plus plan": "Plus", - "chatgpt plus": "Plus", "chatgpt-plus": "Plus", "chatgpt_plus": "Plus", + "guest": "Guest", "free": "Free", "go": "Go", + "plus": "ChatGPT Plus", "plus plan": "ChatGPT Plus", + "chatgpt plus": "ChatGPT Plus", "chatgpt-plus": "ChatGPT Plus", + "chatgpt_plus": "ChatGPT Plus", "pro": "Pro 20x", "codex pro": "Pro 20x", "prolite": "Pro 5x", "pro_lite": "Pro 5x", "pro-lite": "Pro 5x", "pro lite": "Pro 5x", "codex pro lite": "Pro 5x", @@ -155,6 +157,7 @@ struct ShareStatsSubscriptionName: Sendable, Equatable { ], UsageProvider.antigravity.rawValue: [ "free": "Free", "paid": "Paid", "pro": "Pro", + "google ai pro": "Google AI Pro", "google one ai pro": "Google AI Pro", "ultra": "Google AI Ultra", "google ai ultra": "Google AI Ultra", ], UsageProvider.copilot.rawValue: [ diff --git a/Sources/CodexBar/SpendActivityHeatmap.swift b/Sources/CodexBar/SpendActivityHeatmap.swift index 7cb7133709..59bc86f4da 100644 --- a/Sources/CodexBar/SpendActivityHeatmap.swift +++ b/Sources/CodexBar/SpendActivityHeatmap.swift @@ -62,6 +62,19 @@ struct SpendActivitySeries { func date(at index: Int) -> Date? { self.calendar.date(byAdding: .day, value: index, to: self.start) } + + /// Naive start-of-day for the first day of week column `week` (0 = oldest week). + func weekStartDate(at week: Int) -> Date? { + self.calendar.date(byAdding: .day, value: week * Self.dayCount, to: self.start) + } + + /// Last day within week column `week` that is not in the future (for range tooltips). + func weekEndDate(at week: Int) -> Date? { + guard let startOfWeek = self.weekStartDate(at: week), + let endOfWeek = self.calendar.date(byAdding: .day, value: Self.dayCount - 1, to: startOfWeek) + else { return nil } + return min(endOfWeek, self.today) + } } enum SpendActivityLevels { @@ -91,18 +104,29 @@ enum SpendActivityLevels { return weekly.map { sum += $0; return sum } } - /// Color for a daily level, blending accent toward the background (Codex's alpha ramp). + /// GitHub contribution-graph greens (light mode), as a tribute. Level 0 is the empty track. static func color(forLevel level: Int) -> Color { - // Alphas mirror Codex: empty ~0.14 (dark) and a 4-step active ramp. - let accent = Color.accentColor switch level { - case 4: return accent - case 3: return accent.opacity(0.68) - case 2: return accent.opacity(0.42) - case 1: return accent.opacity(0.22) - default: return Color(nsColor: .separatorColor).opacity(0.16) + case 4: self.rgb(0x216E39) + case 3: self.rgb(0x30A14E) + case 2: self.rgb(0x40C463) + case 1: self.rgb(0x9BE9A8) + default: self.rgb(0xEBEDF0) } } + + /// Uniform fill for the weekly/cumulative columns: no per-level shading — the trend is read + /// from the filled height, so a single mid-green keeps it clean. Empty cells use level 0. + static var uniformFill: Color { + rgb(0x40C463) + } + + private static func rgb(_ hex: UInt32) -> Color { + Color( + red: Double((hex >> 16) & 0xFF) / 255, + green: Double((hex >> 8) & 0xFF) / 255, + blue: Double(hex & 0xFF) / 255) + } } // MARK: - 视图 @@ -155,12 +179,16 @@ struct SpendActivityHeatmapView: View { SpendActivityDailyGrid(series: series) self.dailyLegend case .weekly: - SpendActivityBarGrid(series: series, values: SpendActivityLevels.weeklyTotals(series.daily)) + SpendActivityWeekGrid( + series: series, + values: SpendActivityLevels.weeklyTotals(series.daily), + cumulative: false) self.barCaption(text: L("Each column = 1 week")) case .cumulative: - SpendActivityBarGrid( + SpendActivityWeekGrid( series: series, - values: SpendActivityLevels.cumulativeTotals(SpendActivityLevels.weeklyTotals(series.daily))) + values: SpendActivityLevels.cumulativeTotals(SpendActivityLevels.weeklyTotals(series.daily)), + cumulative: true) self.barCaption(text: L("Running total")) } } @@ -359,36 +387,120 @@ private struct SpendActivityDailyGrid: View { } } -// MARK: - 每周 / 累计 柱状网格 +// MARK: - 每周 / 累计 方格矩阵(7×52,每列一周,Codex 方块风格 + tooltip) -private struct SpendActivityBarGrid: View { +/// A full 7×52 grid like the daily view, but each *column* is one week: every cell in the column +/// shares that week's intensity, forming a colored column. Hovering a column shows a tooltip with +/// the week's total (or the running total up to that week, when `cumulative` is set). +private struct SpendActivityWeekGrid: View { let series: SpendActivitySeries let values: [Int] + let cumulative: Bool private let columns = SpendActivitySeries.weekCount + private let rows = SpendActivitySeries.dayCount + + @State private var hoveredColumn: Int? var body: some View { + // Fill each column bottom-up to a height proportional to its value, so weekly shows + // per-week magnitude and cumulative reads as a rising slope. Filled cells share one + // uniform green (no per-level shading — the height carries the trend); the rest render + // as the empty track. let maxValue = self.values.max() ?? 0 - VStack(alignment: .leading, spacing: 2) { - Canvas { context, size in - let pitch = size.width / CGFloat(self.values.count) - let barWidth = max(pitch * 0.6, 1.5) - for (index, value) in self.values.enumerated() { - guard value > 0, maxValue > 0 else { continue } - let height = size.height * CGFloat(value) / CGFloat(maxValue) - let rect = CGRect( - x: CGFloat(index) * pitch + (pitch - barWidth) / 2, - y: size.height - height, - width: barWidth, - height: height) - context.fill( - RoundedRectangle(cornerRadius: 1.5, style: .continuous).path(in: rect), - with: .color(Color.accentColor.opacity(0.78))) + HStack(alignment: .top, spacing: 4) { + Spacer().frame(width: 16) // align with the daily grid's weekday gutter + GeometryReader { geo in + let pitch = geo.size.width / CGFloat(self.columns) + let rowPitch = geo.size.height / CGFloat(self.rows) + let cell = max(min(pitch, rowPitch) - 1.5, 2) + Canvas { context, _ in + let corner = min(cell * 0.22, 2.5) + for col in 0.. 0 + ? Int((Double(value) / Double(maxValue) * Double(self.rows)).rounded()) + : 0 + for row in 0..= self.rows - filled + let rect = CGRect( + x: CGFloat(col) * pitch + (pitch - cell) / 2, + y: CGFloat(row) * rowPitch + (rowPitch - cell) / 2, + width: cell, + height: cell) + context.fill( + RoundedRectangle(cornerRadius: corner, style: .continuous).path(in: rect), + with: .color(isFilled + ? SpendActivityLevels.uniformFill + : SpendActivityLevels.color(forLevel: 0))) + } + } + } + .overlay(alignment: .topLeading) { + self.tooltip(in: geo.size, pitch: pitch) + } + .contentShape(Rectangle()) + .onContinuousHover { phase in + switch phase { + case let .active(location): + self.hoveredColumn = self.columnIndex(at: location, pitch: pitch) + case .ended: + self.hoveredColumn = nil + } } } - .frame(height: 90) - .frame(maxWidth: .infinity) + .aspectRatio(CGFloat(self.columns) / CGFloat(self.rows), contentMode: .fit) } - .padding(.leading, 20) } + + /// Hide week columns that have not started yet (entirely in the future). + private func isVisible(_ col: Int) -> Bool { + guard let weekStart = self.series.weekStartDate(at: col) else { return false } + return weekStart <= self.series.today + } + + private func columnIndex(at location: CGPoint, pitch: CGFloat) -> Int? { + let col = Int(location.x / pitch) + guard col >= 0, col < self.columns else { return nil } + return self.isVisible(col) ? col : nil + } + + @ViewBuilder + private func tooltip(in size: CGSize, pitch: CGFloat) -> some View { + if let col = self.hoveredColumn, + col < self.values.count, + let weekStart = self.series.weekStartDate(at: col) + { + let tokens = self.values[col] + let anchorX = CGFloat(col) * pitch + pitch / 2 + let flip = anchorX > size.width * 0.6 + Text(self.tooltipText(tokens: tokens, weekStart: weekStart)) + .font(.caption.weight(.medium)) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 6, style: .continuous)) + .fixedSize() + .offset(x: flip ? anchorX - 180 : anchorX + 6, y: -30) + .allowsHitTesting(false) + } + } + + private func tooltipText(tokens: Int, weekStart: Date) -> String { + let count = UsageFormatter.tokenCountString(tokens) + let date = Self.dayFormatter.string(from: weekStart) + if self.cumulative { + return String(format: L("Cumulative %@ tokens as of %@"), count, date) + } + return String(format: L("%@ tokens in the week of %@"), count, date) + } + + private static let dayFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .none + return formatter + }() } diff --git a/Sources/CodexBar/SpendBillingAttribution.swift b/Sources/CodexBar/SpendBillingAttribution.swift new file mode 100644 index 0000000000..c29c8db2e2 --- /dev/null +++ b/Sources/CodexBar/SpendBillingAttribution.swift @@ -0,0 +1,313 @@ +import CodexBarCore +import Foundation + +/// Billing-source attribution for the "By subscription" view. +/// +/// The dashboard's default grouping attributes spend to the *tool* that consumed it (Codex, +/// Claude Code, Cursor…). But a tool can act as a harness for another vendor's API: Claude Code +/// running on `ANTHROPIC_BASE_URL=https://api.kimi.com` is really spending Kimi credit, and Codex +/// driving `MiniMax-M3` through a third-party endpoint is really spending MiniMax credit. For the +/// subscription view the user wants to see *which vendor they actually paid*, not which UI happened +/// to issue the request. +/// +/// `SpendBillingAttribution` re-attributes each source's daily usage to the vendor that bills it: +/// +/// - **Cursor** keeps its bundled/default models (Claude, GPT…) under Cursor, while models reached +/// through a user-added Kimi/MiniMax/DeepSeek endpoint move to that endpoint's vendor. +/// - **Antigravity / Kimi / MiniMax CLI** keep everything under their own name. Antigravity's +/// Gemini and Claude models ship with the subscription, while the local vendor CLIs call only +/// their own plans. +/// - **Codex** is split per model: OpenAI models stay with Codex; third-party models driven through +/// a user-configured endpoint (`MiniMax-M3`, `deepseek-*`, `kimi-for-coding`) move to that vendor. +/// - **Claude Code** (used here as a free harness pointed at third-party endpoints) is split per +/// model: `claude-*` stays with Claude, everything else moves to its vendor. +/// +/// Attribution currently uses the model id because that is the common field all supported local +/// scanners retain. Only explicit third-party ids move; unknown/default model ids stay with the +/// harness so attribution never guesses. +enum SpendBillingAttribution { + /// Splits every input into one attributed input per billing vendor. Vendors are keyed by + /// `UsageProvider`, so downstream grouping/summing is unchanged — only the provider each entry + /// is attributed to differs. Sources that keep all their usage (Cursor, Antigravity, …) come + /// back as a single, unchanged input. + static func attribute( + _ inputs: [SpendDashboardModel.ProviderInput]) -> [SpendDashboardModel.ProviderInput] + { + let fragments = inputs.flatMap { input -> [Fragment] in + switch self.splitPolicy(for: input.provider) { + case .keepAll: + return [Fragment(input: input, routed: false)] + case .splitByModel: + return self.splitByVendor(input) + } + } + + // This is a provider/subscription ranking, not a tool ranking. Collapse every fragment + // billed by the same vendor into one row and always use the vendor brand as its title. + // Scanner labels such as "Kimi Code CLI" and "MiniMax Code" belong only in "By tool". + let byVendor = Dictionary(grouping: fragments, by: \.input.provider) + return byVendor.keys.sorted(by: { $0.rawValue < $1.rawValue }).map { vendor in + let vendorFragments = byVendor[vendor, default: []] + let inputs = vendorFragments.map(\.input) + let displayName = Self.vendorDisplayName(for: vendor) + if inputs.count == 1, let input = inputs.first { + return SpendDashboardModel.ProviderInput( + id: vendorFragments[0].routed ? "billing:\(vendor.rawValue)" : input.id, + provider: vendor, + displayName: displayName, + modelProviderName: displayName, + subscriptionName: input.subscriptionName, + snapshot: input.snapshot) + } + let native = vendorFragments.first { !$0.routed }?.input + return Self.mergedInput( + inputs, + id: native?.id ?? "billing:\(vendor.rawValue)", + provider: vendor, + displayName: displayName, + modelProviderName: displayName) + } + } + + // MARK: - Policy + + private enum SplitPolicy { + /// Everything the tool consumes is billed by the tool's own vendor (resold quota or bundled + /// models) — no split. + case keepAll + /// The tool can drive third-party APIs directly; attribute each model to its own vendor. + case splitByModel + } + + private static func splitPolicy(for provider: UsageProvider) -> SplitPolicy { + switch provider { + case .codex, .claude, .cursor: + // These harnesses can be pointed at third-party endpoints. Cursor's bundled Claude/GPT + // ids fall back to Cursor; only explicit external-vendor ids move. + .splitByModel + default: + // Antigravity bundles Claude; the local vendor CLIs (Kimi, MiniMax) only ever call + // their own models. + .keepAll + } + } + + // MARK: - Splitting + + private struct Fragment { + let input: SpendDashboardModel.ProviderInput + let routed: Bool + } + + private static func splitByVendor(_ input: SpendDashboardModel.ProviderInput) -> [Fragment] { + // Bucket each day's model breakdowns by billing vendor, then rebuild one attributed input + // per vendor with only that vendor's usage. Days where the source has no breakdowns fall + // back to the tool's own provider so the totals still add up. + let explicitVendors = Set(input.snapshot.daily.flatMap { entry in + (entry.modelBreakdowns ?? []).map { + self.billingVendor(forModel: $0.modelName, defaultProvider: input.provider) + } + }) + if explicitVendors.isEmpty || explicitVendors == [input.provider] { + // No third-party model is present. Preserve the scanner's original aggregate proofs, + // coverage bounds and malformed-row semantics instead of rebuilding an equivalent- + // looking snapshot from daily rows. + return [Fragment(input: input, routed: false)] + } + + var dailyByVendor: [UsageProvider: [CostUsageDailyReport.Entry]] = [:] + for entry in input.snapshot.daily { + let breakdowns = entry.modelBreakdowns ?? [] + if breakdowns.isEmpty { + dailyByVendor[input.provider, default: []].append(entry) + continue + } + for breakdown in breakdowns { + let vendor = self.billingVendor(forModel: breakdown.modelName, defaultProvider: input.provider) + let dayEntry = Self.entry(from: entry, keeping: breakdown) + dailyByVendor[vendor, default: []].append(dayEntry) + } + } + + // Merge same-day entries per vendor (a vendor may appear in several breakdowns of one day). + var attributed: [Fragment] = [] + for vendor in dailyByVendor.keys.sorted(by: { $0.rawValue < $1.rawValue }) { + let entries = dailyByVendor[vendor, default: []] + let merged = Self.mergeByDay(entries) + guard !merged.isEmpty else { continue } + let snapshot = Self.snapshot( + from: input.snapshot, + daily: merged, + historyLabel: Self.vendorDisplayName(for: vendor)) + attributed.append(Fragment( + input: SpendDashboardModel.ProviderInput( + id: vendor == input.provider + ? input.id + : "\(input.id)|vendor:\(vendor.rawValue)", + provider: vendor, + displayName: Self.vendorDisplayName(for: vendor), + modelProviderName: Self.vendorDisplayName(for: vendor), + subscriptionName: vendor == input.provider ? input.subscriptionName : nil, + snapshot: snapshot), + routed: vendor != input.provider)) + } + return attributed.isEmpty ? [Fragment(input: input, routed: false)] : attributed + } + + private static func mergedInput( + _ inputs: [SpendDashboardModel.ProviderInput], + id: String, + provider: UsageProvider, + displayName: String, + modelProviderName: String) -> SpendDashboardModel.ProviderInput + { + guard let first = inputs.first else { + preconditionFailure("Cannot merge an empty billing attribution input") + } + // A provider can publish both a live quota snapshot and a local session-history snapshot. + // Live quota snapshots intentionally carry `historyCoverageIsEstablished == false`; mixing + // one into complete local history used to downgrade the whole subscription to + // "Spend unavailable" (notably MiniMax). Once at least one source establishes historical + // coverage, only those historical sources participate in the spend/history merge. + let establishedHistoryInputs = inputs.filter(\.snapshot.historyCoverageIsEstablished) + let historyInputs = establishedHistoryInputs.isEmpty ? inputs : establishedHistoryInputs + if historyInputs.count == 1, let input = historyInputs.first { + return SpendDashboardModel.ProviderInput( + id: input.id, + provider: provider, + displayName: displayName, + modelProviderName: modelProviderName, + subscriptionName: inputs.compactMap(\.subscriptionName).first, + snapshot: input.snapshot) + } + + let daily = Self.mergeByDay(historyInputs.flatMap(\.snapshot.daily)) + let historyDays = historyInputs.map(\.snapshot.historyDays).min() ?? first.snapshot.historyDays + let updatedAt = historyInputs.map(\.snapshot.updatedAt).min() ?? first.snapshot.updatedAt + let snapshot = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: Self.sum(daily.map(\.totalTokens)), + last30DaysCostUSD: Self.sumCost(daily.map(\.costUSD)), + last30DaysRequests: Self.sum(daily.map(\.requestCount)), + currencyCode: historyInputs.first?.snapshot.currencyCode ?? first.snapshot.currencyCode, + historyDays: historyDays, + historyCoverageIsEstablished: historyInputs.allSatisfy(\.snapshot.historyCoverageIsEstablished), + historyLabel: displayName, + meteredCostUSD: nil, + costSource: historyInputs.allSatisfy { $0.snapshot.costSource == .providerReported } + ? .providerReported + : .estimated, + credentialScopeFingerprint: nil, + daily: daily, + updatedAt: updatedAt) + return SpendDashboardModel.ProviderInput( + id: id, + provider: provider, + displayName: displayName, + modelProviderName: modelProviderName, + subscriptionName: inputs.compactMap(\.subscriptionName).first, + snapshot: snapshot) + } + + /// Rebuilds a daily entry restricted to a single model breakdown, recomputing the aggregate + /// token/cost figures from that breakdown so per-vendor day totals stay consistent. + private static func entry( + from entry: CostUsageDailyReport.Entry, + keeping breakdown: CostUsageDailyReport.ModelBreakdown) -> CostUsageDailyReport.Entry + { + CostUsageDailyReport.Entry( + date: entry.date, + inputTokens: breakdown.inputTokens, + outputTokens: breakdown.outputTokens, + cacheReadTokens: breakdown.cacheReadTokens, + cacheCreationTokens: breakdown.cacheCreationTokens, + totalTokens: breakdown.totalTokens, + requestCount: breakdown.requestCount, + costUSD: breakdown.costUSD, + modelsUsed: [breakdown.modelName], + modelBreakdowns: [breakdown]) + } + + private static func mergeByDay(_ entries: [CostUsageDailyReport.Entry]) -> [CostUsageDailyReport.Entry] { + let grouped = Dictionary(grouping: entries, by: \.date) + return grouped.keys.sorted().map { day in + let dayEntries = grouped[day] ?? [] + if dayEntries.count == 1, let only = dayEntries.first { return only } + return CostUsageDailyReport.Entry( + date: day, + inputTokens: Self.sum(dayEntries.map(\.inputTokens)), + outputTokens: Self.sum(dayEntries.map(\.outputTokens)), + cacheReadTokens: Self.sum(dayEntries.map(\.cacheReadTokens)), + cacheCreationTokens: Self.sum(dayEntries.map(\.cacheCreationTokens)), + totalTokens: Self.sum(dayEntries.map(\.totalTokens)), + requestCount: Self.sum(dayEntries.map(\.requestCount)), + costUSD: Self.sumCost(dayEntries.map(\.costUSD)), + modelsUsed: dayEntries.flatMap { $0.modelsUsed ?? [] }, + modelBreakdowns: dayEntries.flatMap { $0.modelBreakdowns ?? [] }) + } + } + + private static func snapshot( + from snapshot: CostUsageTokenSnapshot, + daily: [CostUsageDailyReport.Entry], + historyLabel: String) -> CostUsageTokenSnapshot + { + let totalTokens = Self.sum(daily.map(\.totalTokens)) + let totalCost = Self.sumCost(daily.map(\.costUSD)) + let totalRequests = Self.sum(daily.map(\.requestCount)) + return CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: totalTokens, + last30DaysCostUSD: totalCost, + last30DaysRequests: totalRequests, + currencyCode: snapshot.currencyCode, + historyDays: snapshot.historyDays, + historyCoverageIsEstablished: snapshot.historyCoverageIsEstablished, + historyLabel: historyLabel, + meteredCostUSD: nil, + costSource: snapshot.costSource, + credentialScopeFingerprint: snapshot.credentialScopeFingerprint, + daily: daily, + updatedAt: snapshot.updatedAt) + } + + // MARK: - Vendor mapping + + /// Maps a model id to the vendor that bills it. `defaultProvider` is used when the model name + /// carries no third-party signal (e.g. an OpenAI model inside Codex, a `claude-*` model inside + /// Claude Code) — those stay with the tool that consumed them. + static func billingVendor(forModel model: String, defaultProvider: UsageProvider) -> UsageProvider { + let name = model.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !name.isEmpty, name != "" else { return defaultProvider } + + // Only Codex / Claude Code ever carry third-party models; for any other tool the model is + // billed by the tool itself, so short-circuit. + guard defaultProvider == .codex || defaultProvider == .claude || defaultProvider == .cursor + else { return defaultProvider } + + if name.hasPrefix("minimax") { return .minimax } + if name.hasPrefix("deepseek") { return .deepseek } + if name.hasPrefix("kimi") || name == "k3" || name.hasPrefix("k3-") { return .kimi } + return defaultProvider + } + + private static func vendorDisplayName(for provider: UsageProvider) -> String { + ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName + } + + // MARK: - Numeric helpers + + private static func sum(_ values: [Int?]) -> Int? { + let present = values.compactMap(\.self) + guard !present.isEmpty else { return nil } + return present.reduce(0, +) + } + + private static func sumCost(_ values: [Double?]) -> Double? { + let present = values.compactMap(\.self) + guard !present.isEmpty else { return nil } + return present.reduce(0, +) + } +} diff --git a/Sources/CodexBar/SpendClientsView.swift b/Sources/CodexBar/SpendClientsView.swift index 0eadc0e699..e5786d3668 100644 --- a/Sources/CodexBar/SpendClientsView.swift +++ b/Sources/CodexBar/SpendClientsView.swift @@ -3,6 +3,14 @@ import SwiftUI // MARK: - 按工具分组数据 +/// Official per-provider brand color (matches the "Daily estimated spend" chart legend). +enum SpendProviderColor { + static func color(for provider: UsageProvider) -> Color { + let rgb = ProviderDescriptorRegistry.descriptor(for: provider).branding.color + return Color(red: rgb.red, green: rgb.green, blue: rgb.blue) + } +} + /// A model's usage attributed to one tool (client), with the five-bucket token breakdown /// taken from the parent model row (buckets are tracked per model, so the per-client split /// shares them proportionally by that client's token contribution). @@ -21,30 +29,77 @@ struct SpendClientModel: Identifiable, Equatable { /// One tool (client) with its models, sorted by tokens descending. struct SpendClientGroup: Identifiable, Equatable { + let sourceID: String let provider: UsageProvider - let name: String + /// Tool name, e.g. "Claude Code", "Codex Desktop", "Kimi Code CLI". + let toolName: String + /// Product family name, e.g. "Claude", "Codex", "Kimi". + let providerName: String let totalTokens: Int let totalCost: Double? let costIsEstimated: Bool let models: [SpendClientModel] var id: String { - self.provider.rawValue + self.sourceID + } + + /// "Tool · Family" when they differ meaningfully, else just the tool name. + var displayTitle: String { + let tool = self.toolName.trimmingCharacters(in: .whitespacesAndNewlines) + let family = self.providerName.trimmingCharacters(in: .whitespacesAndNewlines) + if family.isEmpty || tool.localizedCaseInsensitiveContains(family) { + return tool + } + return "\(tool) · \(family)" } } enum SpendClientBreakdown { - /// Groups model rows by contributing tool. A model used by several tools appears under each, + /// The local tool that produced a provider's usage logs. Providers whose data is read from a + /// CLI/desktop app's local files surface under that tool's name; providers with a more specific + /// `sourceName` (e.g. a Codex account name, or the explicit "… CLI" names set at load time) + /// keep it untouched. + private static func toolName(provider: UsageProvider, sourceName: String, providerName: String) -> String { + // Only remap when the source name is just the product family (no specific tool identity). + guard sourceName.trimmingCharacters(in: .whitespacesAndNewlines) + .localizedCaseInsensitiveCompare(providerName.trimmingCharacters(in: .whitespacesAndNewlines)) + == .orderedSame + else { return sourceName } + switch provider { + case .claude: return "Claude Code" + case .codex: return "Codex Desktop" + case .kimi: return "Kimi Desktop" + case .gemini: return "Gemini CLI" + case .opencode, .opencodego: return "OpenCode" + case .minimax: return "MiniMax Code" + case .cursor: return "Cursor" + case .copilot: return "GitHub Copilot" + case .antigravity: return "Antigravity" + default: return sourceName + } + } + + /// Groups model rows by contributing tool (one card per tool/account, e.g. each Codex + /// account, Claude Code, Kimi Code CLI). A model used by several tools appears under each, /// with that tool's token/cost share (from `contributions`); the five-bucket breakdown is the /// model's own, shown for context under each tool it ran in. static func groups(from analysis: SpendDashboardModel.ModelAnalysis) -> [SpendClientGroup] { - var byProvider: [UsageProvider: (name: String, models: [String: Accum])] = [:] + var bySource: [String: (provider: UsageProvider, tool: String, family: String, models: [String: Accum])] = [:] for row in analysis.rows { for contribution in row.contributions { let tokens = contribution.totalTokens ?? 0 guard tokens > 0 || contribution.estimatedCost != nil else { continue } - var bucket = byProvider[contribution.provider] ?? (contribution.providerName, [:]) + var bucket = bySource[contribution.sourceID] + ?? ( + contribution.provider, + Self.toolName( + provider: contribution.provider, + sourceName: contribution.sourceName, + providerName: contribution.providerName), + contribution.providerName, + [:]) var accum = bucket.models[row.id] ?? Accum( displayName: row.displayName, costIsEstimated: row.costIsEstimated) @@ -58,11 +113,11 @@ enum SpendClientBreakdown { accum.cacheCreationTokens = row.cacheCreationTokens accum.reasoningTokens = row.reasoningTokens bucket.models[row.id] = accum - byProvider[contribution.provider] = bucket + bySource[contribution.sourceID] = bucket } } - return byProvider.map { provider, bucket in + return bySource.map { sourceID, bucket in let models = bucket.models.map { id, accum in SpendClientModel( id: id, @@ -83,8 +138,10 @@ enum SpendClientBreakdown { return (partial ?? 0) + cost } return SpendClientGroup( - provider: provider, - name: bucket.name, + sourceID: sourceID, + provider: bucket.provider, + toolName: bucket.tool, + providerName: bucket.family, totalTokens: totalTokens, totalCost: totalCost, costIsEstimated: models.contains { $0.costIsEstimated }, @@ -136,7 +193,7 @@ struct SpendClientsView: View { .frame(width: 16, height: 16) .accessibilityHidden(true) } - Text(group.name) + Text(group.displayTitle) .font(.body.weight(.semibold)) if group.costIsEstimated { Text(L("Estimated")) @@ -156,14 +213,14 @@ struct SpendClientsView: View { ForEach(Array(group.models.enumerated()), id: \.element.id) { index, model in if index > 0 { Divider().padding(.vertical, 2) } - self.modelRow(model, groupTokens: group.totalTokens) + self.modelRow(model, groupTokens: group.totalTokens, provider: group.provider) } } .padding(12) .background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) } - private func modelRow(_ model: SpendClientModel, groupTokens: Int) -> some View { + private func modelRow(_ model: SpendClientModel, groupTokens: Int, provider: UsageProvider) -> some View { VStack(alignment: .leading, spacing: 3) { HStack(alignment: .firstTextBaseline, spacing: 8) { Text(model.displayName) @@ -185,7 +242,7 @@ struct SpendClientsView: View { GeometryReader { geo in let share = CGFloat(model.tokens) / CGFloat(groupTokens) RoundedRectangle(cornerRadius: 2, style: .continuous) - .fill(Color.accentColor.opacity(0.75)) + .fill(SpendProviderColor.color(for: provider).opacity(0.85)) .frame(width: max(geo.size.width * share, 2), height: 3) } .frame(height: 3) diff --git a/Sources/CodexBar/SpendDashboardController.swift b/Sources/CodexBar/SpendDashboardController.swift index 4c269bc643..527c81960a 100644 --- a/Sources/CodexBar/SpendDashboardController.swift +++ b/Sources/CodexBar/SpendDashboardController.swift @@ -66,6 +66,7 @@ struct SpendDashboardLoadRequest: Sendable { let geminiCLIHomePath: String? let openCodeDataHomePath: String? let miniMaxHomePath: String? + let antigravityHomePath: String? let now: Date let force: Bool @@ -79,6 +80,7 @@ struct SpendDashboardLoadRequest: Sendable { geminiCLIHomePath: String? = nil, openCodeDataHomePath: String? = nil, miniMaxHomePath: String? = nil, + antigravityHomePath: String? = nil, now: Date, force: Bool) { @@ -91,6 +93,7 @@ struct SpendDashboardLoadRequest: Sendable { self.geminiCLIHomePath = geminiCLIHomePath self.openCodeDataHomePath = openCodeDataHomePath self.miniMaxHomePath = miniMaxHomePath + self.antigravityHomePath = antigravityHomePath self.now = now self.force = force } @@ -150,6 +153,12 @@ struct MiniMaxSpendSnapshotLoadContext: Sendable { let historyDays: Int } +struct AntigravitySpendSnapshotLoadContext: Sendable { + let homePath: String + let now: Date + let historyDays: Int +} + enum SpendDashboardSource { typealias CodexSnapshotLoader = @Sendable (CodexSpendSnapshotLoadContext) async throws -> CostUsageTokenSnapshot @@ -161,6 +170,8 @@ enum SpendDashboardSource { -> CostUsageTokenSnapshot? typealias MiniMaxSnapshotLoader = @Sendable (MiniMaxSpendSnapshotLoadContext) async throws -> CostUsageTokenSnapshot? + typealias AntigravitySnapshotLoader = @Sendable (AntigravitySpendSnapshotLoadContext) async throws + -> CostUsageTokenSnapshot? static let scanDays = 365 @@ -278,6 +289,9 @@ enum SpendDashboardSource { inputs.append(SpendDashboardModel.ProviderInput( provider: provider, displayName: store.metadata(for: provider).displayName, + subscriptionName: SpendSubscriptionPlan + .from(snapshot: store.snapshot(for: provider), provider: provider)? + .displayName, snapshot: snapshot)) } return SpendDashboardLoadRequest( @@ -298,6 +312,9 @@ enum SpendDashboardSource { miniMaxHomePath: self.localModelHistoryProviders(store: store).contains(.minimax) ? Self.miniMaxHomeURL().path : nil, + antigravityHomePath: self.localModelHistoryProviders(store: store).contains(.antigravity) + ? Self.antigravityHomeURL().path + : nil, now: captureNow, force: mode.forcesLoader) } @@ -319,6 +336,9 @@ enum SpendDashboardSource { }, miniMaxSnapshotLoader: { context in try await self.loadMiniMaxSnapshot(context) + }, + antigravitySnapshotLoader: { context in + try await self.loadAntigravitySnapshot(context) }) } @@ -358,6 +378,9 @@ enum SpendDashboardSource { }, miniMaxSnapshotLoader: @escaping MiniMaxSnapshotLoader = { context in try await Self.loadMiniMaxSnapshot(context) + }, + antigravitySnapshotLoader: @escaping AntigravitySnapshotLoader = { context in + try await Self.loadAntigravitySnapshot(context) }) async -> SpendDashboardLoadResult { var inputs = request.capturedInputs @@ -393,6 +416,7 @@ enum SpendDashboardSource { provider: .codex, displayName: account.displayName, modelProviderName: ProviderDescriptorRegistry.descriptor(for: .codex).metadata.displayName, + subscriptionName: nil, snapshot: snapshot)) } catch is CancellationError { failedSourceIDs.formUnion(request.codexRequests.map { "codex:\($0.id)" }) @@ -441,11 +465,24 @@ enum SpendDashboardSource { try await miniMaxSnapshotLoader(MiniMaxSpendSnapshotLoadContext( homePath: homePath, now: request.now, historyDays: Self.scanDays)) }), + LocalSnapshotSource( + homePath: request.antigravityHomePath, + sourceID: "antigravity:local", + provider: .antigravity, + displayName: "Antigravity", + load: { homePath in + try await antigravitySnapshotLoader(AntigravitySpendSnapshotLoadContext( + homePath: homePath, now: request.now, historyDays: Self.scanDays)) + }), ] for source in localSources { do { if let input = try await source.loadInput() { inputs.append(input) + // The spend dashboard's canonical history for these providers is the local + // scanner. A failed/unchanged live quota publication must not remain as a + // refresh warning after the corresponding local history loaded successfully. + failedSourceIDs.remove(source.provider.rawValue) } } catch is CancellationError { failedSourceIDs.insert(source.sourceID) @@ -541,6 +578,20 @@ enum SpendDashboardSource { }.value } + private static func loadAntigravitySnapshot( + _ context: AntigravitySpendSnapshotLoadContext) async throws -> CostUsageTokenSnapshot? + { + try await Task.detached(priority: .utility) { + try Task.checkCancellation() + let snapshot = AntigravitySessionScanner.scan( + environment: [AntigravitySessionScanner.homeEnvironmentKey: context.homePath], + historyDays: context.historyDays, + now: context.now) + try Task.checkCancellation() + return snapshot + }.value + } + /// Main-actor capture of the Gemini CLI home, mirroring `KimiSettingsReader.kimiCodeHomeURL` /// (the scanner itself appends `tmp` to whatever `GEMINI_CLI_HOME` resolves to). private static func geminiCLIHomeURL( @@ -587,6 +638,22 @@ enum SpendDashboardSource { .appendingPathComponent(".minimax", isDirectory: true) } + /// Main-actor capture of the Antigravity home feeding `AntigravitySessionScanner` (the scanner + /// itself appends `conversations` to whatever `ANTIGRAVITY_HOME` resolves to). + private static func antigravityHomeURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + if let override = environment[AntigravitySessionScanner.homeEnvironmentKey]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !override.isEmpty + { + return URL(fileURLWithPath: override, isDirectory: true) + } + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".gemini", isDirectory: true) + .appendingPathComponent("antigravity", isDirectory: true) + } + @MainActor static func costCapableProviders(store: UsageStore) -> [UsageProvider] { store.enabledProvidersForDisplay().filter { @@ -597,7 +664,7 @@ enum SpendDashboardSource { @MainActor static func localModelHistoryProviders(store: UsageStore) -> [UsageProvider] { store.enabledProvidersForDisplay().filter { - $0 == .kimi || $0 == .gemini || $0 == .opencode || $0 == .minimax + $0 == .kimi || $0 == .gemini || $0 == .opencode || $0 == .minimax || $0 == .antigravity } } @@ -917,6 +984,7 @@ final class SpendDashboardController { private(set) var model = SpendDashboardModel(requestedDays: 30, groups: []) private(set) var isRefreshing = false private(set) var failedSourceCount = 0 + private(set) var failedSourceIDs: Set = [] private(set) var generation: UInt64 = 0 private(set) var configuration: SpendDashboardConfiguration? private(set) var selectedDays: Int @@ -984,12 +1052,14 @@ final class SpendDashboardController { if !invalidatedSourceIDs.isEmpty { self.loadedInputs.removeAll { invalidatedSourceIDs.contains($0.id) } self.failedSourceCount = 0 + self.failedSourceIDs = [] self.rebuildModel() } guard configuration.costUsageEnabled, !configuration.providerIDs.isEmpty else { self.loadedInputs = [] self.failedSourceCount = 0 + self.failedSourceIDs = [] self.isRefreshing = false self.lastSuccessfulConfiguration = configuration self.phase = .ordinary @@ -1152,6 +1222,7 @@ final class SpendDashboardController { self.loadedAt = request.now self.lastSuccessfulConfiguration = request.configuration self.failedSourceCount = result.failedSourceCount + self.failedSourceIDs = result.failedSourceIDs self.isRefreshing = false self.phase = .ordinary self.loadTask = nil @@ -1185,7 +1256,9 @@ final class SpendDashboardController { !forceFailed.contains(input.id) && !invalidated.contains(input.id) && !outcome.confirmedEmptySourceIDs.contains(input.id) && - (forcedCodexIDs.contains(input.id) || barrierFailed.contains(input.id)) + (forcedCodexIDs.contains(input.id) || + barrierFailed.contains(input.id) || + Self.isLocalHistorySource(input.id)) { inputs.append(input) capturedIDs.insert(input.id) @@ -1198,6 +1271,15 @@ final class SpendDashboardController { confirmedEmptySourceIDs: outcome.confirmedEmptySourceIDs) } + private static func isLocalHistorySource(_ sourceID: String) -> Bool { + switch sourceID { + case "kimi:local", "gemini:local", "opencode:local", "minimax:local", "antigravity:local": + true + default: + false + } + } + func refresh() { guard let configuration else { return } self.update(configuration: configuration, force: true) @@ -1260,6 +1342,7 @@ final class SpendDashboardController { provider: input.provider, displayName: displayName, modelProviderName: input.modelProviderName, + subscriptionName: input.subscriptionName, snapshot: input.snapshot) } diff --git a/Sources/CodexBar/SpendDashboardModel.swift b/Sources/CodexBar/SpendDashboardModel.swift index 065a91b800..199bf583b0 100644 --- a/Sources/CodexBar/SpendDashboardModel.swift +++ b/Sources/CodexBar/SpendDashboardModel.swift @@ -7,6 +7,7 @@ struct SpendDashboardModel: Equatable, Sendable { let provider: UsageProvider let displayName: String let modelProviderName: String + let subscriptionName: String? let snapshot: CostUsageTokenSnapshot /// Origin of this source's cost figures (provider-billed vs locally estimated). @@ -19,12 +20,14 @@ struct SpendDashboardModel: Equatable, Sendable { provider: UsageProvider, displayName: String, modelProviderName: String? = nil, + subscriptionName: String? = nil, snapshot: CostUsageTokenSnapshot) { self.id = id ?? provider.rawValue self.provider = provider self.displayName = displayName self.modelProviderName = modelProviderName ?? displayName + self.subscriptionName = subscriptionName self.snapshot = snapshot } } @@ -34,6 +37,7 @@ struct SpendDashboardModel: Equatable, Sendable { let rank: Int let provider: UsageProvider let displayName: String + var subscriptionName: String? let totalTokens: Int? let totalCost: Double? let coveredDayCount: Int @@ -94,6 +98,7 @@ struct SpendDashboardModel: Equatable, Sendable { struct ModelAnalysisRow: Identifiable, Equatable, Sendable { let id: String let displayName: String + let modelProvider: UsageProvider let rawModelNames: [String] let providers: [UsageProvider] let providerNames: [String] @@ -114,6 +119,7 @@ struct SpendDashboardModel: Equatable, Sendable { init( id: String, displayName: String, + modelProvider: UsageProvider? = nil, rawModelNames: [String], providers: [UsageProvider], providerNames: [String], @@ -129,6 +135,9 @@ struct SpendDashboardModel: Equatable, Sendable { { self.id = id self.displayName = displayName + self.modelProvider = modelProvider ?? SpendProviderIdentity.modelProvider( + rawNames: rawModelNames, + fallbackProviders: providers) self.rawModelNames = rawModelNames self.providers = providers self.providerNames = providerNames @@ -479,7 +488,15 @@ extension SpendDashboardModel { let summaries = inputs.map { input in Self.inputSummary(input: input, bounds: bounds, calendar: calendar) } - let providers = Self.providerRows(summaries) + // "By subscription" shows which vendor the user actually paid, so re-attribute each source's + // usage to its billing vendor (Codex driving MiniMax, Claude Code as a third-party harness, + // …) before ranking. The per-model breakdown, stacked chart and analysis keep the original + // per-tool attribution. + let billingInputs = SpendBillingAttribution.attribute(inputs) + let billingSummaries = billingInputs.map { input in + Self.inputSummary(input: input, bounds: bounds, calendar: calendar) + } + let providers = Self.providerRows(billingSummaries) let completeModelSummaries = summaries.filter { summary in guard summary.totalCost != nil else { return false } return Self.modelSummary(summaries: [summary]).completeness == .complete @@ -489,15 +506,21 @@ extension SpendDashboardModel { let modelHistoryCompleteness = completeModelSummaries.count == summaries.count ? ModelHistoryCompleteness.complete : ModelHistoryCompleteness.incomplete - let dailyPoints = Self.dailyPoints(summaries: summaries) + // The spend chart answers the same billing question as "By subscription". Its series must + // therefore be the vendor that owns the quota, not the harness that emitted the log. + let dailyPoints = Self.dailyPoints(summaries: billingSummaries) return CurrencyGroup( currencyCode: currencyCode, providers: providers, models: modelSummary.rows, modelAnalysis: modelAnalysis, dailyPoints: dailyPoints, - totalTokens: Self.completeIntSum(providers.map(\.totalTokens)), - totalCost: Self.completeCostSum(providers.map(\.totalCost)), + // "Tracked tokens" is the subtotal we actually parsed, not a completeness assertion. + // A source without token detail must not erase known tokens from every other source. + // This mirrors Tokscale's aggregation: parsed token buckets always sum independently + // of whether every model/source can also be priced. + totalTokens: Self.availableIntSum(providers.map(\.totalTokens)), + totalCost: Self.availableCostSum(providers.map(\.totalCost)), coveredDayCount: Self.commonCoverageDayCount(summaries: summaries, calendar: calendar), chartDomain: Self.chartDomain(bounds: bounds, calendar: calendar), modelHistoryCompleteness: modelHistoryCompleteness) @@ -542,15 +565,22 @@ extension SpendDashboardModel { let invalidCostHistory = hasInvalidCostHistory || !costAggregateIsConsistent // Daily-sum fallback: when the provider's aggregate cost figure uses a different window or // accounting than the local per-day logs (so the strict consistency check fails), fall back - // to summing the per-day costs instead of voiding the whole provider. This keeps the spend - // figure available as long as every in-range day was individually priceable. + // to summing the per-day costs instead of voiding the whole provider. + // + // The fallback sums only the days that were individually priceable. A day whose events all + // lack a cost figure (e.g. Cursor usage events that omit `totalCents`) yields a nil + // `entry.costUSD`; that single unpriceable day must not void the spend total for the whole + // window — the priceable days still carry a meaningful subtotal, matching how the model + // breakdown ("By tool") already aggregates them. The incomplete coverage is still surfaced + // via `hasInvalidCostHistory` / `modelHistoryCompleteness`. let dailyCostSum = Self.completeCostSum(entries.map { Self.validCost($0.entry.costUSD) }) + let availableDailyCostSum = Self.availableCostSum(entries.map { Self.validCost($0.entry.costUSD) }) let totalCost: Double? = if !invalidCostHistory { entries.isEmpty ? (coveredDayCount > 0 && hasCompleteCostHistory ? 0 : nil) : dailyCostSum - } else if !hasInvalidCostHistory, dailyCostSum != nil { - dailyCostSum + } else if !hasInvalidCostHistory { + availableDailyCostSum } else { nil } @@ -581,6 +611,7 @@ extension SpendDashboardModel { rank: rank + 1, provider: entry.element.input.provider, displayName: entry.element.input.displayName, + subscriptionName: entry.element.input.subscriptionName, totalTokens: entry.element.totalTokens, totalCost: entry.element.totalCost, coveredDayCount: entry.element.coveredDayCount) @@ -1348,6 +1379,23 @@ extension SpendDashboardModel { return self.safeCostSum(values.compactMap(\.self)) } + /// Sums the providers that *have* a cost figure, ignoring those without one (e.g. a provider + /// with no local cost history). One cost-less provider must not void the whole currency's + /// spend total; it is still surfaced individually as "unavailable". Returns nil only when no + /// provider contributes a cost. + private static func availableCostSum(_ values: [Double?]) -> Double? { + let present = values.compactMap(\.self) + guard !present.isEmpty else { return nil } + return self.safeCostSum(present) + } + + /// Sums the token values that were actually observed. Missing source totals remain visible on + /// their individual rows, but do not turn the dashboard-wide "Tracked tokens" subtotal into + /// an em dash. + private static func availableIntSum(_ values: [Int?]) -> Int? { + self.safeIntSum(values.compactMap(\.self)) + } + private static func safeIntSum(_ values: [Int]) -> Int? { guard !values.isEmpty else { return nil } var result = 0 diff --git a/Sources/CodexBar/SpendModelIdentity.swift b/Sources/CodexBar/SpendModelIdentity.swift index b2b1ee38e8..0c61d2388d 100644 --- a/Sources/CodexBar/SpendModelIdentity.swift +++ b/Sources/CodexBar/SpendModelIdentity.swift @@ -185,12 +185,37 @@ struct SpendModelIdentity: Equatable, Sendable { private static let displayAliases: [String: String] = [ "k3": "Kimi K3", "kimi-k3": "Kimi K3", + "k3-256k": "Kimi K3 (256K)", + "kimi-k3-256k": "Kimi K3 (256K)", "k2.5": "Kimi K2.5", "kimi-k2.5": "Kimi K2.5", "k2": "Kimi K2", "kimi-k2": "Kimi K2", "kimi-for-coding": "Kimi for Coding", "kimi-for-coding-highspeed": "Kimi for Coding High-Speed", + // Antigravity's internal Gemini/Claude codenames → the public model names they route to. + "gemini-pro-default": "Gemini 3.1 Pro", + "gemini-pro-agent": "Gemini 3.1 Pro", + "gemini-3.1-pro": "Gemini 3.1 Pro", + "gemini-3.1-pro-high": "Gemini 3.1 Pro", + "gemini-3.1-pro-low": "Gemini 3.1 Pro", + "gemini-3-pro": "Gemini 3 Pro", + "gemini-3-pro-high": "Gemini 3 Pro", + "gemini-3-pro-low": "Gemini 3 Pro", + "gemini-3-flash": "Gemini 3 Flash", + "gemini-3-flash-c": "Gemini 3 Flash", + "gemini-default": "Gemini 3 Flash", + "gemini-3-flash-a": "Gemini 3.5 Flash (High)", + "gemini-3-flash-agent": "Gemini 3.5 Flash (High)", + "gemini-3-flash-b": "Gemini 3.5 Flash (High)", + "gemini-3.5-flash-high": "Gemini 3.5 Flash (High)", + "gemini-3.5-flash-low": "Gemini 3.5 Flash (Medium)", + "gemini-3.5-flash-medium": "Gemini 3.5 Flash (Medium)", + "gemini-3.5-flash-extra-low": "Gemini 3.5 Flash (Low)", + "gemini-3.6-flash": "Gemini 3.6 Flash", + "claude-opus-4-6-thinking": "Claude Opus 4.6", + "claude-sonnet-4-6-thinking": "Claude Sonnet 4.6", + "claude-haiku-4-6-thinking": "Claude Haiku 4.6", ] /// Parses an ASCII digit run of exactly `digits` characters, rejecting anything else. diff --git a/Sources/CodexBar/SpendProviderIdentity.swift b/Sources/CodexBar/SpendProviderIdentity.swift new file mode 100644 index 0000000000..cd80d13b7a --- /dev/null +++ b/Sources/CodexBar/SpendProviderIdentity.swift @@ -0,0 +1,87 @@ +import CodexBarCore +import Foundation + +/// Canonical vendor identity used by every model-centric spend surface. +/// +/// Tools are deliberately not vendors: Cursor can run Kimi, Antigravity can run Claude, and +/// Claude Code can be a harness for MiniMax. This resolver keeps that distinction in one place so +/// model icons, colors, labels, charts, and future exports cannot drift into separate heuristics. +enum SpendProviderIdentity { + static func modelProvider( + rawNames: some Sequence, + fallbackProviders: some Sequence) -> UsageProvider + { + for name in rawNames { + if let provider = self.explicitModelProvider(name) { + return provider + } + } + for provider in fallbackProviders { + return provider + } + return .openai + } + + static func modelProvider(rawName: String, fallback: UsageProvider) -> UsageProvider { + self.explicitModelProvider(rawName) ?? fallback + } + + static func displayName(for provider: UsageProvider) -> String { + ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName + } + + /// Explicit model-family detection. Generic provider ids/display names cover new providers + /// automatically; aliases handle families whose public model ids differ from product ids. + private static func explicitModelProvider(_ rawName: String) -> UsageProvider? { + let normalized = rawName + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + guard !normalized.isEmpty, normalized != "" else { return nil } + + let components = normalized + .split(whereSeparator: { $0 == "/" || $0 == ":" || $0 == "." || $0 == "-" || $0 == " " }) + .map(String.init) + let first = components.first ?? normalized + + let aliases: [(matches: Bool, provider: UsageProvider)] = [ + (normalized.contains("claude") || first == "anthropic", .claude), + (normalized.contains("gemini") || first == "google", .gemini), + (normalized.contains("minimax"), .minimax), + (normalized.contains("deepseek"), .deepseek), + ( + normalized.contains("kimi") || normalized.contains("moonshot") || + normalized == "k3" || normalized.hasPrefix("k3-"), + .kimi), + ( + normalized.contains("gpt") || normalized.hasPrefix("chatgpt") || + normalized.hasPrefix("codex-") || Self.isOpenAIReasoningFamily(normalized), + .openai), + (normalized.contains("grok") || first == "xai", .grok), + ( + normalized.contains("mistral") || normalized.contains("mixtral") || + normalized.contains("codestral") || normalized.contains("devstral"), + .mistral), + (normalized.contains("qwen"), .alibaba), + (normalized.contains("glm") || first == "zai", .zai), + ] + if let explicit = aliases.first(where: \.matches) { + return explicit.provider + } + + // The provider registry is the extensibility path: a newly added provider whose model id + // begins with either its canonical id or public display name needs no dashboard change. + return UsageProvider.allCases.first { provider in + let id = provider.rawValue.lowercased() + let display = self.displayName(for: provider).lowercased() + return first == id || normalized.hasPrefix("\(id)/") || + normalized.hasPrefix("\(id)-") || normalized.hasPrefix("\(display)/") || + normalized.hasPrefix("\(display)-") + } + } + + private static func isOpenAIReasoningFamily(_ name: String) -> Bool { + ["o1", "o3", "o4"].contains { family in + name == family || name.hasPrefix("\(family)-") + } + } +} diff --git a/Sources/CodexBar/SpendSubscriptionPlan.swift b/Sources/CodexBar/SpendSubscriptionPlan.swift new file mode 100644 index 0000000000..a343467b0c --- /dev/null +++ b/Sources/CodexBar/SpendSubscriptionPlan.swift @@ -0,0 +1,45 @@ +import CodexBarCore +import Foundation + +/// Reader-facing subscription label for the private local dashboard. +/// +/// Share cards intentionally use `ShareStatsSubscriptionName`'s closed allow-list. The local +/// dashboard can be more capable: it first uses that curated catalog, then safely humanizes a new +/// provider plan returned in `loginMethod`. This makes future provider tiers appear automatically +/// without confusing authentication methods or account identifiers for plan names. +struct SpendSubscriptionPlan: Equatable, Sendable { + let displayName: String + + static func from(snapshot: UsageSnapshot?, provider: UsageProvider) -> Self? { + if let curated = ShareStatsSubscriptionName.from(snapshot: snapshot, provider: provider) { + return Self(displayName: curated.displayName) + } + guard let identity = snapshot?.identity(for: provider), + let raw = identity.loginMethod?.trimmingCharacters(in: .whitespacesAndNewlines), + !raw.isEmpty, + !self.matchesAccountIdentity(raw, identity: identity), + !self.looksLikeAuthenticationMethod(raw) + else { return nil } + + let cleaned = UsageFormatter.cleanPlanName(raw) + return Self(displayName: cleaned.isEmpty ? raw : cleaned) + } + + private static func matchesAccountIdentity( + _ value: String, + identity: ProviderIdentitySnapshot) -> Bool + { + [identity.accountEmail, identity.accountOrganization] + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } + .contains { $0.localizedCaseInsensitiveCompare(value) == .orderedSame } + } + + private static func looksLikeAuthenticationMethod(_ value: String) -> Bool { + let normalized = value.lowercased() + if normalized.contains("@") || normalized.contains("://") { return true } + return [ + "api key", "apikey", "oauth", "browser cookie", "cookie", "access token", + "bearer token", "service account", "admin api", + ].contains(normalized) + } +} diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 86d3e2fea8..174abf52e9 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 = "9c63f4de93213b76" + static let value = "de433e82c0e0fa12" } diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravitySessionScanner.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravitySessionScanner.swift new file mode 100644 index 0000000000..e111677086 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravitySessionScanner.swift @@ -0,0 +1,590 @@ +import Foundation + +#if canImport(SQLite3) +import SQLite3 +#elseif canImport(CSQLite3) +import CSQLite3 +#endif + +// Reads Antigravity (Google) local assistant-turn token usage from the conversation databases +// (`~/.gemini/antigravity/conversations/*.db`) and folds it into a `CostUsageTokenSnapshot` for the +// Usage & Spend dashboard. Same output shape as `MiniMaxSessionScanner`. +// +// Each `gen_metadata` row is one generation encoded as a `GeneratorMetadata` protobuf (the same +// message the IDE returns over `GetCascadeTrajectoryGeneratorMetadata`). There is no shipped +// `.proto`, so — mirroring tokscale's antigravity-cli parser — this scanner ships a tiny +// dependency-free protobuf wire-format reader and pulls only the fields it needs: +// +// gen_metadata.#1 → chatModel message +// #19 (string) → responseModel (e.g. `gemini-3.6-flash`) +// #9.#4 = {#1 sec, #2 ns} → per-generation wall-clock Timestamp +// #4 → usage message +// #1 (varint, const) → fixed system-prompt tokens (≈1132), billable input +// #2 (varint) → newly-processed (non-cached) input tokens +// #5 (varint) → cacheRead tokens +// #9 (varint) → output (text) tokens +// #10 (varint) → thinking / reasoning tokens +// #11 (string) → responseId (dedup key) +// trajectory_metadata_blob.#2 = {#1 sec, #2 ns} → session-created fallback Timestamp +// +// `reasoning` is part of billing output, so it stays folded into `outputTokens` and is additionally +// surfaced as the `reasoningTokens` sub-bucket (never added on top). Costs are priced from the +// token buckets at the vendor's models.dev rate (Google provider), matching how Codex/Claude spend +// is estimated; when no official rate is known the day stays token-only (`costUSD: nil`). +#if canImport(SQLite3) || canImport(CSQLite3) +public enum AntigravitySessionScanner { + public static let defaultHistoryDays = 30 + + /// Environment override for the Antigravity conversations directory, resolved directly here so + /// this scanner stays self-contained (mirrors `MiniMaxSessionScanner.MINIMAX_HOME`). + public static let homeEnvironmentKey = "ANTIGRAVITY_HOME" + + /// models.dev providers that carry Gemini / Claude list pricing. Antigravity routes both + /// Google (Gemini) and Anthropic (Claude) models, so the pricing provider is picked per model. + private static let googleModelsDevProviderID = "google" + private static let anthropicModelsDevProviderID = "anthropic" + + /// Maps Antigravity's wire `responseModel` strings (placeholders, tier suffixes, aliases) to + /// the models.dev model id used for pricing, mirroring tokscale's antigravity alias table + /// (verified against the Antigravity Context Window Monitor models.ts). Only the pricing key is + /// rewritten — the raw model name stays in the breakdown for display. + private static let modelPricingAliases: [String: String] = [ + "gemini-pro-default": "gemini-3.1-pro-preview", + "gemini-pro-agent": "gemini-3.1-pro-preview", + "gemini-3.1-pro": "gemini-3.1-pro-preview", + "gemini-3.1-pro-high": "gemini-3.1-pro-preview", + "gemini-3.1-pro-low": "gemini-3.1-pro-preview", + "gemini-3-pro": "gemini-3-pro-preview", + "gemini-3-pro-high": "gemini-3-pro-preview", + "gemini-3-pro-low": "gemini-3-pro-preview", + "gemini-3-flash": "gemini-3-flash-preview", + "gemini-3-flash-c": "gemini-3-flash-preview", + "gemini-default": "gemini-3-flash-preview", + // The `gemini-3-flash-a`/`-agent`/`-b` family is the High tier of 3.5 Flash, not the + // unrelated gemini-3-flash-preview backend; models.dev prices it as `gemini-3.5-flash`. + "gemini-3-flash-a": "gemini-3.5-flash", + "gemini-3-flash-agent": "gemini-3.5-flash", + "gemini-3-flash-b": "gemini-3.5-flash", + "gemini-3.5-flash-high": "gemini-3.5-flash", + "claude-opus-4-6-thinking": "claude-opus-4-6", + "claude-sonnet-4-6-thinking": "claude-sonnet-4-6", + "claude-haiku-4-6-thinking": "claude-haiku-4-6", + ] + + private struct DayModelKey: Hashable { + let day: String + let model: String + } + + private struct TokenAccumulator { + var input = 0 + var cacheRead = 0 + var output = 0 + var reasoning = 0 + var requests = 0 + var cost = 0.0 + var sawCost = false + + mutating func add(_ row: UsageRow) -> Bool { + guard let nextInput = Self.adding(self.input, row.input), + let nextCacheRead = Self.adding(self.cacheRead, row.cacheRead), + let nextOutput = Self.adding(self.output, row.output), + let nextReasoning = Self.adding(self.reasoning, row.reasoning), + let nextRequests = Self.adding(self.requests, 1) + else { + return false + } + self.input = nextInput + self.cacheRead = nextCacheRead + self.output = nextOutput + self.reasoning = nextReasoning + self.requests = nextRequests + if let cost = row.costUSD { + self.cost += cost + self.sawCost = true + } + return true + } + + mutating func merge(_ other: TokenAccumulator) -> Bool { + guard let nextInput = Self.adding(self.input, other.input), + let nextCacheRead = Self.adding(self.cacheRead, other.cacheRead), + let nextOutput = Self.adding(self.output, other.output), + let nextReasoning = Self.adding(self.reasoning, other.reasoning), + let nextRequests = Self.adding(self.requests, other.requests) + else { + return false + } + self.input = nextInput + self.cacheRead = nextCacheRead + self.output = nextOutput + self.reasoning = nextReasoning + self.requests = nextRequests + if other.sawCost { + self.cost += other.cost + self.sawCost = true + } + return true + } + + var total: Int? { + guard let withCacheRead = Self.adding(self.input, self.cacheRead) else { return nil } + return Self.adding(withCacheRead, self.output) + } + + private static func adding(_ lhs: Int, _ rhs: Int) -> Int? { + let result = lhs.addingReportingOverflow(rhs) + return result.overflow ? nil : result.partialValue + } + } + + private struct UsageRow { + let model: String + let createdMs: Int64 + let input: Int + let cacheRead: Int + let output: Int + let reasoning: Int + let responseID: String? + let costUSD: Double? + } + + public static func scan( + environment: [String: String] = ProcessInfo.processInfo.environment, + historyDays: Int = defaultHistoryDays, + now: Date = Date(), + calendar: Calendar = .current, + modelsDevCacheRoot: URL? = nil) -> CostUsageTokenSnapshot? + { + let days = max(1, historyDays) + let conversationsURL = self.conversationsURL(environment: environment) + let databaseURLs = self.conversationDatabases(under: conversationsURL) + guard !databaseURLs.isEmpty else { return nil } + + let modelsDevCatalog = CostUsagePricing.modelsDevCatalog(now: now, cacheRoot: modelsDevCacheRoot) + + var rows: [UsageRow] = [] + var seenResponseIDs: Set = [] + for databaseURL in databaseURLs { + self.readRows( + databaseURL: databaseURL, + catalog: modelsDevCatalog, + cacheRoot: modelsDevCacheRoot, + into: &rows, + seenResponseIDs: &seenResponseIDs) + } + guard !rows.isEmpty else { return nil } + + let end = calendar.startOfDay(for: now) + let start = calendar.date(byAdding: .day, value: -(days - 1), to: end) ?? end + var values: [DayModelKey: TokenAccumulator] = [:] + + for row in rows { + let date = Date(timeIntervalSince1970: TimeInterval(row.createdMs) / 1000) + let day = calendar.startOfDay(for: date) + guard day >= start, day <= end else { continue } + let key = DayModelKey(day: CostUsageLocalDay.key(from: day, calendar: calendar), model: row.model) + var value = values[key] ?? TokenAccumulator() + guard value.add(row) else { continue } + values[key] = value + } + + guard !values.isEmpty else { return nil } + let byDay = Dictionary(grouping: values, by: \.key.day) + let daily = byDay.keys.sorted().compactMap { day -> CostUsageDailyReport.Entry? in + let models = (byDay[day] ?? []).sorted { lhs, rhs in + lhs.key.model.localizedCaseInsensitiveCompare(rhs.key.model) == .orderedAscending + } + var total = TokenAccumulator() + var modelBreakdowns: [CostUsageDailyReport.ModelBreakdown] = [] + var dayCost = 0.0 + var daySawCost = false + for (key, value) in models { + guard let modelTotal = value.total else { return nil } + guard total.merge(value) else { return nil } + modelBreakdowns.append(CostUsageDailyReport.ModelBreakdown( + modelName: key.model, + costUSD: value.sawCost ? value.cost : nil, + totalTokens: modelTotal, + inputTokens: value.input, + cacheReadTokens: value.cacheRead, + cacheCreationTokens: nil, + outputTokens: value.output, + reasoningTokens: value.reasoning > 0 ? value.reasoning : nil, + requestCount: value.requests)) + if value.sawCost { + dayCost += value.cost + daySawCost = true + } + } + guard let totalTokens = total.total else { return nil } + return CostUsageDailyReport.Entry( + date: day, + inputTokens: total.input, + outputTokens: total.output, + cacheReadTokens: total.cacheRead, + cacheCreationTokens: nil, + totalTokens: totalTokens, + requestCount: total.requests, + costUSD: daySawCost ? dayCost : nil, + modelsUsed: modelBreakdowns.map(\.modelName), + modelBreakdowns: modelBreakdowns) + } + let totalTokens = self.sum(daily.compactMap(\.totalTokens)) + let totalRequests = self.sum(daily.compactMap(\.requestCount)) + let totalCost = daily.compactMap(\.costUSD).reduce(0, +) + let sawCost = daily.contains { $0.costUSD != nil } + guard let totalTokens, let totalRequests else { return nil } + + return CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: totalTokens, + last30DaysCostUSD: sawCost ? totalCost : nil, + last30DaysRequests: totalRequests, + currencyCode: sawCost ? "USD" : "XXX", + historyDays: days, + historyCoverageIsEstablished: true, + historyLabel: "Antigravity", + costSource: .estimated, + daily: daily, + updatedAt: now) + } + + // MARK: - Paths + + public static func antigravityHomeURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + if let override = environment[self.homeEnvironmentKey]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !override.isEmpty + { + return URL(fileURLWithPath: override, isDirectory: true) + } + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".gemini", isDirectory: true) + .appendingPathComponent("antigravity", isDirectory: true) + } + + public static func conversationsURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + self.antigravityHomeURL(environment: environment) + .appendingPathComponent("conversations", isDirectory: true) + } + + private static func conversationDatabases(under directory: URL) -> [URL] { + guard let contents = try? FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles]) + else { + return [] + } + return contents + .filter { $0.pathExtension == "db" } + .sorted { $0.lastPathComponent < $1.lastPathComponent } + } + + // MARK: - SQLite + + private static func readRows( + databaseURL: URL, + catalog: ModelsDevCatalog?, + cacheRoot: URL?, + into rows: inout [UsageRow], + seenResponseIDs: inout Set) + { + var db: OpaquePointer? + // Read the main database file directly (immutable=1) to avoid WAL -shm/-wal setup on a + // read-only connection. + let uri = "file://\(databaseURL.path)?immutable=1" + guard sqlite3_open_v2(uri, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_URI, nil) == SQLITE_OK else { + sqlite3_close(db) + return + } + defer { sqlite3_close(db) } + sqlite3_busy_timeout(db, 250) + + guard self.hasTable(named: "gen_metadata", db: db) else { return } + let sessionTimestampMs = self.sessionCreatedMs(db: db) ?? self.fileModifiedMs(databaseURL) + + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, "SELECT data FROM gen_metadata ORDER BY idx", -1, &stmt, nil) == SQLITE_OK + else { return } + defer { sqlite3_finalize(stmt) } + + while true { + let step = sqlite3_step(stmt) + if step == SQLITE_DONE { break } + guard step == SQLITE_ROW else { return } + guard let blob = self.columnBlob(stmt, 0) else { continue } + if let row = self.parseGeneration( + blob, + sessionTimestampMs: sessionTimestampMs, + catalog: catalog, + cacheRoot: cacheRoot, + seenResponseIDs: &seenResponseIDs) + { + rows.append(row) + } + } + } + + private static func parseGeneration( + _ blob: [UInt8], + sessionTimestampMs: Int64, + catalog: ModelsDevCatalog?, + cacheRoot: URL?, + seenResponseIDs: inout Set) -> UsageRow? + { + guard let chatModel = WireReader.messageField(blob, 1), + let usage = WireReader.messageField(chatModel, 4) + else { + return nil + } + + // Per-generation wall-clock time; fall back to the session-created stamp. + let timestampMs = WireReader.messageField(chatModel, 9) + .flatMap { WireReader.messageField($0, 4) } + .flatMap { WireReader.timestampMs($0) } + .flatMap { $0 > 0 ? $0 : nil } + ?? sessionTimestampMs + + let input = Self.clampedInt(WireReader.varintField(usage, 1)) + + Self.clampedInt(WireReader.varintField(usage, 2)) + let cacheRead = Self.clampedInt(WireReader.varintField(usage, 5)) + let output = Self.clampedInt(WireReader.varintField(usage, 9)) + let reasoning = Self.clampedInt(WireReader.varintField(usage, 10)) + guard input > 0 || output > 0 || cacheRead > 0 || reasoning > 0 else { return nil } + + if let responseID = WireReader.stringField(usage, 11)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !responseID.isEmpty + { + guard seenResponseIDs.insert(responseID).inserted else { return nil } + } + + let modelRaw = WireReader.stringField(chatModel, 19)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let model = modelRaw.isEmpty ? "gemini" : modelRaw + + let costUSD = self.costUSD( + model: model, + tokens: TokenBuckets(input: input, cacheRead: cacheRead, output: output), + catalog: catalog, + cacheRoot: cacheRoot) + + return UsageRow( + model: model, + createdMs: timestampMs, + input: input, + cacheRead: cacheRead, + output: output, + reasoning: reasoning, + responseID: nil, + costUSD: costUSD) + } + + // MARK: - Pricing + + private struct TokenBuckets { + let input: Int + let cacheRead: Int + let output: Int + } + + /// Prices a generation at the vendor's official models.dev rate. The wire model name is first + /// normalized through `modelPricingAliases`, then the provider is chosen by family: Claude + /// models price against the Anthropic catalog, everything else against Google. `reasoning` is + /// already folded into `output`, so it is not priced twice. Cache-read tokens use the cheaper + /// cache-read rate when the catalog carries one, else the plain input rate. Returns nil when no + /// official rate is known. + private static func costUSD( + model: String, + tokens: TokenBuckets, + catalog: ModelsDevCatalog?, + cacheRoot: URL?) -> Double? + { + let lowered = model.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let pricingModel = self.modelPricingAliases[lowered] ?? lowered + let providerID = pricingModel.hasPrefix("claude-") + ? self.anthropicModelsDevProviderID + : self.googleModelsDevProviderID + guard let lookup = CostUsagePricing.modelsDevLookup( + providerID: providerID, + model: pricingModel, + catalog: catalog, + cacheRoot: cacheRoot) + else { + return nil + } + let pricing = lookup.pricing + let cacheReadRate = pricing.cacheReadInputCostPerToken ?? pricing.inputCostPerToken + let cost = Double(tokens.input) * pricing.inputCostPerToken + + Double(tokens.cacheRead) * cacheReadRate + + Double(tokens.output) * pricing.outputCostPerToken + return cost.isFinite ? cost : nil + } + + // MARK: - SQLite helpers + + private static func sessionCreatedMs(db: OpaquePointer?) -> Int64? { + guard self.hasTable(named: "trajectory_metadata_blob", db: db) else { return nil } + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, "SELECT data FROM trajectory_metadata_blob LIMIT 1", -1, &stmt, nil) == SQLITE_OK + else { return nil } + defer { sqlite3_finalize(stmt) } + guard sqlite3_step(stmt) == SQLITE_ROW, + let blob = self.columnBlob(stmt, 0), + let tsMessage = WireReader.messageField(blob, 2) + else { + return nil + } + return WireReader.timestampMs(tsMessage).flatMap { $0 > 0 ? $0 : nil } + } + + private static func fileModifiedMs(_ url: URL) -> Int64 { + guard let values = try? url.resourceValues(forKeys: [.contentModificationDateKey]), + let modified = values.contentModificationDate + else { + return 0 + } + return Int64(modified.timeIntervalSince1970 * 1000) + } + + private static func hasTable(named name: String, db: OpaquePointer?) -> Bool { + var stmt: OpaquePointer? + guard sqlite3_prepare_v2( + db, + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1", + -1, + &stmt, + nil) == SQLITE_OK + else { + return false + } + defer { sqlite3_finalize(stmt) } + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(stmt, 1, name, -1, transient) + return sqlite3_step(stmt) == SQLITE_ROW + } + + private static func columnBlob(_ stmt: OpaquePointer?, _ index: Int32) -> [UInt8]? { + guard sqlite3_column_type(stmt, index) != SQLITE_NULL else { return nil } + let count = Int(sqlite3_column_bytes(stmt, index)) + guard count > 0, let pointer = sqlite3_column_blob(stmt, index) else { return nil } + let buffer = UnsafeRawBufferPointer(start: pointer, count: count) + return Array(buffer) + } + + private static func clampedInt(_ value: UInt64?) -> Int { + guard let value else { return 0 } + return Int(clamping: value) + } + + private static func sum(_ values: [Int]) -> Int? { + var result = 0 + for value in values { + let addition = result.addingReportingOverflow(value) + guard !addition.overflow else { return nil } + result = addition.partialValue + } + return result + } +} + +// MARK: - Protobuf wire-format reader + +/// Minimal dependency-free protobuf wire-format reader, mirroring tokscale's antigravity-cli +/// parser. Malformed data degrades to `nil`, never traps. Group wire types (3/4) are deprecated and +/// unsupported; encountering one stops the scan rather than risking desync. +private enum WireReader { + enum Value { + case varint(UInt64) + case length([UInt8]) + } + + static func messageField(_ buffer: [UInt8], _ field: UInt64) -> [UInt8]? { + for (number, value) in self.fields(buffer) where number == field { + if case let .length(bytes) = value { return bytes } + } + return nil + } + + static func varintField(_ buffer: [UInt8], _ field: UInt64) -> UInt64? { + for (number, value) in self.fields(buffer) where number == field { + if case let .varint(raw) = value { return raw } + } + return nil + } + + static func stringField(_ buffer: [UInt8], _ field: UInt64) -> String? { + guard let bytes = self.messageField(buffer, field) else { return nil } + return String(bytes: bytes, encoding: .utf8) + } + + /// Decode a `{#1: seconds, #2: nanos}` Timestamp to epoch milliseconds. Out-of-range nanos mark + /// the stamp malformed; checked arithmetic keeps corrupt seconds from overflowing. + static func timestampMs(_ buffer: [UInt8]) -> Int64? { + guard let secondsRaw = self.varintField(buffer, 1), + let seconds = Int64(exactly: secondsRaw) + else { + return nil + } + let nanosRaw = self.varintField(buffer, 2) ?? 0 + guard let nanos = Int64(exactly: nanosRaw), (0...999_999_999).contains(nanos) else { return nil } + let millis = seconds.multipliedReportingOverflow(by: 1000) + guard !millis.overflow else { return nil } + let total = millis.partialValue.addingReportingOverflow(nanos / 1_000_000) + return total.overflow ? nil : total.partialValue + } + + private static func fields(_ buffer: [UInt8]) -> [(UInt64, Value)] { + var result: [(UInt64, Value)] = [] + var position = 0 + while position < buffer.count { + guard let tag = self.readVarint(buffer, &position) else { break } + let field = tag >> 3 + switch tag & 0x7 { + case 0: + guard let value = self.readVarint(buffer, &position) else { return result } + result.append((field, .varint(value))) + case 1: + guard position + 8 <= buffer.count else { return result } + position += 8 + case 2: + guard let length = self.readVarint(buffer, &position), + let count = Int(exactly: length), + position + count <= buffer.count + else { + return result + } + result.append((field, .length(Array(buffer[position..<(position + count)])))) + position += count + case 5: + guard position + 4 <= buffer.count else { return result } + position += 4 + default: + return result + } + } + return result + } + + private static func readVarint(_ buffer: [UInt8], _ position: inout Int) -> UInt64? { + var result: UInt64 = 0 + var shift: UInt64 = 0 + while position < buffer.count { + let byte = buffer[position] + position += 1 + result |= UInt64(byte & 0x7F) << shift + if byte & 0x80 == 0 { return result } + shift += 7 + if shift >= 64 { return nil } + } + return nil + } +} +#endif diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiCodeSessionScanner.swift b/Sources/CodexBarCore/Providers/Kimi/KimiCodeSessionScanner.swift index 6cdc1a322a..535d101e59 100644 --- a/Sources/CodexBarCore/Providers/Kimi/KimiCodeSessionScanner.swift +++ b/Sources/CodexBarCore/Providers/Kimi/KimiCodeSessionScanner.swift @@ -29,8 +29,16 @@ public enum KimiCodeSessionScanner { var cacheCreation = 0 var output = 0 var requests = 0 + var cost = 0.0 + var sawCost = false - mutating func add(_ usage: WireEvent.Usage) -> Bool { + mutating func add( + _ usage: WireEvent.Usage, + model: String, + pricingDate: Date, + modelsDevCatalog: ModelsDevCatalog?, + modelsDevCacheRoot: URL?) -> Bool + { guard let input = Self.valid(usage.inputOther), let cacheRead = Self.valid(usage.inputCacheRead), let cacheCreation = Self.valid(usage.inputCacheCreation), @@ -48,6 +56,16 @@ public enum KimiCodeSessionScanner { self.cacheCreation = nextCacheCreation self.output = nextOutput self.requests = nextRequests + if let cost = Self.estimatedCost( + model: model, + usage: usage, + pricingDate: pricingDate, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + { + self.cost += cost + self.sawCost = true + } return true } @@ -65,6 +83,10 @@ public enum KimiCodeSessionScanner { self.cacheCreation = nextCacheCreation self.output = nextOutput self.requests = nextRequests + if other.sawCost { + self.cost += other.cost + self.sawCost = true + } return true } @@ -86,6 +108,42 @@ public enum KimiCodeSessionScanner { let result = lhs.addingReportingOverflow(rhs) return result.overflow ? nil : result.partialValue } + + private static func estimatedCost( + model: String, + usage: WireEvent.Usage, + pricingDate: Date, + modelsDevCatalog: ModelsDevCatalog?, + modelsDevCacheRoot: URL?) -> Double? + { + guard let input = self.valid(usage.inputOther), + let cacheRead = self.valid(usage.inputCacheRead), + let cacheCreation = self.valid(usage.inputCacheCreation), + let output = self.valid(usage.output) + else { + return nil + } + return CostUsagePricing.claudeCostUSD( + model: self.pricingModelID(model), + inputTokens: input, + cacheReadInputTokens: cacheRead, + cacheCreationInputTokens: cacheCreation, + outputTokens: output, + pricingDate: pricingDate, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + } + + private static func pricingModelID(_ model: String) -> String { + let bare = model.split(separator: "/", omittingEmptySubsequences: true).last + .map(String.init) ?? model + switch bare.lowercased() { + case "k3", "k3-256k": + return "kimi-k3" + default: + return bare + } + } } public static func scan( @@ -93,7 +151,8 @@ public enum KimiCodeSessionScanner { fileManager: FileManager = .default, historyDays: Int = defaultHistoryDays, now: Date = Date(), - calendar: Calendar = .current) -> CostUsageTokenSnapshot? + calendar: Calendar = .current, + modelsDevCacheRoot: URL? = nil) -> CostUsageTokenSnapshot? { let days = max(1, historyDays) let home = KimiSettingsReader.kimiCodeHomeURL(environment: environment) @@ -110,6 +169,7 @@ public enum KimiCodeSessionScanner { let start = calendar.date(byAdding: .day, value: -(days - 1), to: end) ?? end var values: [DayModelKey: TokenAccumulator] = [:] let decoder = JSONDecoder() + let modelsDevCatalog = CostUsagePricing.modelsDevCatalog(now: now, cacheRoot: modelsDevCacheRoot) while let url = enumerator.nextObject() as? URL { guard url.lastPathComponent == "wire.jsonl", @@ -141,7 +201,13 @@ public enum KimiCodeSessionScanner { guard day >= start, day <= end else { continue } let key = DayModelKey(day: CostUsageLocalDay.key(from: day, calendar: calendar), model: rawModel) var value = values[key] ?? TokenAccumulator() - guard value.add(usage) else { continue } + guard value.add( + usage, + model: rawModel, + pricingDate: date, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + else { continue } values[key] = value } } @@ -154,18 +220,24 @@ public enum KimiCodeSessionScanner { } var total = TokenAccumulator() var modelBreakdowns: [CostUsageDailyReport.ModelBreakdown] = [] + var dayCost = 0.0 + var daySawCost = false for (key, value) in models { guard let modelTotal = value.total else { return nil } guard total.merge(value) else { return nil } modelBreakdowns.append(CostUsageDailyReport.ModelBreakdown( modelName: key.model, - costUSD: nil, + costUSD: value.sawCost ? value.cost : nil, totalTokens: modelTotal, inputTokens: value.input, cacheReadTokens: value.cacheRead, cacheCreationTokens: value.cacheCreation, outputTokens: value.output, requestCount: value.requests)) + if value.sawCost { + dayCost += value.cost + daySawCost = true + } } guard let totalTokens = total.total else { return nil } return CostUsageDailyReport.Entry( @@ -176,12 +248,14 @@ public enum KimiCodeSessionScanner { cacheCreationTokens: total.cacheCreation, totalTokens: totalTokens, requestCount: total.requests, - costUSD: nil, + costUSD: daySawCost ? dayCost : nil, modelsUsed: modelBreakdowns.map(\.modelName), modelBreakdowns: modelBreakdowns) } let totalTokens = self.sum(daily.compactMap(\.totalTokens)) let totalRequests = self.sum(daily.compactMap(\.requestCount)) + let totalCost = daily.compactMap(\.costUSD).reduce(0, +) + let sawCost = daily.contains { $0.costUSD != nil } guard let totalTokens, let totalRequests else { return nil } return CostUsageTokenSnapshot( @@ -189,12 +263,13 @@ public enum KimiCodeSessionScanner { sessionCostUSD: nil, sessionRequests: nil, last30DaysTokens: totalTokens, - last30DaysCostUSD: nil, + last30DaysCostUSD: sawCost ? totalCost : nil, last30DaysRequests: totalRequests, - currencyCode: "XXX", + currencyCode: sawCost ? "USD" : "XXX", historyDays: days, historyCoverageIsEstablished: true, historyLabel: "Kimi Code CLI", + costSource: .estimated, daily: daily, updatedAt: now) } diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSessionScanner.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSessionScanner.swift index 0f553a5fcd..44e582e41b 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSessionScanner.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSessionScanner.swift @@ -16,8 +16,12 @@ import CSQLite3 // plus `model` (e.g. `minimax/MiniMax-M3`) and `ts` (epoch milliseconds). `reasoning` is part of // billing output, so it stays folded into `outputTokens` and is additionally surfaced as the // `reasoningTokens` sub-bucket (never added on top). `cache_write` maps to `cacheCreationTokens`. -// `cost_usd` is reported by the provider; when present it is summed into the day/model cost, -// otherwise the snapshot stays token-only (`costUSD: nil`). +// `cost_usd` is reported by the provider, but MiniMax coding plans run on a flat subscription and +// report `0` there. To stay consistent with how Codex/Claude spend is estimated, we price each turn +// at the vendor's official models.dev rate (via `CostUsagePricing.claudeCostUSD`, which routes +// MiniMax through the third-party lookup) instead of trusting the zeroed provider figure. A turn is +// priced from its token buckets; only when no official rate is known does the snapshot fall back to +// the provider's `cost_usd`, and failing that stays token-only (`costUSD: nil`). #if canImport(SQLite3) || canImport(CSQLite3) public enum MiniMaxSessionScanner { public static let defaultHistoryDays = 30 @@ -41,7 +45,11 @@ public enum MiniMaxSessionScanner { var cost = 0.0 var sawCost = false - mutating func add(_ row: UsageRow) -> Bool { + mutating func add( + _ row: UsageRow, + modelsDevCatalog: ModelsDevCatalog?, + modelsDevCacheRoot: URL?) -> Bool + { guard let nextInput = Self.adding(self.input, row.input), let nextCacheRead = Self.adding(self.cacheRead, row.cacheRead), let nextCacheCreation = Self.adding(self.cacheCreation, row.cacheCreation), @@ -57,7 +65,10 @@ public enum MiniMaxSessionScanner { self.output = nextOutput self.reasoning = nextReasoning self.requests = nextRequests - if let cost = row.cost { + if let cost = row.estimatedCost( + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + { self.cost += cost self.sawCost = true } @@ -111,19 +122,53 @@ public enum MiniMaxSessionScanner { let cacheRead: Int let cacheCreation: Int let cost: Double? + + /// Prices the turn at the vendor's official models.dev rate (mirroring how Codex/Claude + /// spend is estimated). The stored `model` is namespaced as `minimax/MiniMax-M3`, but the + /// third-party lookup keys on the bare model id, so the provider prefix is stripped first. + /// `reasoning` is already folded into `output`, so it is not priced twice. Falls back to the + /// provider-reported `cost_usd` only when it carries a real (non-zero) figure and no + /// official rate is known. + func estimatedCost( + modelsDevCatalog: ModelsDevCatalog?, + modelsDevCacheRoot: URL?) -> Double? + { + let bareModel = Self.bareModelID(self.model) + if let priced = CostUsagePricing.claudeCostUSD( + model: bareModel, + inputTokens: self.input, + cacheReadInputTokens: self.cacheRead, + cacheCreationInputTokens: self.cacheCreation, + outputTokens: self.output, + pricingDate: Date(timeIntervalSince1970: TimeInterval(self.createdMs) / 1000), + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + { + return priced + } + if let cost = self.cost, cost > 0 { return cost } + return nil + } + + private static func bareModelID(_ model: String) -> String { + guard let slash = model.lastIndex(of: "/") else { return model } + return String(model[model.index(after: slash)...]) + } } public static func scan( environment: [String: String] = ProcessInfo.processInfo.environment, historyDays: Int = defaultHistoryDays, now: Date = Date(), - calendar: Calendar = .current) -> CostUsageTokenSnapshot? + calendar: Calendar = .current, + modelsDevCacheRoot: URL? = nil) -> CostUsageTokenSnapshot? { let days = max(1, historyDays) let databaseURL = self.runtimeDatabaseURL(environment: environment) guard FileManager.default.fileExists(atPath: databaseURL.path) else { return nil } guard let rows = self.readRows(databaseURL: databaseURL), !rows.isEmpty else { return nil } + let modelsDevCatalog = CostUsagePricing.modelsDevCatalog(now: now, cacheRoot: modelsDevCacheRoot) let end = calendar.startOfDay(for: now) let start = calendar.date(byAdding: .day, value: -(days - 1), to: end) ?? end var values: [DayModelKey: TokenAccumulator] = [:] @@ -134,7 +179,8 @@ public enum MiniMaxSessionScanner { guard day >= start, day <= end else { continue } let key = DayModelKey(day: CostUsageLocalDay.key(from: day, calendar: calendar), model: row.model) var value = values[key] ?? TokenAccumulator() - guard value.add(row) else { continue } + guard value.add(row, modelsDevCatalog: modelsDevCatalog, modelsDevCacheRoot: modelsDevCacheRoot) + else { continue } values[key] = value } @@ -196,6 +242,7 @@ public enum MiniMaxSessionScanner { historyDays: days, historyCoverageIsEstablished: true, historyLabel: "MiniMax", + costSource: .estimated, daily: daily, updatedAt: now) } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+ThirdParty.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+ThirdParty.swift index 328ad7af78..da8b4ad3ab 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+ThirdParty.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+ThirdParty.swift @@ -14,6 +14,11 @@ extension CostUsagePricing { let candidates: [(providerID: String, modelID: String)] if lower.hasPrefix("deepseek-") { candidates = [("deepseek", trimmed)] + } else if lower == "k3" || lower == "k3-256k" || lower == "kimi-k3" { + candidates = [ + ("moonshotai", "kimi-k3"), + ("moonshotai-cn", "kimi-k3"), + ] } else if lower == "kimi-for-coding" { candidates = [ ("kimi-for-coding", trimmed), @@ -51,6 +56,42 @@ extension CostUsagePricing { .cacheReadInputCostPerTokenAboveThreshold) } } + + // Kimi's official API price on 2026-07-26 is $3/M uncached input, $0.30/M cached + // input, and $15/M output for kimi-k3. Keep a fallback because newly released models can + // precede the models.dev catalog; the catalog remains authoritative as soon as it contains + // the model. Source: https://www.kimi.com/help/kimi-api/api-pricing + if lower == "k3" || lower == "k3-256k" || lower == "kimi-k3" { + return ClaudePricing( + inputCostPerToken: 3 / 1_000_000, + outputCostPerToken: 15 / 1_000_000, + cacheCreationInputCostPerToken: 3 / 1_000_000, + cacheReadInputCostPerToken: 0.30 / 1_000_000, + thresholdTokens: nil, + inputCostPerTokenAboveThreshold: nil, + outputCostPerTokenAboveThreshold: nil, + cacheCreationInputCostPerTokenAboveThreshold: nil, + cacheReadInputCostPerTokenAboveThreshold: nil) + } + + // MiniMax-M3 launched before every cached pricing catalog carried its pay-as-you-go row. + // Keep the official standard-tier API price as a fallback so local Token Plan usage stays + // priceable while offline or during catalog refresh. For requests whose input context + // (including cache hits) exceeds 512K, MiniMax doubles input, output, and cache-read rates. + // Source (accessed 2026-07-26): + // https://platform.minimax.io/subscribe/token-plan?tab=api-enterprise + if lower == "minimax-m3" { + return ClaudePricing( + inputCostPerToken: 0.30 / 1_000_000, + outputCostPerToken: 1.20 / 1_000_000, + cacheCreationInputCostPerToken: 0.30 / 1_000_000, + cacheReadInputCostPerToken: 0.06 / 1_000_000, + thresholdTokens: 512_000, + inputCostPerTokenAboveThreshold: 0.60 / 1_000_000, + outputCostPerTokenAboveThreshold: 2.40 / 1_000_000, + cacheCreationInputCostPerTokenAboveThreshold: 0.60 / 1_000_000, + cacheReadInputCostPerTokenAboveThreshold: 0.12 / 1_000_000) + } return nil } diff --git a/Tests/CodexBarTests/AntigravitySessionScannerTests.swift b/Tests/CodexBarTests/AntigravitySessionScannerTests.swift new file mode 100644 index 0000000000..cacf3dc7aa --- /dev/null +++ b/Tests/CodexBarTests/AntigravitySessionScannerTests.swift @@ -0,0 +1,241 @@ +import Foundation +import SQLite3 +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct AntigravitySessionScannerTests { + enum SQLiteTestError: Error { + case open + case exec(String) + } + + @Test + func `scans token usage from conversation database`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + + try Self.createDatabase(at: env.databaseURL) + try Self.insertGeneration( + databaseURL: env.databaseURL, + meta: GenerationMeta( + index: 0, + model: "gemini-3.6-flash", + responseID: "resp-1", + timestampMs: Self.ms("2026-07-20T10:00:00.000Z")), + tokens: Tokens(newlyProcessed: 500, cacheRead: 16000, output: 300, reasoning: 40)) + try Self.insertGeneration( + databaseURL: env.databaseURL, + meta: GenerationMeta( + index: 1, + model: "gemini-3.6-flash", + responseID: "resp-2", + timestampMs: Self.ms("2026-07-20T11:00:00.000Z")), + tokens: Tokens(newlyProcessed: 100, cacheRead: 0, output: 50, reasoning: 0)) + + let now = Date(timeIntervalSince1970: TimeInterval(Self.ms("2026-07-20T12:00:00.000Z")) / 1000) + let snapshot = AntigravitySessionScanner.scan( + environment: [AntigravitySessionScanner.homeEnvironmentKey: env.homeURL.path], + historyDays: 30, + now: now) + + let report = try #require(snapshot) + #expect(report.last30DaysTokens == 1132 + 500 + 16000 + 300 + 1132 + 100 + 50) + #expect(report.last30DaysRequests == 2) + #expect(report.daily.count == 1) + let entry = try #require(report.daily.first) + #expect(entry.requestCount == 2) + #expect(entry.inputTokens == 1132 + 500 + 1132 + 100) + #expect(entry.cacheReadTokens == 16000) + #expect(entry.outputTokens == 350) + #expect(entry.modelsUsed == ["gemini-3.6-flash"]) + } + + @Test + func `dedupes generations by response id`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + + try Self.createDatabase(at: env.databaseURL) + for index in 0..<3 { + try Self.insertGeneration( + databaseURL: env.databaseURL, + meta: GenerationMeta( + index: index, + model: "gemini-3.6-flash", + responseID: "same-response", + timestampMs: Self.ms("2026-07-20T10:00:00.000Z")), + tokens: Tokens(newlyProcessed: 500, cacheRead: 0, output: 300, reasoning: 0)) + } + + let now = Date(timeIntervalSince1970: TimeInterval(Self.ms("2026-07-20T12:00:00.000Z")) / 1000) + let snapshot = AntigravitySessionScanner.scan( + environment: [AntigravitySessionScanner.homeEnvironmentKey: env.homeURL.path], + historyDays: 30, + now: now) + + #expect(snapshot?.last30DaysRequests == 1) + } + + @Test + func `empty conversations directory yields no snapshot`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + + let snapshot = AntigravitySessionScanner.scan( + environment: [AntigravitySessionScanner.homeEnvironmentKey: env.homeURL.path], + historyDays: 30, + now: Date()) + + #expect(snapshot == nil) + } + + @Test + func `database without gen_metadata table is skipped`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + + var db: OpaquePointer? + guard sqlite3_open(env.databaseURL.path, &db) == SQLITE_OK else { throw SQLiteTestError.open } + sqlite3_close(db) + + let snapshot = AntigravitySessionScanner.scan( + environment: [AntigravitySessionScanner.homeEnvironmentKey: env.homeURL.path], + historyDays: 30, + now: Date()) + + #expect(snapshot == nil) + } + + // MARK: - Environment + + private static func makeEnvironment() throws -> (root: URL, homeURL: URL, databaseURL: URL) { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("AntigravitySessionScannerTests-\(UUID().uuidString)", isDirectory: true) + let homeURL = root.appendingPathComponent("antigravity", isDirectory: true) + let conversationsURL = homeURL.appendingPathComponent("conversations", isDirectory: true) + try FileManager.default.createDirectory(at: conversationsURL, withIntermediateDirectories: true) + return (root, homeURL, conversationsURL.appendingPathComponent("session-1.db", isDirectory: false)) + } + + private static func createDatabase(at url: URL) throws { + var db: OpaquePointer? + guard sqlite3_open(url.path, &db) == SQLITE_OK else { throw SQLiteTestError.open } + defer { sqlite3_close(db) } + try self.exec(db: db, sql: "CREATE TABLE gen_metadata (idx INTEGER PRIMARY KEY, data BLOB);") + } + + struct Tokens { + /// Fixed system-prompt tokens the scanner adds on top of `newlyProcessed` for billable input. + var systemPrompt: UInt64 = 1132 + var newlyProcessed: UInt64 + var cacheRead: UInt64 + var output: UInt64 + var reasoning: UInt64 + } + + struct GenerationMeta { + var index: Int + var model: String + var responseID: String? + var timestampMs: Int64 + } + + private static func insertGeneration( + databaseURL: URL, + meta: GenerationMeta, + tokens: Tokens) throws + { + var db: OpaquePointer? + guard sqlite3_open(databaseURL.path, &db) == SQLITE_OK else { throw SQLiteTestError.open } + defer { sqlite3_close(db) } + + let blob = Self.buildGeneration( + model: meta.model, + tokens: tokens, + responseID: meta.responseID, + timestampMs: meta.timestampMs) + + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, "INSERT INTO gen_metadata (idx, data) VALUES (?, ?)", -1, &stmt, nil) == SQLITE_OK + else { throw SQLiteTestError.exec("prepare") } + defer { sqlite3_finalize(stmt) } + sqlite3_bind_int(stmt, 1, Int32(meta.index)) + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + _ = blob.withUnsafeBytes { buffer in + sqlite3_bind_blob(stmt, 2, buffer.baseAddress, Int32(buffer.count), transient) + } + guard sqlite3_step(stmt) == SQLITE_DONE else { throw SQLiteTestError.exec("insert") } + } + + private static func exec(db: OpaquePointer?, sql: String) throws { + var error: UnsafeMutablePointer? + guard sqlite3_exec(db, sql, nil, nil, &error) == SQLITE_OK else { + let message = error.map { String(cString: $0) } ?? "unknown" + sqlite3_free(error) + throw SQLiteTestError.exec(message) + } + } + + private static func ms(_ iso: String) -> Int64 { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let date = formatter.date(from: iso) ?? Date(timeIntervalSince1970: 0) + return Int64(date.timeIntervalSince1970 * 1000) + } + + // MARK: - Protobuf encoding + + /// Builds a `gen_metadata` blob: `{#1: chatModel}` where chatModel carries `#4: usage`, + /// `#9: {#4: Timestamp}` and `#19: responseModel`. + private static func buildGeneration( + model: String, + tokens: Tokens, + responseID: String?, + timestampMs: Int64) -> [UInt8] + { + var usage = self.encVarint(field: 1, value: tokens.systemPrompt) + usage += self.encVarint(field: 2, value: tokens.newlyProcessed) + usage += self.encVarint(field: 5, value: tokens.cacheRead) + usage += self.encVarint(field: 9, value: tokens.output) + usage += self.encVarint(field: 10, value: tokens.reasoning) + if let responseID { + usage += self.encLen(field: 11, payload: Array(responseID.utf8)) + } + + var generation: [UInt8] = [] + generation += self.encLen(field: 4, payload: self.encTimestamp(seconds: timestampMs / 1000, nanos: 0)) + + var chatModel = self.encLen(field: 4, payload: usage) + chatModel += self.encLen(field: 9, payload: generation) + chatModel += self.encLen(field: 19, payload: Array(model.utf8)) + + return self.encLen(field: 1, payload: chatModel) + } + + private static func encTimestamp(seconds: Int64, nanos: Int64) -> [UInt8] { + var out = self.encVarint(field: 1, value: UInt64(bitPattern: seconds)) + out += self.encVarint(field: 2, value: UInt64(bitPattern: nanos)) + return out + } + + private static func encVarint(field: UInt64, value: UInt64) -> [UInt8] { + self.encodeVarint(field << 3) + self.encodeVarint(value) + } + + private static func encLen(field: UInt64, payload: [UInt8]) -> [UInt8] { + self.encodeVarint((field << 3) | 2) + self.encodeVarint(UInt64(payload.count)) + payload + } + + private static func encodeVarint(_ value: UInt64) -> [UInt8] { + var value = value + var out: [UInt8] = [] + while true { + var byte = UInt8(value & 0x7F) + value >>= 7 + if value != 0 { byte |= 0x80 } + out.append(byte) + if value == 0 { return out } + } + } +} diff --git a/Tests/CodexBarTests/KimiCodeSessionScannerTests.swift b/Tests/CodexBarTests/KimiCodeSessionScannerTests.swift index 882b474c2c..59df9ef384 100644 --- a/Tests/CodexBarTests/KimiCodeSessionScannerTests.swift +++ b/Tests/CodexBarTests/KimiCodeSessionScannerTests.swift @@ -49,10 +49,11 @@ struct KimiCodeSessionScannerTests { now: now, calendar: Self.calendar)) - #expect(snapshot.currencyCode == "XXX") + #expect(snapshot.currencyCode == "USD") #expect(snapshot.last30DaysTokens == 82) #expect(snapshot.last30DaysRequests == 3) - #expect(snapshot.last30DaysCostUSD == nil) + #expect(snapshot.last30DaysCostUSD != nil) + #expect(snapshot.costSource == .estimated) #expect(snapshot.daily.count == 2) #expect(snapshot.daily.flatMap { $0.modelBreakdowns ?? [] }.map(\.modelName) == [ "kimi-code/k3", @@ -63,6 +64,8 @@ struct KimiCodeSessionScannerTests { #expect(snapshot.daily.flatMap { $0.modelBreakdowns ?? [] }.map(\.cacheReadTokens) == [25, 9]) #expect(snapshot.daily.flatMap { $0.modelBreakdowns ?? [] }.map(\.cacheCreationTokens) == [6, 0]) #expect(snapshot.daily.flatMap { $0.modelBreakdowns ?? [] }.map(\.outputTokens) == [10, 10]) + let breakdowns = snapshot.daily.flatMap { $0.modelBreakdowns ?? [] } + #expect(abs((breakdowns.first?.costUSD ?? 0) - 0.0002175) < 0.000000001) // Kimi wire.jsonl carries no reasoning field, so the reasoning bucket stays nil. #expect(snapshot.daily.flatMap { $0.modelBreakdowns ?? [] }.map(\.reasoningTokens) == [nil, nil]) } diff --git a/Tests/CodexBarTests/MiniMaxSessionScannerTests.swift b/Tests/CodexBarTests/MiniMaxSessionScannerTests.swift new file mode 100644 index 0000000000..3e28f4e27f --- /dev/null +++ b/Tests/CodexBarTests/MiniMaxSessionScannerTests.swift @@ -0,0 +1,91 @@ +import CodexBarCore +import Foundation +#if canImport(SQLite3) +import SQLite3 +#elseif canImport(CSQLite3) +import CSQLite3 +#endif +import Testing + +#if canImport(SQLite3) || canImport(CSQLite3) +struct MiniMaxSessionScannerTests { + @Test + func `scanner prices MiniMax M3 even when the models catalog is empty`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("MiniMaxSessionScannerTests-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let database = root.appendingPathComponent("v2/sqlite/runtime-state.sqlite") + try FileManager.default.createDirectory( + at: database.deletingLastPathComponent(), + withIntermediateDirectories: true) + try Self.createDatabase(at: database) + + let now = Date(timeIntervalSince1970: 1_785_033_600) // 2026-07-26 UTC + let cacheRoot = root.appendingPathComponent("empty-model-pricing-cache", isDirectory: true) + let snapshot = try #require(MiniMaxSessionScanner.scan( + environment: [MiniMaxSessionScanner.homeEnvironmentKey: root.path], + historyDays: 30, + now: now, + calendar: Self.calendar, + modelsDevCacheRoot: cacheRoot)) + + #expect(snapshot.currencyCode == "USD") + #expect(snapshot.costSource == .estimated) + #expect(snapshot.last30DaysTokens == 350) + #expect(snapshot.last30DaysRequests == 1) + #expect(abs((snapshot.last30DaysCostUSD ?? 0) - 0.000_102) < 0.000_000_001) + let breakdown = try #require(snapshot.daily.first?.modelBreakdowns?.first) + #expect(breakdown.modelName == "minimax/MiniMax-M3") + #expect(breakdown.reasoningTokens == 20) + #expect(abs((breakdown.costUSD ?? 0) - 0.000_102) < 0.000_000_001) + } + + private static func createDatabase(at url: URL) throws { + var database: OpaquePointer? + guard sqlite3_open(url.path, &database) == SQLITE_OK, let database else { + throw SQLiteFixtureError.open + } + defer { sqlite3_close(database) } + let sql = """ + CREATE TABLE local_runtime_token_usage ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + agent_name TEXT NOT NULL, + framework_type TEXT NOT NULL, + turn_id TEXT, + model TEXT, + ts INTEGER NOT NULL, + input_tokens INTEGER NOT NULL, + output_tokens INTEGER NOT NULL, + reasoning_tokens INTEGER NOT NULL, + cache_read_tokens INTEGER NOT NULL, + cache_write_tokens INTEGER NOT NULL, + cost_usd REAL, + raw TEXT + ); + INSERT INTO local_runtime_token_usage ( + session_id, agent_name, framework_type, turn_id, model, ts, + input_tokens, output_tokens, reasoning_tokens, cache_read_tokens, + cache_write_tokens, cost_usd + ) VALUES ( + 'session-1', 'main', 'agent', 'turn-1', 'minimax/MiniMax-M3', 1785033600000, + 100, 50, 20, 200, 0, 0 + ); + """ + guard sqlite3_exec(database, sql, nil, nil, nil) == SQLITE_OK else { + throw SQLiteFixtureError.schema + } + } + + private static let calendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + }() + + private enum SQLiteFixtureError: Error { + case open + case schema + } +} +#endif diff --git a/Tests/CodexBarTests/ModelsDevPricingTests.swift b/Tests/CodexBarTests/ModelsDevPricingTests.swift index a0da2f6370..c064e7d29a 100644 --- a/Tests/CodexBarTests/ModelsDevPricingTests.swift +++ b/Tests/CodexBarTests/ModelsDevPricingTests.swift @@ -6,6 +6,29 @@ import Testing @testable import CodexBarCore struct ModelsDevPricingTests { + @Test + func `MiniMax M3 remains priceable without a refreshed catalog`() throws { + let emptyCatalog = ModelsDevCatalog(providers: [:]) + let base = try #require(CostUsagePricing.claudeCostUSD( + model: "MiniMax-M3", + inputTokens: 467_711, + cacheReadInputTokens: 1_255_798, + cacheCreationInputTokens: 0, + outputTokens: 14833, + modelsDevCatalog: emptyCatalog)) + + #expect(abs(base - 0.466_921_560) < 0.000_000_001) + + let shortContext = try #require(CostUsagePricing.claudeCostUSD( + model: "MiniMax-M3", + inputTokens: 100, + cacheReadInputTokens: 200, + cacheCreationInputTokens: 0, + outputTokens: 50, + modelsDevCatalog: emptyCatalog)) + #expect(abs(shortContext - 0.000_102) < 0.000_000_001) + } + @Test func `parses models dev subset`() throws { let catalog = try Self.fixtureCatalog() diff --git a/Tests/CodexBarTests/ShareStatsTests.swift b/Tests/CodexBarTests/ShareStatsTests.swift index 6db809b020..7cfa2e3686 100644 --- a/Tests/CodexBarTests/ShareStatsTests.swift +++ b/Tests/CodexBarTests/ShareStatsTests.swift @@ -79,9 +79,14 @@ struct ShareStatsTests { @Test func `subscription labels require a plan tier provider contract`() { #expect(Self.subscriptionName(provider: .codex, rawName: "pro")?.displayName == "Pro 20x") - #expect(Self.subscriptionName(provider: .codex, rawName: "Plus Plan")?.displayName == "Plus") + #expect(Self.subscriptionName( + provider: .codex, + rawName: "Plus Plan")?.displayName == "ChatGPT Plus") #expect(Self.subscriptionName(provider: .cursor, rawName: "Cursor Pro")?.displayName == "Cursor Pro") #expect(Self.subscriptionName(provider: .gemini, rawName: "Paid")?.displayName == "Paid") + #expect(Self.subscriptionName( + provider: .antigravity, + rawName: "Google AI Pro")?.displayName == "Google AI Pro") #expect(Self.subscriptionName(provider: .copilot, rawName: "Business")?.displayName == "Business") #expect(Self.subscriptionName(provider: .perplexity, rawName: "Max")?.displayName == "Max") #expect(Self.subscriptionName(provider: .windsurf, rawName: "Teams")?.displayName == "Teams") @@ -108,6 +113,15 @@ struct ShareStatsTests { #expect(name?.displayName == "Pro 20x") } + @Test + func `private dashboard accepts future plan tiers but rejects authentication labels`() { + let future = Self.snapshot(provider: .cursor, rawName: "Cursor Super Pro") + let auth = Self.snapshot(provider: .cursor, rawName: "API key") + + #expect(SpendSubscriptionPlan.from(snapshot: future, provider: .cursor)?.displayName == "Cursor Super Pro") + #expect(SpendSubscriptionPlan.from(snapshot: auth, provider: .cursor) == nil) + } + @Test func `bedrock regional model identifiers map to public families`() { #expect(ShareStatsSanitizer.modelName("us.amazon.nova-2-lite-v1:0") == "Amazon Nova") diff --git a/Tests/CodexBarTests/SpendBillingAttributionTests.swift b/Tests/CodexBarTests/SpendBillingAttributionTests.swift new file mode 100644 index 0000000000..f6cc67dbd1 --- /dev/null +++ b/Tests/CodexBarTests/SpendBillingAttributionTests.swift @@ -0,0 +1,272 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct SpendBillingAttributionTests { + @Test + func `subscription rows follow the vendor that owns the quota`() throws { + let inputs = [ + SpendDashboardModel.ProviderInput( + id: "cursor", + provider: .cursor, + displayName: "Cursor", + snapshot: Self.snapshot([ + Self.entry(model: "default", cost: 50, tokens: 500), + Self.entry(model: "claude-sonnet-4-6", cost: 100, tokens: 1000), + Self.entry(model: "k3", cost: 25, tokens: 250), + ])), + SpendDashboardModel.ProviderInput( + id: "antigravity", + provider: .antigravity, + displayName: "Antigravity", + snapshot: Self.snapshot([ + Self.entry(model: "gemini-3.1-pro", cost: 80, tokens: 800), + Self.entry(model: "claude-opus-4-6", cost: 20, tokens: 200), + ])), + SpendDashboardModel.ProviderInput( + id: "codex", + provider: .codex, + displayName: "Codex", + snapshot: Self.snapshot([ + Self.entry(model: "gpt-5.5", cost: 300, tokens: 3000), + Self.entry(model: "MiniMax-M3", cost: 70, tokens: 700), + ])), + SpendDashboardModel.ProviderInput( + id: "claude", + provider: .claude, + displayName: "Claude Code", + snapshot: Self.snapshot([ + Self.entry(model: "deepseek-v4-pro", cost: 40, tokens: 400), + Self.entry(model: "kimi-for-coding", cost: 20, tokens: 200), + Self.entry(model: "claude-sonnet-4-6", cost: 5, tokens: 50), + ])), + SpendDashboardModel.ProviderInput( + id: "kimi-native", + provider: .kimi, + displayName: "Kimi Code CLI", + snapshot: Self.snapshot([ + Self.entry(model: "kimi-code/k3", cost: 10, tokens: 100), + ])), + SpendDashboardModel.ProviderInput( + id: "minimax-native", + provider: .minimax, + displayName: "MiniMax Code", + snapshot: Self.snapshot([ + Self.entry(model: "MiniMax-M3", cost: 0.23, tokens: 17), + ])), + ] + + let group = try #require(SpendDashboardModel.build( + inputs: inputs, + requestedDays: 30, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.providers.map(\.provider) == [ + .codex, + .cursor, + .antigravity, + .minimax, + .kimi, + .deepseek, + .claude, + ]) + #expect(group.providers.map(\.id) == [ + "codex", + "cursor", + "antigravity", + "minimax-native", + "kimi-native", + "billing:deepseek", + "claude", + ]) + #expect(group.providers.map(\.totalCost) == [300, 150, 100, 70.23, 55, 40, 5]) + #expect(group.providers.map(\.displayName) == [ + "Codex", + "Cursor", + "Antigravity", + "MiniMax", + "Kimi", + "DeepSeek", + "Claude", + ]) + #expect(group.providers.first(where: { $0.provider == .kimi })?.totalTokens == 550) + #expect(group.providers.first(where: { $0.provider == .minimax })?.totalTokens == 717) + } + + @Test + func `Cursor moves only explicit external models`() { + #expect(SpendBillingAttribution.billingVendor(forModel: "default", defaultProvider: .cursor) == .cursor) + #expect(SpendBillingAttribution.billingVendor( + forModel: "claude-sonnet-4-6", + defaultProvider: .cursor) == .cursor) + #expect(SpendBillingAttribution.billingVendor(forModel: "gpt-5.6", defaultProvider: .cursor) == .cursor) + #expect(SpendBillingAttribution.billingVendor(forModel: "k3", defaultProvider: .cursor) == .kimi) + #expect(SpendBillingAttribution.billingVendor( + forModel: "kimi-for-coding", + defaultProvider: .cursor) == .kimi) + #expect(SpendBillingAttribution.billingVendor( + forModel: "MiniMax-M3", + defaultProvider: .cursor) == .minimax) + #expect(SpendBillingAttribution.billingVendor( + forModel: "deepseek-v4", + defaultProvider: .cursor) == .deepseek) + } + + @Test + func `bundled and official tools never leak models to another subscription`() { + #expect(SpendBillingAttribution.billingVendor( + forModel: "claude-opus-4-6", + defaultProvider: .antigravity) == .antigravity) + #expect(SpendBillingAttribution.billingVendor( + forModel: "gemini-3.6-flash", + defaultProvider: .antigravity) == .antigravity) + #expect(SpendBillingAttribution.billingVendor( + forModel: "k3", + defaultProvider: .kimi) == .kimi) + #expect(SpendBillingAttribution.billingVendor( + forModel: "MiniMax-M3", + defaultProvider: .minimax) == .minimax) + } + + @Test + func `live quota snapshot does not erase complete MiniMax local history`() throws { + let liveQuota = CostUsageTokenSnapshot( + sessionTokens: 10, + sessionCostUSD: nil, + last30DaysTokens: nil, + last30DaysCostUSD: nil, + currencyCode: "USD", + historyDays: 30, + historyCoverageIsEstablished: false, + daily: [], + updatedAt: Self.now) + let localHistory = Self.snapshot([ + Self.entry(model: "minimax/MiniMax-M3", cost: 0.23, tokens: 1_738_342), + ]) + let model = SpendDashboardModel.build( + inputs: [ + SpendDashboardModel.ProviderInput( + id: "minimax", + provider: .minimax, + displayName: "MiniMax", + subscriptionName: "Token Plan Plus", + snapshot: liveQuota), + SpendDashboardModel.ProviderInput( + id: "minimax:local", + provider: .minimax, + displayName: "MiniMax", + snapshot: localHistory), + ], + requestedDays: 30, + now: Self.now, + calendar: Self.calendar) + + let row = try #require(model.groups.first?.providers.first) + #expect(row.id == "minimax:local") + #expect(row.displayName == "MiniMax") + #expect(row.subscriptionName == "Token Plan Plus") + #expect(row.totalTokens == 1_738_342) + #expect(abs((row.totalCost ?? 0) - 0.23) < 0.000_001) + } + + @Test + func `inactive recent MiniMax window is zero while cumulative keeps historical spend`() throws { + let now = Date(timeIntervalSince1970: 1_785_427_200) // 2026-07-29 00:00:00 UTC + let entry = CostUsageDailyReport.Entry( + date: "2026-07-12", + inputTokens: 1_000_000, + outputTokens: 738_342, + totalTokens: 1_738_342, + costUSD: 0.23, + modelsUsed: ["minimax/MiniMax-M3"], + modelBreakdowns: [CostUsageDailyReport.ModelBreakdown( + modelName: "minimax/MiniMax-M3", + costUSD: 0.23, + totalTokens: 1_738_342, + inputTokens: 1_000_000, + outputTokens: 738_342)]) + let snapshot = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: 1_738_342, + last30DaysCostUSD: 0.23, + currencyCode: "USD", + historyDays: 365, + historyCoverageIsEstablished: true, + daily: [entry], + updatedAt: now) + let input = SpendDashboardModel.ProviderInput( + provider: .minimax, + displayName: "MiniMax", + subscriptionName: "Token Plan Plus", + snapshot: snapshot) + + let recent = try #require(SpendDashboardModel.build( + inputs: [input], + requestedDays: 7, + now: now, + calendar: Self.calendar).groups.first?.providers.first) + let cumulative = try #require(SpendDashboardModel.build( + inputs: [input], + requestedDays: 365, + now: now, + calendar: Self.calendar).groups.first?.providers.first) + + #expect(recent.totalTokens == 0) + #expect(recent.totalCost == 0) + #expect(recent.subscriptionName == "Token Plan Plus") + #expect(cumulative.totalTokens == 1_738_342) + #expect(abs((cumulative.totalCost ?? 0) - 0.23) < 0.000_001) + } + + @Test + func `model provider identity follows the model vendor instead of its harness`() { + #expect(SpendProviderIdentity.modelProvider( + rawName: "claude-opus-4-6-thinking", + fallback: .antigravity) == .claude) + #expect(SpendProviderIdentity.modelProvider( + rawName: "gemini-3.6-flash", + fallback: .antigravity) == .gemini) + #expect(SpendProviderIdentity.modelProvider(rawName: "k3", fallback: .cursor) == .kimi) + #expect(SpendProviderIdentity.modelProvider(rawName: "gpt-5.6-terra", fallback: .codex) == .openai) + #expect(SpendProviderIdentity.modelProvider(rawName: "future-model", fallback: .cursor) == .cursor) + } + + private static func entry(model: String, cost: Double, tokens: Int) -> CostUsageDailyReport.Entry { + CostUsageDailyReport.Entry( + date: "2026-07-24", + inputTokens: tokens / 2, + outputTokens: tokens - tokens / 2, + totalTokens: tokens, + costUSD: cost, + modelsUsed: [model], + modelBreakdowns: [CostUsageDailyReport.ModelBreakdown( + modelName: model, + costUSD: cost, + totalTokens: tokens, + inputTokens: tokens / 2, + outputTokens: tokens - tokens / 2)]) + } + + private static func snapshot(_ entries: [CostUsageDailyReport.Entry]) -> CostUsageTokenSnapshot { + CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: entries.compactMap(\.totalTokens).reduce(0, +), + last30DaysCostUSD: entries.compactMap(\.costUSD).reduce(0, +), + currencyCode: "USD", + historyDays: 30, + daily: entries, + updatedAt: self.now) + } + + private static let calendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + }() + + private static let now = Date(timeIntervalSince1970: 1_785_024_000) // 2026-07-24 00:00:00 UTC +} diff --git a/Tests/CodexBarTests/SpendDashboardDateTruthTests.swift b/Tests/CodexBarTests/SpendDashboardDateTruthTests.swift index 201fa5a80c..2a3044a850 100644 --- a/Tests/CodexBarTests/SpendDashboardDateTruthTests.swift +++ b/Tests/CodexBarTests/SpendDashboardDateTruthTests.swift @@ -320,7 +320,7 @@ struct SpendDashboardDateTruthTests { dailyPoints: usd.dailyPoints, aggregateTotal: usd.totalCost).content == .chart) - #expect(cad.totalCost == nil) + #expect(cad.totalCost == 5) #expect(cad.totalTokens == 30) #expect(cad.modelHistoryCompleteness == .incomplete) #expect(cad.models.map(\.provider) == [.mistral]) @@ -426,8 +426,8 @@ struct SpendDashboardDateTruthTests { #expect(usd.providers.first(where: { $0.id == "malformed" })?.totalCost == nil) #expect(usd.providers.first(where: { $0.id == "malformed" })?.totalTokens == nil) - #expect(usd.totalCost == nil) - #expect(usd.totalTokens == nil) + #expect(usd.totalCost == 4) + #expect(usd.totalTokens == 10) #expect(usd.modelHistoryCompleteness == .incomplete) #expect(usd.models.map(\.provider) == [.codex]) #expect(usd.models.map(\.totalCost) == [4]) @@ -606,7 +606,7 @@ struct SpendDashboardDateTruthTests { let eur = try #require(groups.first(where: { $0.currencyCode == "EUR" })) let usd = try #require(groups.first(where: { $0.currencyCode == "USD" })) - #expect(usd.totalCost == nil) + #expect(usd.totalCost == 4) #expect(usd.modelHistoryCompleteness == .incomplete) #expect(usd.models.map(\.provider) == [.codex]) #expect(usd.models.map(\.totalCost) == [4]) @@ -714,7 +714,7 @@ struct SpendDashboardDateTruthTests { let usd = try #require(groups.first(where: { $0.currencyCode == "USD" })) #expect(usd.totalCost == 0) - #expect(usd.totalTokens == nil) + #expect(usd.totalTokens == 0) #expect(usd.modelHistoryCompleteness == .complete) #expect(eur.totalCost == nil) #expect(eur.totalTokens == 0) diff --git a/Tests/CodexBarTests/SpendDashboardLocalHistoryRecoveryTests.swift b/Tests/CodexBarTests/SpendDashboardLocalHistoryRecoveryTests.swift new file mode 100644 index 0000000000..264658a8a6 --- /dev/null +++ b/Tests/CodexBarTests/SpendDashboardLocalHistoryRecoveryTests.swift @@ -0,0 +1,117 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct SpendDashboardLocalHistoryRecoveryTests { + @Test + func `successful local history clears unavailable live provider warning`() async { + let now = Date(timeIntervalSince1970: 1_784_179_200) + let request = SpendDashboardLoadRequest( + configuration: SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.minimax.rawValue], + codexAccountIdentities: []), + capturedInputs: [], + unavailableSourceIDs: [UsageProvider.minimax.rawValue], + codexRequests: [], + miniMaxHomePath: "/synthetic/minimax-home", + now: now, + force: true) + let snapshot = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: 10, + last30DaysCostUSD: 0.23, + daily: [ + CostUsageDailyReport.Entry( + date: "2026-07-15", + inputTokens: nil, + outputTokens: nil, + totalTokens: 10, + costUSD: 0.23, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: now) + + let result = await SpendDashboardSource.load( + request, + codexSnapshotLoader: { _ in + Issue.record("No Codex source should be loaded") + return snapshot + }, + miniMaxSnapshotLoader: { context in + #expect(context.homePath == "/synthetic/minimax-home") + return snapshot + }) + + #expect(result.inputs.map(\.id) == ["minimax:local"]) + #expect(result.failedSourceIDs.isEmpty) + } + + @Test + func `manual refresh reconciliation retains successful local history`() async throws { + let now = Date(timeIntervalSince1970: 1_784_179_200) + let configuration = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.kimi.rawValue], + codexAccountIdentities: []) + let request = SpendDashboardLoadRequest( + configuration: configuration, + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [], + now: now, + force: true) + let input = SpendDashboardModel.ProviderInput( + id: "kimi:local", + provider: .kimi, + displayName: "Kimi Code CLI", + snapshot: Self.snapshot(now: now, cost: 2)) + let defaults = try #require(UserDefaults(suiteName: "SpendDashboardLocalHistoryRecoveryTests")) + defaults.removePersistentDomain(forName: "SpendDashboardLocalHistoryRecoveryTests") + let controller = SpendDashboardController( + userDefaults: defaults, + requestBuilder: { _ in request }, + loader: { _ in + SpendDashboardLoadResult(inputs: [input], failedSourceIDs: []) + }) + + controller.update(configuration: configuration, force: true) + await Self.waitUntil { !controller.isRefreshing } + + #expect(controller.model.groups.first?.providers.map(\.id) == ["kimi:local"]) + #expect(controller.model.groups.first?.totalCost == 2) + } + + private static func snapshot(now: Date, cost: Double) -> CostUsageTokenSnapshot { + CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: 10, + last30DaysCostUSD: cost, + daily: [ + CostUsageDailyReport.Entry( + date: "2026-07-15", + inputTokens: nil, + outputTokens: nil, + totalTokens: 10, + costUSD: cost, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: now) + } + + private static func waitUntil(_ condition: @MainActor () -> Bool) async { + for _ in 0..<1000 { + if condition() { + return + } + await Task.yield() + } + Issue.record("Timed out waiting for controller state") + } +} diff --git a/Tests/CodexBarTests/SpendDashboardModelTests.swift b/Tests/CodexBarTests/SpendDashboardModelTests.swift index 2082c8e4ab..2adb11d031 100644 --- a/Tests/CodexBarTests/SpendDashboardModelTests.swift +++ b/Tests/CodexBarTests/SpendDashboardModelTests.swift @@ -10,11 +10,15 @@ struct SpendDashboardModelTests { CodexBarLocalizationOverride.$appLanguage.withValue("en") { #expect(spendDashboardRefreshFailureText(1) == "Refresh failures: 1") #expect(spendDashboardRefreshFailureText(2) == "Refresh failures: 2") - #expect(spendDashboardCoverageText(covered: 3, requested: 7) == "Coverage: 3 / 7") + #expect(spendDashboardCoverageText( + covered: 30, + requested: 365) == "Common complete coverage: 30d · Time range: Cumulative") } CodexBarLocalizationOverride.$appLanguage.withValue("de") { #expect(spendDashboardRefreshFailureText(1234) == "Fehlgeschlagene Aktualisierungen: 1.234") - #expect(spendDashboardCoverageText(covered: 3, requested: 30) == "Abdeckung: 3 / 30") + #expect(spendDashboardCoverageText( + covered: 7, + requested: 30) == "Abdeckung: 7d · Zeitraum: 30d") } CodexBarLocalizationOverride.$appLanguage.withValue("fa") { #expect(codexBarLocalizedInteger(12) == "۱۲") @@ -23,10 +27,51 @@ struct SpendDashboardModelTests { #expect(spendDashboardDayRangeText(365) == "تجمعی") #expect(spendDashboardRankText(1234) == "#۱٬۲۳۴") #expect(spendDashboardRefreshFailureText(2) == "\(L("Refresh failures")): ۲") - #expect(spendDashboardCoverageText(covered: 3, requested: 30) == "پوشش: ۳ / ۳۰") + #expect(spendDashboardCoverageText( + covered: 7, + requested: 30) == "پوشش: ۷ روز · بازه زمانی: ۳۰ روز") } } + @Test + func `tracked token subtotal keeps known values when another subscription is incomplete`() throws { + let known = Self.input(id: "known", provider: .codex, currency: "USD", cost: 2) + let incomplete = SpendDashboardModel.ProviderInput( + id: "incomplete", + provider: .minimax, + displayName: "MiniMax", + snapshot: CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: nil, + last30DaysCostUSD: nil, + currencyCode: "USD", + historyDays: 30, + daily: [CostUsageDailyReport.Entry( + date: "2026-07-16", + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + costUSD: nil, + modelsUsed: ["MiniMax-M3"], + modelBreakdowns: [CostUsageDailyReport.ModelBreakdown( + modelName: "MiniMax-M3", + costUSD: nil, + totalTokens: nil)])], + updatedAt: Self.now)) + + let group = try #require(SpendDashboardModel.build( + inputs: [known, incomplete], + requestedDays: 30, + now: Self.now, + calendar: Self.calendar).groups.first) + + let miniMax = try #require(group.providers.first { $0.provider == .minimax }) + let knownTokens = try #require(known.snapshot.daily.first?.totalTokens) + #expect(miniMax.totalTokens == nil) + #expect(group.totalTokens == knownTokens) + } + @Test func `Codex account indices use app locale numerals`() throws { let home = FileManager.default.temporaryDirectory @@ -318,8 +363,8 @@ struct SpendDashboardModelTests { now: Self.now, calendar: Self.calendar).groups.first) - #expect(group.totalCost == nil) - #expect(group.totalTokens == nil) + #expect(group.totalCost == 4) + #expect(group.totalTokens == 10) #expect(group.modelHistoryCompleteness == .incomplete) #expect(group.models.map(\.provider) == [.claude]) #expect(group.models.map(\.modelName) == ["test-model"]) @@ -890,7 +935,7 @@ struct SpendDashboardModelTests { #expect(group.providers.first(where: { $0.id == "invalid" })?.totalCost == nil) #expect(group.totalCost == nil) - #expect(group.totalTokens == nil) + #expect(group.totalTokens == 20) #expect(group.dailyPoints.isEmpty) } @@ -1012,7 +1057,7 @@ struct SpendDashboardModelTests { #expect(group.providers.first(where: { $0.id == "nonfinite" })?.totalTokens == 2) #expect(group.providers.filter { $0.id != "nonfinite" }.allSatisfy { $0.totalTokens == nil }) #expect(group.totalCost == nil) - #expect(group.totalTokens == nil) + #expect(group.totalTokens == 2) } @Test diff --git a/Tests/CodexBarTests/SpendModelIdentityTests.swift b/Tests/CodexBarTests/SpendModelIdentityTests.swift index 754d5102ca..fe35ac4ae5 100644 --- a/Tests/CodexBarTests/SpendModelIdentityTests.swift +++ b/Tests/CodexBarTests/SpendModelIdentityTests.swift @@ -68,6 +68,26 @@ struct SpendModelIdentityTests { #expect(SpendModelIdentity(rawName: "k2.5", provider: .kimi).displayName == "Kimi K2.5") let coding = SpendModelIdentity(rawName: "kimi-code/kimi-for-coding-highspeed", provider: .kimi) #expect(coding.displayName == "Kimi for Coding High-Speed") + #expect(SpendModelIdentity(rawName: "k3-256k", provider: .kimi).displayName == "Kimi K3 (256K)") + } + + @Test + func `identity names Antigravity aliases by their public model tier`() { + #expect(SpendModelIdentity( + rawName: "gemini-pro-default", + provider: .antigravity).displayName == "Gemini 3.1 Pro") + #expect(SpendModelIdentity( + rawName: "gemini-3-flash-a", + provider: .antigravity).displayName == "Gemini 3.5 Flash (High)") + #expect(SpendModelIdentity( + rawName: "gemini-3.5-flash-low", + provider: .antigravity).displayName == "Gemini 3.5 Flash (Medium)") + #expect(SpendModelIdentity( + rawName: "gemini-3.5-flash-extra-low", + provider: .antigravity).displayName == "Gemini 3.5 Flash (Low)") + #expect(SpendModelIdentity( + rawName: "gemini-default", + provider: .antigravity).displayName == "Gemini 3 Flash") } @Test From 2cf1d8481386d9bc367a8404c109d99ddfa4fc3a Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Mon, 27 Jul 2026 01:21:11 +0800 Subject: [PATCH 04/14] perf: bound local usage scans --- .../CodexBar/SpendDashboardController.swift | 60 ++++++-------- .../CodexBarCore/CostUsageScanExecutor.swift | 9 +- .../AntigravitySessionScanner.swift | 73 +++++++++------- .../Gemini/GeminiSessionScanner.swift | 19 +++++ .../Kimi/KimiCodeSessionScanner.swift | 83 +++++++++++++------ .../MiniMax/MiniMaxSessionScanner.swift | 45 +++++++++- .../OpenCode/OpenCodeSessionScanner.swift | 41 +++++++-- .../CostUsageScannerPriorityTests.swift | 4 +- .../KimiCodeSessionScannerTests.swift | 33 ++++++++ .../LocalizationLanguageCatalogTests.swift | 7 +- .../MiniMaxSessionScannerTests.swift | 46 +++++++++- .../SpendDashboardDateTruthTests.swift | 4 +- ...SpendDashboardSourceConcurrencyTests.swift | 17 ++-- 13 files changed, 322 insertions(+), 119 deletions(-) diff --git a/Sources/CodexBar/SpendDashboardController.swift b/Sources/CodexBar/SpendDashboardController.swift index 527c81960a..62c6f88cab 100644 --- a/Sources/CodexBar/SpendDashboardController.swift +++ b/Sources/CodexBar/SpendDashboardController.swift @@ -525,71 +525,61 @@ enum SpendDashboardSource { private static func loadKimiCodeSnapshot( _ context: KimiCodeSpendSnapshotLoadContext) async throws -> CostUsageTokenSnapshot? { - try await Task.detached(priority: .utility) { - try Task.checkCancellation() - let snapshot = KimiCodeSessionScanner.scan( + try await CostUsageScanExecutor.run { checkCancellation in + try KimiCodeSessionScanner.scanCancellable( environment: [KimiSettingsReader.codeHomeEnvironmentKey: context.homePath], historyDays: context.historyDays, - now: context.now) - try Task.checkCancellation() - return snapshot - }.value + now: context.now, + checkCancellation: checkCancellation) + } } private static func loadGeminiSnapshot( _ context: GeminiSpendSnapshotLoadContext) async throws -> CostUsageTokenSnapshot? { - try await Task.detached(priority: .utility) { - try Task.checkCancellation() - let snapshot = GeminiSessionScanner.scan( + try await CostUsageScanExecutor.run { checkCancellation in + try GeminiSessionScanner.scanCancellable( environment: [GeminiSessionScanner.cliHomeEnvironmentKey: context.homePath], historyDays: context.historyDays, - now: context.now) - try Task.checkCancellation() - return snapshot - }.value + now: context.now, + checkCancellation: checkCancellation) + } } private static func loadOpenCodeSnapshot( _ context: OpenCodeSpendSnapshotLoadContext) async throws -> CostUsageTokenSnapshot? { - try await Task.detached(priority: .utility) { - try Task.checkCancellation() - let snapshot = OpenCodeSessionScanner.scan( + try await CostUsageScanExecutor.run { checkCancellation in + try OpenCodeSessionScanner.scanCancellable( environment: [OpenCodeSessionScanner.dataHomeEnvironmentKey: context.homePath], historyDays: context.historyDays, - now: context.now) - try Task.checkCancellation() - return snapshot - }.value + now: context.now, + checkCancellation: checkCancellation) + } } private static func loadMiniMaxSnapshot( _ context: MiniMaxSpendSnapshotLoadContext) async throws -> CostUsageTokenSnapshot? { - try await Task.detached(priority: .utility) { - try Task.checkCancellation() - let snapshot = MiniMaxSessionScanner.scan( + try await CostUsageScanExecutor.run { checkCancellation in + try MiniMaxSessionScanner.scanCancellable( environment: [MiniMaxSessionScanner.homeEnvironmentKey: context.homePath], historyDays: context.historyDays, - now: context.now) - try Task.checkCancellation() - return snapshot - }.value + now: context.now, + checkCancellation: checkCancellation) + } } private static func loadAntigravitySnapshot( _ context: AntigravitySpendSnapshotLoadContext) async throws -> CostUsageTokenSnapshot? { - try await Task.detached(priority: .utility) { - try Task.checkCancellation() - let snapshot = AntigravitySessionScanner.scan( + try await CostUsageScanExecutor.run { checkCancellation in + try AntigravitySessionScanner.scanCancellable( environment: [AntigravitySessionScanner.homeEnvironmentKey: context.homePath], historyDays: context.historyDays, - now: context.now) - try Task.checkCancellation() - return snapshot - }.value + now: context.now, + checkCancellation: checkCancellation) + } } /// Main-actor capture of the Gemini CLI home, mirroring `KimiSettingsReader.kimiCodeHomeURL` diff --git a/Sources/CodexBarCore/CostUsageScanExecutor.swift b/Sources/CodexBarCore/CostUsageScanExecutor.swift index aef8b2b78d..3aec3ada70 100644 --- a/Sources/CodexBarCore/CostUsageScanExecutor.swift +++ b/Sources/CodexBarCore/CostUsageScanExecutor.swift @@ -120,7 +120,14 @@ public enum CostUsageScanExecutor { guard state.install(continuation) else { return } queue.async { guard state.begin() else { return } - state.complete(with: Result { try work(checkCancellation) }) + #if canImport(ObjectiveC) + let result = autoreleasepool { + Result { try work(checkCancellation) } + } + #else + let result = Result { try work(checkCancellation) } + #endif + state.complete(with: result) } } } onCancel: { diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravitySessionScanner.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravitySessionScanner.swift index e111677086..e57cc28268 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravitySessionScanner.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravitySessionScanner.swift @@ -156,37 +156,51 @@ public enum AntigravitySessionScanner { calendar: Calendar = .current, modelsDevCacheRoot: URL? = nil) -> CostUsageTokenSnapshot? { + try? self.scanCancellable( + environment: environment, + historyDays: historyDays, + now: now, + calendar: calendar, + modelsDevCacheRoot: modelsDevCacheRoot) + } + + public static func scanCancellable( + environment: [String: String] = ProcessInfo.processInfo.environment, + historyDays: Int = defaultHistoryDays, + now: Date = Date(), + calendar: Calendar = .current, + modelsDevCacheRoot: URL? = nil, + checkCancellation: @escaping () throws -> Void = {}) throws -> CostUsageTokenSnapshot? + { + try checkCancellation() let days = max(1, historyDays) let conversationsURL = self.conversationsURL(environment: environment) let databaseURLs = self.conversationDatabases(under: conversationsURL) guard !databaseURLs.isEmpty else { return nil } let modelsDevCatalog = CostUsagePricing.modelsDevCatalog(now: now, cacheRoot: modelsDevCacheRoot) - - var rows: [UsageRow] = [] - var seenResponseIDs: Set = [] - for databaseURL in databaseURLs { - self.readRows( - databaseURL: databaseURL, - catalog: modelsDevCatalog, - cacheRoot: modelsDevCacheRoot, - into: &rows, - seenResponseIDs: &seenResponseIDs) - } - guard !rows.isEmpty else { return nil } - let end = calendar.startOfDay(for: now) let start = calendar.date(byAdding: .day, value: -(days - 1), to: end) ?? end var values: [DayModelKey: TokenAccumulator] = [:] - - for row in rows { - let date = Date(timeIntervalSince1970: TimeInterval(row.createdMs) / 1000) - let day = calendar.startOfDay(for: date) - guard day >= start, day <= end else { continue } - let key = DayModelKey(day: CostUsageLocalDay.key(from: day, calendar: calendar), model: row.model) - var value = values[key] ?? TokenAccumulator() - guard value.add(row) else { continue } - values[key] = value + var seenResponseIDs: Set = [] + for databaseURL in databaseURLs { + try checkCancellation() + try self.readRows( + databaseURL: databaseURL, + pricing: (catalog: modelsDevCatalog, cacheRoot: modelsDevCacheRoot), + seenResponseIDs: &seenResponseIDs, + checkCancellation: checkCancellation) + { row in + let date = Date(timeIntervalSince1970: TimeInterval(row.createdMs) / 1000) + let day = calendar.startOfDay(for: date) + guard day >= start, day <= end else { return } + let key = DayModelKey( + day: CostUsageLocalDay.key(from: day, calendar: calendar), + model: row.model) + var value = values[key] ?? TokenAccumulator() + guard value.add(row) else { return } + values[key] = value + } } guard !values.isEmpty else { return nil } @@ -291,10 +305,10 @@ public enum AntigravitySessionScanner { private static func readRows( databaseURL: URL, - catalog: ModelsDevCatalog?, - cacheRoot: URL?, - into rows: inout [UsageRow], - seenResponseIDs: inout Set) + pricing: (catalog: ModelsDevCatalog?, cacheRoot: URL?), + seenResponseIDs: inout Set, + checkCancellation: () throws -> Void, + onRow: (UsageRow) -> Void) throws { var db: OpaquePointer? // Read the main database file directly (immutable=1) to avoid WAL -shm/-wal setup on a @@ -316,6 +330,7 @@ public enum AntigravitySessionScanner { defer { sqlite3_finalize(stmt) } while true { + try checkCancellation() let step = sqlite3_step(stmt) if step == SQLITE_DONE { break } guard step == SQLITE_ROW else { return } @@ -323,11 +338,11 @@ public enum AntigravitySessionScanner { if let row = self.parseGeneration( blob, sessionTimestampMs: sessionTimestampMs, - catalog: catalog, - cacheRoot: cacheRoot, + catalog: pricing.catalog, + cacheRoot: pricing.cacheRoot, seenResponseIDs: &seenResponseIDs) { - rows.append(row) + onRow(row) } } } diff --git a/Sources/CodexBarCore/Providers/Gemini/GeminiSessionScanner.swift b/Sources/CodexBarCore/Providers/Gemini/GeminiSessionScanner.swift index cef0d50d5f..f0458ea008 100644 --- a/Sources/CodexBarCore/Providers/Gemini/GeminiSessionScanner.swift +++ b/Sources/CodexBarCore/Providers/Gemini/GeminiSessionScanner.swift @@ -217,6 +217,23 @@ public enum GeminiSessionScanner { now: Date = Date(), calendar: Calendar = .current) -> CostUsageTokenSnapshot? { + try? self.scanCancellable( + environment: environment, + fileManager: fileManager, + historyDays: historyDays, + now: now, + calendar: calendar) + } + + public static func scanCancellable( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default, + historyDays: Int = defaultHistoryDays, + now: Date = Date(), + calendar: Calendar = .current, + checkCancellation: @escaping () throws -> Void = {}) throws -> CostUsageTokenSnapshot? + { + try checkCancellation() let days = max(1, historyDays) let tmp = self.geminiTmpURL(environment: environment) guard let enumerator = fileManager.enumerator( @@ -233,6 +250,7 @@ public enum GeminiSessionScanner { let decoder = JSONDecoder() while let url = enumerator.nextObject() as? URL { + try checkCancellation() let pathExtension = url.pathExtension.lowercased() guard pathExtension == "json" || pathExtension == "jsonl", self.isChatTranscript(url, under: tmp) @@ -252,6 +270,7 @@ public enum GeminiSessionScanner { ? self.parseStream(data: data, fallbackDate: fallbackDate, decoder: decoder) : self.parseChatRecording(data: data, fallbackDate: fallbackDate, decoder: decoder) for turn in turns { + try checkCancellation() let day = calendar.startOfDay(for: turn.date) guard day >= start, day <= end else { continue } let key = DayModelKey(day: CostUsageLocalDay.key(from: day, calendar: calendar), model: turn.model) diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiCodeSessionScanner.swift b/Sources/CodexBarCore/Providers/Kimi/KimiCodeSessionScanner.swift index 535d101e59..d988ab17ee 100644 --- a/Sources/CodexBarCore/Providers/Kimi/KimiCodeSessionScanner.swift +++ b/Sources/CodexBarCore/Providers/Kimi/KimiCodeSessionScanner.swift @@ -154,6 +154,25 @@ public enum KimiCodeSessionScanner { calendar: Calendar = .current, modelsDevCacheRoot: URL? = nil) -> CostUsageTokenSnapshot? { + try? self.scanCancellable( + environment: environment, + fileManager: fileManager, + historyDays: historyDays, + now: now, + calendar: calendar, + modelsDevCacheRoot: modelsDevCacheRoot) + } + + public static func scanCancellable( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default, + historyDays: Int = defaultHistoryDays, + now: Date = Date(), + calendar: Calendar = .current, + modelsDevCacheRoot: URL? = nil, + checkCancellation: @escaping () throws -> Void = {}) throws -> CostUsageTokenSnapshot? + { + try checkCancellation() let days = max(1, historyDays) let home = KimiSettingsReader.kimiCodeHomeURL(environment: environment) let sessions = home.appendingPathComponent("sessions", isDirectory: true) @@ -172,6 +191,7 @@ public enum KimiCodeSessionScanner { let modelsDevCatalog = CostUsagePricing.modelsDevCatalog(now: now, cacheRoot: modelsDevCacheRoot) while let url = enumerator.nextObject() as? URL { + try checkCancellation() guard url.lastPathComponent == "wire.jsonl", url.deletingLastPathComponent().deletingLastPathComponent().lastPathComponent == "agents" else { @@ -183,32 +203,45 @@ public enum KimiCodeSessionScanner { { continue } - guard let data = try? Data(contentsOf: url) else { continue } - for line in data.split(separator: 0x0A, omittingEmptySubsequences: true) { - guard let event = try? decoder.decode(WireEvent.self, from: Data(line)), - event.type == "usage.record", - event.usageScope == "turn", - let time = event.time, - time.isFinite, - let rawModel = event.model?.trimmingCharacters(in: .whitespacesAndNewlines), - !rawModel.isEmpty, - let usage = event.usage - else { - continue + do { + try CostUsageJsonl.scan( + fileURL: url, + maxLineBytes: 1024 * 1024, + prefixBytes: 1024 * 1024, + checkCancellation: checkCancellation) + { line in + guard !line.wasTruncated, + let event = try? decoder.decode(WireEvent.self, from: line.bytes), + event.type == "usage.record", + event.usageScope == "turn", + let time = event.time, + time.isFinite, + let rawModel = event.model?.trimmingCharacters(in: .whitespacesAndNewlines), + !rawModel.isEmpty, + let usage = event.usage + else { + return + } + let date = Date(timeIntervalSince1970: time / 1000) + let day = calendar.startOfDay(for: date) + guard day >= start, day <= end else { return } + let key = DayModelKey( + day: CostUsageLocalDay.key(from: day, calendar: calendar), + model: rawModel) + var value = values[key] ?? TokenAccumulator() + guard value.add( + usage, + model: rawModel, + pricingDate: date, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + else { return } + values[key] = value } - let date = Date(timeIntervalSince1970: time / 1000) - let day = calendar.startOfDay(for: date) - guard day >= start, day <= end else { continue } - let key = DayModelKey(day: CostUsageLocalDay.key(from: day, calendar: calendar), model: rawModel) - var value = values[key] ?? TokenAccumulator() - guard value.add( - usage, - model: rawModel, - pricingDate: date, - modelsDevCatalog: modelsDevCatalog, - modelsDevCacheRoot: modelsDevCacheRoot) - else { continue } - values[key] = value + } catch is CancellationError { + throw CancellationError() + } catch { + continue } } diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSessionScanner.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSessionScanner.swift index 44e582e41b..e2070915ad 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSessionScanner.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSessionScanner.swift @@ -163,17 +163,44 @@ public enum MiniMaxSessionScanner { calendar: Calendar = .current, modelsDevCacheRoot: URL? = nil) -> CostUsageTokenSnapshot? { + try? self.scanCancellable( + environment: environment, + historyDays: historyDays, + now: now, + calendar: calendar, + modelsDevCacheRoot: modelsDevCacheRoot) + } + + public static func scanCancellable( + environment: [String: String] = ProcessInfo.processInfo.environment, + historyDays: Int = defaultHistoryDays, + now: Date = Date(), + calendar: Calendar = .current, + modelsDevCacheRoot: URL? = nil, + checkCancellation: @escaping () throws -> Void = {}) throws -> CostUsageTokenSnapshot? + { + try checkCancellation() let days = max(1, historyDays) let databaseURL = self.runtimeDatabaseURL(environment: environment) guard FileManager.default.fileExists(atPath: databaseURL.path) else { return nil } - guard let rows = self.readRows(databaseURL: databaseURL), !rows.isEmpty else { return nil } - - let modelsDevCatalog = CostUsagePricing.modelsDevCatalog(now: now, cacheRoot: modelsDevCacheRoot) let end = calendar.startOfDay(for: now) let start = calendar.date(byAdding: .day, value: -(days - 1), to: end) ?? end + let until = calendar.date(byAdding: .day, value: 1, to: end) ?? now + guard let rows = try self.readRows( + databaseURL: databaseURL, + sinceMs: Int64(start.timeIntervalSince1970 * 1000), + untilMs: Int64(until.timeIntervalSince1970 * 1000), + checkCancellation: checkCancellation), + !rows.isEmpty + else { + return nil + } + + let modelsDevCatalog = CostUsagePricing.modelsDevCatalog(now: now, cacheRoot: modelsDevCacheRoot) var values: [DayModelKey: TokenAccumulator] = [:] for row in rows { + try checkCancellation() let date = Date(timeIntervalSince1970: TimeInterval(row.createdMs) / 1000) let day = calendar.startOfDay(for: date) guard day >= start, day <= end else { continue } @@ -273,7 +300,12 @@ public enum MiniMaxSessionScanner { // MARK: - SQLite - private static func readRows(databaseURL: URL) -> [UsageRow]? { + private static func readRows( + databaseURL: URL, + sinceMs: Int64, + untilMs: Int64, + checkCancellation: () throws -> Void) throws -> [UsageRow]? + { var db: OpaquePointer? // The runtime database is WAL-journaled. A plain read-only open cannot create the shared // -shm/-wal files and may surface an empty or stale snapshot; `immutable=1` tells SQLite the @@ -299,14 +331,19 @@ public enum MiniMaxSessionScanner { cache_write_tokens, cost_usd FROM local_runtime_token_usage + WHERE ts >= ? AND ts < ? + ORDER BY ts """ var stmt: OpaquePointer? guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return nil } defer { sqlite3_finalize(stmt) } + sqlite3_bind_int64(stmt, 1, sinceMs) + sqlite3_bind_int64(stmt, 2, untilMs) var rows: [UsageRow] = [] while true { + try checkCancellation() let step = sqlite3_step(stmt) if step == SQLITE_DONE { break } guard step == SQLITE_ROW else { return nil } diff --git a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeSessionScanner.swift b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeSessionScanner.swift index 7b6a3a1967..3491905129 100644 --- a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeSessionScanner.swift +++ b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeSessionScanner.swift @@ -174,23 +174,43 @@ public enum OpenCodeSessionScanner { now: Date = Date(), calendar: Calendar = .current) -> CostUsageTokenSnapshot? { + try? self.scanCancellable( + environment: environment, + fileManager: fileManager, + historyDays: historyDays, + now: now, + calendar: calendar) + } + + public static func scanCancellable( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default, + historyDays: Int = defaultHistoryDays, + now: Date = Date(), + calendar: Calendar = .current, + checkCancellation: @escaping () throws -> Void = {}) throws -> CostUsageTokenSnapshot? + { + try checkCancellation() let days = max(1, historyDays) let end = calendar.startOfDay(for: now) let start = calendar.date(byAdding: .day, value: -(days - 1), to: end) ?? end var context = ScanContext(start: start, end: end, calendar: calendar) var records: [UsageRecord] = [] - records.append(contentsOf: self.scanJSONMessages( + try records.append(contentsOf: self.scanJSONMessages( environment: environment, fileManager: fileManager, - context: &context)) - records.append(contentsOf: self.scanDatabaseMessages( + context: &context, + checkCancellation: checkCancellation)) + try records.append(contentsOf: self.scanDatabaseMessages( environment: environment, - context: &context)) + context: &context, + checkCancellation: checkCancellation)) var values: [DayModelKey: TokenAccumulator] = [:] var costs: [DayModelKey: Double] = [:] for record in records { + try checkCancellation() let key = DayModelKey(day: record.day, model: record.model) var value = values[key] ?? TokenAccumulator() guard value.add(record.usage) else { continue } @@ -274,7 +294,8 @@ public enum OpenCodeSessionScanner { private static func scanJSONMessages( environment: [String: String], fileManager: FileManager, - context: inout ScanContext) -> [UsageRecord] + context: inout ScanContext, + checkCancellation: () throws -> Void) throws -> [UsageRecord] { let start = context.start let end = context.end @@ -291,6 +312,7 @@ public enum OpenCodeSessionScanner { let decoder = JSONDecoder() var records: [UsageRecord] = [] while let url = enumerator.nextObject() as? URL { + try checkCancellation() guard url.pathExtension.lowercased() == "json" else { continue } if let modificationDate = try? url.resourceValues(forKeys: [.contentModificationDateKey]) .contentModificationDate, @@ -340,7 +362,8 @@ public enum OpenCodeSessionScanner { /// SQL so we never materialize the blob. private static func scanDatabaseMessages( environment: [String: String], - context: inout ScanContext) -> [UsageRecord] + context: inout ScanContext, + checkCancellation: () throws -> Void) throws -> [UsageRecord] { let start = context.start let end = context.end @@ -375,14 +398,20 @@ public enum OpenCodeSessionScanner { json_extract(data, '$.cost') FROM message WHERE json_extract(data, '$.role') = 'assistant' + AND json_extract(data, '$.time.created') >= ? + AND json_extract(data, '$.time.created') < ? """ var stmt: OpaquePointer? guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] } defer { sqlite3_finalize(stmt) } + let until = calendar.date(byAdding: .day, value: 1, to: end) ?? end + sqlite3_bind_int64(stmt, 1, Int64(start.timeIntervalSince1970 * 1000)) + sqlite3_bind_int64(stmt, 2, Int64(until.timeIntervalSince1970 * 1000)) var records: [UsageRecord] = [] while true { + try checkCancellation() let step = sqlite3_step(stmt) if step == SQLITE_DONE { break } guard step == SQLITE_ROW else { return [] } diff --git a/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift b/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift index 2db80641cd..8e7db813c3 100644 --- a/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift @@ -406,7 +406,9 @@ struct CostUsageScannerPriorityTests { until: day, now: day, options: options) - #expect(first.summary?.totalCostUSD == nil) + // The auto-review harness is intentionally zero-priced until the completed response + // identifies the real model. Preserve that known zero instead of reporting unavailable. + #expect(first.summary?.totalCostUSD == 0) try CostUsageScannerCodexPriorityTests.insertTestLog( dbURL: dbURL, diff --git a/Tests/CodexBarTests/KimiCodeSessionScannerTests.swift b/Tests/CodexBarTests/KimiCodeSessionScannerTests.swift index 59df9ef384..190205a7a2 100644 --- a/Tests/CodexBarTests/KimiCodeSessionScannerTests.swift +++ b/Tests/CodexBarTests/KimiCodeSessionScannerTests.swift @@ -101,6 +101,39 @@ struct KimiCodeSessionScannerTests { calendar: Self.calendar) == nil) } + @Test + func `streaming scanner cancels without loading the remaining transcript`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let agent = root + .appendingPathComponent("sessions/workspace/session-a/agents/main", isDirectory: true) + try FileManager.default.createDirectory(at: agent, withIntermediateDirectories: true) + let line = Self.usage( + time: 1_784_257_200_000, + model: "kimi-code/k3", + input: 10, + cacheRead: 20, + output: 3) + try Self.write(Array(repeating: line, count: 20000), to: agent.appendingPathComponent("wire.jsonl")) + + var checks = 0 + #expect(throws: CancellationError.self) { + _ = try KimiCodeSessionScanner.scanCancellable( + environment: [KimiSettingsReader.codeHomeEnvironmentKey: root.path], + historyDays: 30, + now: Date(timeIntervalSince1970: 1_784_347_200), + calendar: Self.calendar, + checkCancellation: { + checks += 1 + if checks >= 4 { + throw CancellationError() + } + }) + } + #expect(checks >= 4) + } + private static let calendar: Calendar = { var calendar = Calendar(identifier: .gregorian) calendar.timeZone = TimeZone(secondsFromGMT: 0)! diff --git a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift index e06e5f038a..51ea9ebbc5 100644 --- a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift +++ b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift @@ -513,21 +513,18 @@ struct LocalizationLanguageCatalogTests { "Gemini Flash", "GitHub", "Google OAuth", + "Input", "Model names are grouped after trimming and case-insensitive exact matching. " + "Sources are not deduplicated across providers.", "No", - "No priced model history", "Oasis-Token", "Other", + "Output", "Partial model history: incomplete source-days are excluded.", "Password", - "Priced model spend", "Provider", - "Show all %d models", - "Show top 20", "Token", "Tokens", - "Tracked model tokens", "%@ %@", "%@: %@", "byte_unit_byte", diff --git a/Tests/CodexBarTests/MiniMaxSessionScannerTests.swift b/Tests/CodexBarTests/MiniMaxSessionScannerTests.swift index 3e28f4e27f..a0561a154c 100644 --- a/Tests/CodexBarTests/MiniMaxSessionScannerTests.swift +++ b/Tests/CodexBarTests/MiniMaxSessionScannerTests.swift @@ -40,7 +40,35 @@ struct MiniMaxSessionScannerTests { #expect(abs((breakdown.costUSD ?? 0) - 0.000_102) < 0.000_000_001) } - private static func createDatabase(at url: URL) throws { + @Test + func `scanner filters old database rows before decoding`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("MiniMaxSessionScannerTests-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let database = root.appendingPathComponent("v2/sqlite/runtime-state.sqlite") + try FileManager.default.createDirectory( + at: database.deletingLastPathComponent(), + withIntermediateDirectories: true) + try Self.createDatabase(at: database, oldRowCount: 200) + + var checks = 0 + let snapshot = try MiniMaxSessionScanner.scanCancellable( + environment: [MiniMaxSessionScanner.homeEnvironmentKey: root.path], + historyDays: 30, + now: Date(timeIntervalSince1970: 1_785_033_600), + calendar: Self.calendar, + checkCancellation: { + checks += 1 + if checks > 10 { + throw CancellationError() + } + }) + + #expect(snapshot?.last30DaysRequests == 1) + #expect(checks <= 10) + } + + private static func createDatabase(at url: URL, oldRowCount: Int = 0) throws { var database: OpaquePointer? guard sqlite3_open(url.path, &database) == SQLITE_OK, let database else { throw SQLiteFixtureError.open @@ -75,6 +103,22 @@ struct MiniMaxSessionScannerTests { guard sqlite3_exec(database, sql, nil, nil, nil) == SQLITE_OK else { throw SQLiteFixtureError.schema } + guard oldRowCount > 0 else { return } + for index in 0.. Date: Mon, 27 Jul 2026 08:14:15 +0800 Subject: [PATCH 05/14] refactor: register dashboard history sources --- .../CodexBar/SpendDashboardController.swift | 204 +++++++++++------- .../AntigravityProviderDescriptor.swift | 1 + .../Gemini/GeminiProviderDescriptor.swift | 1 + .../Kimi/KimiProviderDescriptor.swift | 1 + .../MiniMax/MiniMaxProviderDescriptor.swift | 1 + .../OpenCode/OpenCodeProviderDescriptor.swift | 1 + .../Providers/ProviderDescriptor.swift | 20 +- .../SpendDashboardModelTests.swift | 19 ++ 8 files changed, 174 insertions(+), 74 deletions(-) diff --git a/Sources/CodexBar/SpendDashboardController.swift b/Sources/CodexBar/SpendDashboardController.swift index 62c6f88cab..38b8ce7956 100644 --- a/Sources/CodexBar/SpendDashboardController.swift +++ b/Sources/CodexBar/SpendDashboardController.swift @@ -38,6 +38,12 @@ struct CodexSpendScanRequest: Equatable, Sendable { let cacheIdentity: String } +struct LocalSpendHistoryRequest: Equatable, Sendable { + let source: ProviderLocalHistorySource + let provider: UsageProvider + let homePath: String +} + enum SpendDashboardRequestBuildMode: Equatable, Sendable { case refreshMissing case forceRefresh @@ -62,20 +68,37 @@ struct SpendDashboardLoadRequest: Sendable { let unavailableSourceIDs: Set let confirmedEmptySourceIDs: Set let codexRequests: [CodexSpendScanRequest] - let kimiCodeHomePath: String? - let geminiCLIHomePath: String? - let openCodeDataHomePath: String? - let miniMaxHomePath: String? - let antigravityHomePath: String? + let localHistoryRequests: [LocalSpendHistoryRequest] let now: Date let force: Bool + var kimiCodeHomePath: String? { + self.localHistoryRequests.first { $0.source == .kimiCode }?.homePath + } + + var geminiCLIHomePath: String? { + self.localHistoryRequests.first { $0.source == .geminiCLI }?.homePath + } + + var openCodeDataHomePath: String? { + self.localHistoryRequests.first { $0.source == .openCode }?.homePath + } + + var miniMaxHomePath: String? { + self.localHistoryRequests.first { $0.source == .miniMax }?.homePath + } + + var antigravityHomePath: String? { + self.localHistoryRequests.first { $0.source == .antigravity }?.homePath + } + init( configuration: SpendDashboardConfiguration, capturedInputs: [SpendDashboardModel.ProviderInput], unavailableSourceIDs: Set, confirmedEmptySourceIDs: Set = [], codexRequests: [CodexSpendScanRequest], + localHistoryRequests: [LocalSpendHistoryRequest]? = nil, kimiCodeHomePath: String? = nil, geminiCLIHomePath: String? = nil, openCodeDataHomePath: String? = nil, @@ -89,11 +112,23 @@ struct SpendDashboardLoadRequest: Sendable { self.unavailableSourceIDs = unavailableSourceIDs self.confirmedEmptySourceIDs = confirmedEmptySourceIDs self.codexRequests = codexRequests - self.kimiCodeHomePath = kimiCodeHomePath - self.geminiCLIHomePath = geminiCLIHomePath - self.openCodeDataHomePath = openCodeDataHomePath - self.miniMaxHomePath = miniMaxHomePath - self.antigravityHomePath = antigravityHomePath + self.localHistoryRequests = localHistoryRequests ?? [ + kimiCodeHomePath.map { + LocalSpendHistoryRequest(source: .kimiCode, provider: .kimi, homePath: $0) + }, + geminiCLIHomePath.map { + LocalSpendHistoryRequest(source: .geminiCLI, provider: .gemini, homePath: $0) + }, + openCodeDataHomePath.map { + LocalSpendHistoryRequest(source: .openCode, provider: .opencode, homePath: $0) + }, + miniMaxHomePath.map { + LocalSpendHistoryRequest(source: .miniMax, provider: .minimax, homePath: $0) + }, + antigravityHomePath.map { + LocalSpendHistoryRequest(source: .antigravity, provider: .antigravity, homePath: $0) + }, + ].compactMap(\.self) self.now = now self.force = force } @@ -300,21 +335,7 @@ enum SpendDashboardSource { unavailableSourceIDs: unavailableSourceIDs, confirmedEmptySourceIDs: confirmedEmptySourceIDs, codexRequests: codexRequests, - kimiCodeHomePath: self.localModelHistoryProviders(store: store).contains(.kimi) - ? KimiSettingsReader.kimiCodeHomeURL().path - : nil, - geminiCLIHomePath: self.localModelHistoryProviders(store: store).contains(.gemini) - ? Self.geminiCLIHomeURL().path - : nil, - openCodeDataHomePath: self.localModelHistoryProviders(store: store).contains(.opencode) - ? Self.openCodeDataHomeURL().path - : nil, - miniMaxHomePath: self.localModelHistoryProviders(store: store).contains(.minimax) - ? Self.miniMaxHomeURL().path - : nil, - antigravityHomePath: self.localModelHistoryProviders(store: store).contains(.antigravity) - ? Self.antigravityHomeURL().path - : nil, + localHistoryRequests: self.localHistoryRequests(store: store), now: captureNow, force: mode.forcesLoader) } @@ -428,53 +449,60 @@ enum SpendDashboardSource { failedSourceIDs.insert(sourceID) } } - let localSources: [LocalSnapshotSource] = [ - LocalSnapshotSource( - homePath: request.kimiCodeHomePath, - sourceID: "kimi:local", - provider: .kimi, - displayName: "Kimi Code CLI", - load: { homePath in - try await kimiCodeSnapshotLoader(KimiCodeSpendSnapshotLoadContext( - homePath: homePath, now: request.now, historyDays: Self.scanDays)) - }), - LocalSnapshotSource( - homePath: request.geminiCLIHomePath, - sourceID: "gemini:local", - provider: .gemini, - displayName: "Gemini CLI", - load: { homePath in - try await geminiSnapshotLoader(GeminiSpendSnapshotLoadContext( - homePath: homePath, now: request.now, historyDays: Self.scanDays)) - }), - LocalSnapshotSource( - homePath: request.openCodeDataHomePath, - sourceID: "opencode:local", - provider: .opencode, - displayName: "OpenCode", - load: { homePath in - try await openCodeSnapshotLoader(OpenCodeSpendSnapshotLoadContext( - homePath: homePath, now: request.now, historyDays: Self.scanDays)) - }), - LocalSnapshotSource( - homePath: request.miniMaxHomePath, - sourceID: "minimax:local", - provider: .minimax, - displayName: "MiniMax", - load: { homePath in - try await miniMaxSnapshotLoader(MiniMaxSpendSnapshotLoadContext( - homePath: homePath, now: request.now, historyDays: Self.scanDays)) - }), - LocalSnapshotSource( - homePath: request.antigravityHomePath, - sourceID: "antigravity:local", - provider: .antigravity, - displayName: "Antigravity", - load: { homePath in - try await antigravitySnapshotLoader(AntigravitySpendSnapshotLoadContext( - homePath: homePath, now: request.now, historyDays: Self.scanDays)) - }), - ] + let localSources: [LocalSnapshotSource] = request.localHistoryRequests.map { localRequest in + switch localRequest.source { + case .kimiCode: + LocalSnapshotSource( + homePath: localRequest.homePath, + sourceID: "\(localRequest.provider.rawValue):local", + provider: localRequest.provider, + displayName: "Kimi Code CLI", + load: { homePath in + try await kimiCodeSnapshotLoader(KimiCodeSpendSnapshotLoadContext( + homePath: homePath, now: request.now, historyDays: Self.scanDays)) + }) + case .geminiCLI: + LocalSnapshotSource( + homePath: localRequest.homePath, + sourceID: "\(localRequest.provider.rawValue):local", + provider: localRequest.provider, + displayName: "Gemini CLI", + load: { homePath in + try await geminiSnapshotLoader(GeminiSpendSnapshotLoadContext( + homePath: homePath, now: request.now, historyDays: Self.scanDays)) + }) + case .openCode: + LocalSnapshotSource( + homePath: localRequest.homePath, + sourceID: "\(localRequest.provider.rawValue):local", + provider: localRequest.provider, + displayName: "OpenCode", + load: { homePath in + try await openCodeSnapshotLoader(OpenCodeSpendSnapshotLoadContext( + homePath: homePath, now: request.now, historyDays: Self.scanDays)) + }) + case .miniMax: + LocalSnapshotSource( + homePath: localRequest.homePath, + sourceID: "\(localRequest.provider.rawValue):local", + provider: localRequest.provider, + displayName: "MiniMax", + load: { homePath in + try await miniMaxSnapshotLoader(MiniMaxSpendSnapshotLoadContext( + homePath: homePath, now: request.now, historyDays: Self.scanDays)) + }) + case .antigravity: + LocalSnapshotSource( + homePath: localRequest.homePath, + sourceID: "\(localRequest.provider.rawValue):local", + provider: localRequest.provider, + displayName: "Antigravity", + load: { homePath in + try await antigravitySnapshotLoader(AntigravitySpendSnapshotLoadContext( + homePath: homePath, now: request.now, historyDays: Self.scanDays)) + }) + } + } for source in localSources { do { if let input = try await source.loadInput() { @@ -654,7 +682,37 @@ enum SpendDashboardSource { @MainActor static func localModelHistoryProviders(store: UsageStore) -> [UsageProvider] { store.enabledProvidersForDisplay().filter { - $0 == .kimi || $0 == .gemini || $0 == .opencode || $0 == .minimax || $0 == .antigravity + !ProviderDescriptorRegistry.descriptor(for: $0).tokenCost.localHistorySources.isEmpty + } + } + + @MainActor + static func localHistoryRequests(store: UsageStore) -> [LocalSpendHistoryRequest] { + self.localModelHistoryProviders(store: store).flatMap { provider in + ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.localHistorySources.map { source in + LocalSpendHistoryRequest( + source: source, + provider: provider, + homePath: self.localHistoryHomeURL(for: source).path) + } + } + } + + private static func localHistoryHomeURL( + for source: ProviderLocalHistorySource, + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + switch source { + case .kimiCode: + KimiSettingsReader.kimiCodeHomeURL(environment: environment) + case .geminiCLI: + self.geminiCLIHomeURL(environment: environment) + case .openCode: + self.openCodeDataHomeURL(environment: environment) + case .miniMax: + self.miniMaxHomeURL(environment: environment) + case .antigravity: + self.antigravityHomeURL(environment: environment) } } diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift index db210b6069..d8929f27ba 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift @@ -35,6 +35,7 @@ public enum AntigravityProviderDescriptor { ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, + localHistorySources: [.antigravity], noDataMessage: { "Antigravity cost summary is not supported." }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .cli, .oauth], diff --git a/Sources/CodexBarCore/Providers/Gemini/GeminiProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Gemini/GeminiProviderDescriptor.swift index 4cb101eb3f..68cd067d40 100644 --- a/Sources/CodexBarCore/Providers/Gemini/GeminiProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Gemini/GeminiProviderDescriptor.swift @@ -36,6 +36,7 @@ public enum GeminiProviderDescriptor { ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, + localHistorySources: [.geminiCLI], noDataMessage: { "Gemini cost summary is not supported." }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .api], diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift index 14d93764e8..964adabb43 100644 --- a/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift @@ -34,6 +34,7 @@ public enum KimiProviderDescriptor { ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, + localHistorySources: [.kimiCode], noDataMessage: { "Kimi cost summary is not supported." }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .api, .web], diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift index 4d02782100..e085b332a3 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift @@ -34,6 +34,7 @@ public enum MiniMaxProviderDescriptor { ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, + localHistorySources: [.miniMax], noDataMessage: { "MiniMax cost summary is not supported." }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .web, .api], diff --git a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeProviderDescriptor.swift index 54883af5d4..ec5f1f219f 100644 --- a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeProviderDescriptor.swift @@ -34,6 +34,7 @@ public enum OpenCodeProviderDescriptor { ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, + localHistorySources: [.openCode], noDataMessage: { "OpenCode cost summary is not supported." }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .web], diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift index 8e5f757011..3c839397ba 100644 --- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift @@ -1,13 +1,31 @@ import Foundation +public enum ProviderLocalHistorySource: String, CaseIterable, Sendable { + case antigravity + case geminiCLI + case kimiCode + case miniMax + case openCode +} + public struct ProviderTokenCostConfig: Sendable { public let supportsTokenCost: Bool + public let localHistorySources: [ProviderLocalHistorySource] public let noDataMessage: @Sendable () -> String - public init(supportsTokenCost: Bool, noDataMessage: @escaping @Sendable () -> String) { + public init( + supportsTokenCost: Bool, + localHistorySources: [ProviderLocalHistorySource] = [], + noDataMessage: @escaping @Sendable () -> String) + { self.supportsTokenCost = supportsTokenCost + self.localHistorySources = localHistorySources self.noDataMessage = noDataMessage } + + public var supportsDashboardHistory: Bool { + self.supportsTokenCost || !self.localHistorySources.isEmpty + } } public struct ProviderDescriptor: Sendable { diff --git a/Tests/CodexBarTests/SpendDashboardModelTests.swift b/Tests/CodexBarTests/SpendDashboardModelTests.swift index 2adb11d031..83864fad58 100644 --- a/Tests/CodexBarTests/SpendDashboardModelTests.swift +++ b/Tests/CodexBarTests/SpendDashboardModelTests.swift @@ -118,6 +118,25 @@ struct SpendDashboardModelTests { #expect(providers == [.codex, .claude, .vertexai, .openai, .mistral, .bedrock, .cursor, .opencodego]) } + @Test + func `dashboard history capability is declared by provider descriptors`() { + let descriptors = ProviderDescriptorRegistry.all + let localDescriptors = descriptors.filter { !$0.tokenCost.localHistorySources.isEmpty } + let declaredSources = localDescriptors.flatMap(\.tokenCost.localHistorySources) + + #expect(Set(declaredSources) == Set(ProviderLocalHistorySource.allCases)) + #expect(declaredSources.count == Set(declaredSources).count) + #expect(Set(localDescriptors.map(\.id)) == [.antigravity, .gemini, .kimi, .minimax, .opencode]) + + let dashboardProviders = Set(descriptors + .filter(\.tokenCost.supportsDashboardHistory) + .map(\.id)) + let expectedProviders = Set(descriptors + .filter { $0.tokenCost.supportsTokenCost || !$0.tokenCost.localHistorySources.isEmpty } + .map(\.id)) + #expect(dashboardProviders == expectedProviders) + } + @Test func `native currencies stay separate and rank only within their currency`() throws { let model = SpendDashboardModel.build( From 2be387eedd9c74da26dfe0936b1ae745eaee2176 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:28:52 +0800 Subject: [PATCH 06/14] fix: preserve merged subscription history coverage --- .../CodexBar/SpendBillingAttribution.swift | 10 ++- .../SpendBillingAttributionTests.swift | 72 ++++++++++++++++++- 2 files changed, 78 insertions(+), 4 deletions(-) diff --git a/Sources/CodexBar/SpendBillingAttribution.swift b/Sources/CodexBar/SpendBillingAttribution.swift index c29c8db2e2..8dae5321b4 100644 --- a/Sources/CodexBar/SpendBillingAttribution.swift +++ b/Sources/CodexBar/SpendBillingAttribution.swift @@ -182,8 +182,14 @@ enum SpendBillingAttribution { } let daily = Self.mergeByDay(historyInputs.flatMap(\.snapshot.daily)) - let historyDays = historyInputs.map(\.snapshot.historyDays).min() ?? first.snapshot.historyDays - let updatedAt = historyInputs.map(\.snapshot.updatedAt).min() ?? first.snapshot.updatedAt + // These inputs are independent histories billed by the same vendor (for example, Codex + // routing MiniMax plus MiniMax Code's native SQLite history). The merged snapshot contains + // the union of their daily entries, so its coverage bounds must describe that union too. + // Using the shortest history and oldest refresh time makes valid entries from the newer or + // longer source fall outside `sourceCoverageInterval`; the dashboard then downgrades both + // an inactive recent window and cumulative spend to "unavailable". + let historyDays = historyInputs.map(\.snapshot.historyDays).max() ?? first.snapshot.historyDays + let updatedAt = historyInputs.map(\.snapshot.updatedAt).max() ?? first.snapshot.updatedAt let snapshot = CostUsageTokenSnapshot( sessionTokens: nil, sessionCostUSD: nil, diff --git a/Tests/CodexBarTests/SpendBillingAttributionTests.swift b/Tests/CodexBarTests/SpendBillingAttributionTests.swift index f6cc67dbd1..e205b5963e 100644 --- a/Tests/CodexBarTests/SpendBillingAttributionTests.swift +++ b/Tests/CodexBarTests/SpendBillingAttributionTests.swift @@ -221,6 +221,69 @@ struct SpendBillingAttributionTests { #expect(abs((cumulative.totalCost ?? 0) - 0.23) < 0.000_001) } + @Test + func `merged MiniMax sources preserve their union of history windows`() throws { + let now = Date(timeIntervalSince1970: 1_785_427_200) // 2026-07-29 00:00:00 UTC + let routedEntry = Self.entry( + date: "2026-06-30", + model: "MiniMax-M3", + cost: 1, + tokens: 1000) + let localEntry = Self.entry( + date: "2026-07-12", + model: "minimax/MiniMax-M3", + cost: 0.23, + tokens: 1_738_342) + let routed = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: routedEntry.totalTokens, + last30DaysCostUSD: routedEntry.costUSD, + currencyCode: "USD", + historyDays: 30, + historyCoverageIsEstablished: true, + daily: [routedEntry], + updatedAt: Date(timeIntervalSince1970: 1_783_396_800)) // 2026-07-05 UTC + let local = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: localEntry.totalTokens, + last30DaysCostUSD: localEntry.costUSD, + currencyCode: "USD", + historyDays: 365, + historyCoverageIsEstablished: true, + daily: [localEntry], + updatedAt: now) + let inputs = [ + SpendDashboardModel.ProviderInput( + id: "codex", + provider: .codex, + displayName: "Codex", + snapshot: routed), + SpendDashboardModel.ProviderInput( + id: "minimax:local", + provider: .minimax, + displayName: "MiniMax", + snapshot: local), + ] + + let recent = try #require(SpendDashboardModel.build( + inputs: inputs, + requestedDays: 7, + now: now, + calendar: Self.calendar).groups.first?.providers.first { $0.provider == .minimax }) + let cumulative = try #require(SpendDashboardModel.build( + inputs: inputs, + requestedDays: 365, + now: now, + calendar: Self.calendar).groups.first?.providers.first { $0.provider == .minimax }) + + #expect(recent.totalTokens == 0) + #expect(recent.totalCost == 0) + #expect(cumulative.totalTokens == 1_739_342) + #expect(abs((cumulative.totalCost ?? 0) - 1.23) < 0.000_001) + } + @Test func `model provider identity follows the model vendor instead of its harness`() { #expect(SpendProviderIdentity.modelProvider( @@ -234,9 +297,14 @@ struct SpendBillingAttributionTests { #expect(SpendProviderIdentity.modelProvider(rawName: "future-model", fallback: .cursor) == .cursor) } - private static func entry(model: String, cost: Double, tokens: Int) -> CostUsageDailyReport.Entry { + private static func entry( + date: String = "2026-07-24", + model: String, + cost: Double, + tokens: Int) -> CostUsageDailyReport.Entry + { CostUsageDailyReport.Entry( - date: "2026-07-24", + date: date, inputTokens: tokens / 2, outputTokens: tokens - tokens / 2, totalTokens: tokens, From 5603bb3cf7cce37c8a846c2a2f717af0363485ee Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:55:08 +0800 Subject: [PATCH 07/14] refine model chart branding --- .../CodexBar/PreferencesSpendModelsView.swift | 30 ++------ Sources/CodexBar/SpendClientsView.swift | 14 +--- Sources/CodexBar/SpendModelIdentity.swift | 73 ++++++++++++++++++- .../SpendDashboardKimiModelTests.swift | 2 +- .../SpendDashboardModelTests.swift | 2 +- .../SpendModelIdentityTests.swift | 11 +++ 6 files changed, 94 insertions(+), 38 deletions(-) diff --git a/Sources/CodexBar/PreferencesSpendModelsView.swift b/Sources/CodexBar/PreferencesSpendModelsView.swift index 40be4dae01..aee3aa31eb 100644 --- a/Sources/CodexBar/PreferencesSpendModelsView.swift +++ b/Sources/CodexBar/PreferencesSpendModelsView.swift @@ -485,7 +485,7 @@ struct SpendModelsSection: View { SpendModelsDayDetailView( detail: detail, metric: self.selectedMetric, - colorForModel: { self.modelColor(for: $0) }) + colorForModel: { _ in Color.accentColor }) } self.ranking } @@ -531,7 +531,7 @@ struct SpendModelsSection: View { yStart: .value(self.chartPresentation.metric.title, point.stackStart), yEnd: .value(self.chartPresentation.metric.title, point.stackEnd), width: .ratio(0.56)) - .foregroundStyle(by: .value(L("Models"), point.seriesName)) + .foregroundStyle(Color.accentColor) .accessibilityLabel(Text("\(point.seriesName), \(self.dayText(point.day))")) .accessibilityValue(Text(self.metricText(point.value))) } @@ -554,9 +554,6 @@ struct SpendModelsSection: View { .chartXScale( domain: self.chartDomain ?? self.fallbackDomain, range: .plotDimension(startPadding: 10, endPadding: 30)) - .chartForegroundStyleScale( - domain: self.presentation.series.map(\.name), - range: self.presentation.series.map { self.modelColor(for: $0.id) }) .chartLegend(.hidden) .chartXAxis { AxisMarks(values: self.xAxisDates) { value in @@ -613,12 +610,9 @@ struct SpendModelsSection: View { VStack(alignment: .leading, spacing: 8) { ForEach(SpendModelsRanking.visibleRows(self.presentation.rows, showsAll: self.showsAllModels)) { row in HStack(spacing: 10) { - ZStack { - Circle() - .fill(self.color(for: row).opacity(0.13)) - SpendProviderIcon(provider: row.source.modelProvider, size: 14) - } - .frame(width: 26, height: 26) + SpendProviderIcon(provider: row.source.modelProvider, size: 20) + .foregroundStyle(.primary) + .frame(width: 26, height: 26) VStack(alignment: .leading, spacing: 1) { Text(row.source.displayName) .font(.body) @@ -692,7 +686,7 @@ struct SpendModelsSection: View { ForEach(points) { point in HStack(spacing: 7) { Circle() - .fill(self.modelColor(for: point.seriesID)) + .fill(Color.accentColor) .frame(width: 8, height: 8) Text(point.seriesName) Spacer(minLength: 12) @@ -754,18 +748,6 @@ struct SpendModelsSection: View { SpendModelsEnglishFormatter.dayText(day) } - /// Brand color of the model's vendor, independent of whichever harness recorded the usage. - private func color(for row: SpendModelsPresentation.Row) -> Color { - SpendProviderColor.color(for: row.source.modelProvider) - } - - private func modelColor(for id: String) -> Color { - guard let row = self.presentation.rows.first(where: { $0.id == id }) else { - return Color(nsColor: .tertiaryLabelColor).opacity(0.55) - } - return self.color(for: row) - } - private func updateSelectedDay(location: CGPoint?, proxy: ChartProxy, geo: GeometryProxy) { guard let location, let plotAnchor = proxy.plotFrame else { self.selectedDay = nil diff --git a/Sources/CodexBar/SpendClientsView.swift b/Sources/CodexBar/SpendClientsView.swift index e5786d3668..78e2b385ec 100644 --- a/Sources/CodexBar/SpendClientsView.swift +++ b/Sources/CodexBar/SpendClientsView.swift @@ -3,14 +3,6 @@ import SwiftUI // MARK: - 按工具分组数据 -/// Official per-provider brand color (matches the "Daily estimated spend" chart legend). -enum SpendProviderColor { - static func color(for provider: UsageProvider) -> Color { - let rgb = ProviderDescriptorRegistry.descriptor(for: provider).branding.color - return Color(red: rgb.red, green: rgb.green, blue: rgb.blue) - } -} - /// A model's usage attributed to one tool (client), with the five-bucket token breakdown /// taken from the parent model row (buckets are tracked per model, so the per-client split /// shares them proportionally by that client's token contribution). @@ -213,14 +205,14 @@ struct SpendClientsView: View { ForEach(Array(group.models.enumerated()), id: \.element.id) { index, model in if index > 0 { Divider().padding(.vertical, 2) } - self.modelRow(model, groupTokens: group.totalTokens, provider: group.provider) + self.modelRow(model, groupTokens: group.totalTokens) } } .padding(12) .background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) } - private func modelRow(_ model: SpendClientModel, groupTokens: Int, provider: UsageProvider) -> some View { + private func modelRow(_ model: SpendClientModel, groupTokens: Int) -> some View { VStack(alignment: .leading, spacing: 3) { HStack(alignment: .firstTextBaseline, spacing: 8) { Text(model.displayName) @@ -242,7 +234,7 @@ struct SpendClientsView: View { GeometryReader { geo in let share = CGFloat(model.tokens) / CGFloat(groupTokens) RoundedRectangle(cornerRadius: 2, style: .continuous) - .fill(SpendProviderColor.color(for: provider).opacity(0.85)) + .fill(Color.accentColor) .frame(width: max(geo.size.width * share, 2), height: 3) } .frame(height: 3) diff --git a/Sources/CodexBar/SpendModelIdentity.swift b/Sources/CodexBar/SpendModelIdentity.swift index 0c61d2388d..f5d04171eb 100644 --- a/Sources/CodexBar/SpendModelIdentity.swift +++ b/Sources/CodexBar/SpendModelIdentity.swift @@ -36,7 +36,7 @@ struct SpendModelIdentity: Equatable, Sendable { self.displayName = alias } else { self.id = canonical - self.displayName = pretty + self.displayName = Self.brandStyledDisplayName(pretty, canonicalID: canonical) } } @@ -218,6 +218,77 @@ struct SpendModelIdentity: Equatable, Sendable { "claude-haiku-4-6-thinking": "Claude Haiku 4.6", ] + /// Applies the public brand's casing and version style while preserving the canonical model + /// identity. This is intentionally structural instead of a list of current model releases, so + /// newly scanned models inherit a recognizable label without requiring a dashboard update. + private static func brandStyledDisplayName(_ original: String, canonicalID: String) -> String { + let parts = canonicalID.split(separator: "-", omittingEmptySubsequences: false).map(String.init) + guard let family = parts.first, parts.count > 1 else { return original } + let tail = Array(parts.dropFirst()) + + switch family { + case "gpt": + return self.gptDisplayName(tail) + case "codex": + return "Codex \(tail.map(self.modelTokenDisplayName).joined(separator: " "))" + case "claude": + return self.claudeDisplayName(tail) + case "gemini": + return "Gemini \(tail.map(self.modelTokenDisplayName).joined(separator: " "))" + case "minimax": + return "MiniMax \(tail.map(self.modelTokenDisplayName).joined(separator: " "))" + case "deepseek": + return "DeepSeek-\(tail.map(self.modelTokenDisplayName).joined(separator: "-"))" + case "kimi": + return "Kimi \(tail.map(self.modelTokenDisplayName).joined(separator: " "))" + default: + return original + } + } + + private static func gptDisplayName(_ parts: [String]) -> String { + guard let version = parts.first else { return "GPT" } + var result = "GPT-\(version)" + for token in parts.dropFirst() { + if token == "codex" { + result += "-Codex" + } else { + result += " \(self.modelTokenDisplayName(token))" + } + } + return result + } + + private static func claudeDisplayName(_ parts: [String]) -> String { + var result = ["Claude"] + var index = 0 + while index < parts.count { + if index + 1 < parts.count, + self.isASCIINumber(parts[index]), + self.isASCIINumber(parts[index + 1]) + { + result.append("\(parts[index]).\(parts[index + 1])") + index += 2 + } else { + result.append(self.modelTokenDisplayName(parts[index])) + index += 1 + } + } + return result.joined(separator: " ") + } + + private static func modelTokenDisplayName(_ token: String) -> String { + guard !token.isEmpty else { return token } + if token == "mini" { return token } + if let first = token.first, + ["k", "m", "r", "v"].contains(String(first)), + token.dropFirst().first?.isNumber == true + { + return token.uppercased() + } + return token.prefix(1).uppercased() + token.dropFirst() + } + /// Parses an ASCII digit run of exactly `digits` characters, rejecting anything else. private static func paddedNumber(_ text: some StringProtocol, digits: Int) -> Int? { guard text.count == digits, text.allSatisfy({ $0.isASCII && $0.isNumber }) else { return nil } diff --git a/Tests/CodexBarTests/SpendDashboardKimiModelTests.swift b/Tests/CodexBarTests/SpendDashboardKimiModelTests.swift index 9046c81069..809804c76d 100644 --- a/Tests/CodexBarTests/SpendDashboardKimiModelTests.swift +++ b/Tests/CodexBarTests/SpendDashboardKimiModelTests.swift @@ -23,7 +23,7 @@ struct SpendDashboardKimiModelTests { calendar: Self.calendar) #expect(model.groups.map(\.currencyCode) == ["USD"]) - #expect(model.modelAnalysis.rows.map(\.displayName) == ["Kimi K3", "gpt-test"]) + #expect(model.modelAnalysis.rows.map(\.displayName) == ["Kimi K3", "GPT-test"]) #expect(model.modelAnalysis.rows.map(\.totalTokens) == [90, 10]) #expect(model.modelAnalysis.rows.first?.rawModelNames == ["kimi-code/k3"]) #expect(model.modelAnalysis.rows.first?.providerNames == ["Kimi"]) diff --git a/Tests/CodexBarTests/SpendDashboardModelTests.swift b/Tests/CodexBarTests/SpendDashboardModelTests.swift index 83864fad58..4bb39e7e13 100644 --- a/Tests/CodexBarTests/SpendDashboardModelTests.swift +++ b/Tests/CodexBarTests/SpendDashboardModelTests.swift @@ -502,7 +502,7 @@ struct SpendDashboardModelTests { #expect(group.modelAnalysis.rows.count == 1) #expect(row.id == "claude-sonnet-4-5") - #expect(row.displayName == "claude-sonnet-4-5") + #expect(row.displayName == "Claude Sonnet 4.5") #expect(row.rawModelNames == ["claude-sonnet-4-5", "anthropic/claude-sonnet-4-5-20250929"]) #expect(row.providers == [.claude, .vertexai]) #expect(row.providerNames == ["Claude", "Vertex AI"]) diff --git a/Tests/CodexBarTests/SpendModelIdentityTests.swift b/Tests/CodexBarTests/SpendModelIdentityTests.swift index fe35ac4ae5..ac5f86fec3 100644 --- a/Tests/CodexBarTests/SpendModelIdentityTests.swift +++ b/Tests/CodexBarTests/SpendModelIdentityTests.swift @@ -71,6 +71,17 @@ struct SpendModelIdentityTests { #expect(SpendModelIdentity(rawName: "k3-256k", provider: .kimi).displayName == "Kimi K3 (256K)") } + @Test + func `identity applies official brand casing to model ids`() { + #expect(SpendModelIdentity(rawName: "gpt-5-mini", provider: .openai).displayName == "GPT-5 mini") + #expect(SpendModelIdentity(rawName: "gpt-5-codex", provider: .codex).displayName == "GPT-5-Codex") + #expect(SpendModelIdentity(rawName: "codex-auto-review", provider: .codex).displayName == "Codex Auto Review") + #expect(SpendModelIdentity(rawName: "claude-sonnet-4-5", provider: .claude) + .displayName == "Claude Sonnet 4.5") + #expect(SpendModelIdentity(rawName: "minimax-m3", provider: .minimax).displayName == "MiniMax M3") + #expect(SpendModelIdentity(rawName: "deepseek-v3", provider: .deepseek).displayName == "DeepSeek-V3") + } + @Test func `identity names Antigravity aliases by their public model tier`() { #expect(SpendModelIdentity( From 702ea2382e121990ce21bf09ff699d29430556a4 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:19:44 +0800 Subject: [PATCH 08/14] prewarm spend dashboard and dedupe quota alerts --- Sources/CodexBar/CodexbarApp.swift | 37 ++++++++- .../PreferencesSpendDashboardPane.swift | 23 ++---- Sources/CodexBar/PreferencesView.swift | 8 +- .../CodexBar/SessionQuotaNotifications.swift | 24 ++++++ .../CodexBar/SpendDashboardController.swift | 16 +++- .../UsageStore+SessionQuotaTransition.swift | 11 ++- Tests/CodexBarTests/AppDelegateTests.swift | 3 +- .../SessionQuotaPersistenceTests.swift | 82 +++++++++++++++++++ .../SpendDashboardClockRolloverTests.swift | 36 ++++++++ 9 files changed, 217 insertions(+), 23 deletions(-) create mode 100644 Tests/CodexBarTests/SessionQuotaPersistenceTests.swift diff --git a/Sources/CodexBar/CodexbarApp.swift b/Sources/CodexBar/CodexbarApp.swift index 2df79477d6..03440bb5f6 100644 --- a/Sources/CodexBar/CodexbarApp.swift +++ b/Sources/CodexBar/CodexbarApp.swift @@ -13,6 +13,7 @@ struct CodexBarApp: App { @State private var store: UsageStore @State private var managedCodexAccountCoordinator: ManagedCodexAccountCoordinator @State private var codexAccountPromotionCoordinator: CodexAccountPromotionCoordinator + @State private var spendDashboardController: SpendDashboardController private let preferencesSelection: PreferencesSelection private let account: AccountInfo @@ -57,6 +58,9 @@ struct CodexBarApp: App { let browserDetection = BrowserDetection(cacheTTL: BrowserDetection.defaultCacheTTL) let account = fetcher.loadAccountInfo() let store = UsageStore(fetcher: fetcher, browserDetection: browserDetection, settings: settings) + let spendDashboardController = SpendDashboardController(requestBuilder: { mode in + await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) + }) let codexAccountPromotionCoordinator = CodexAccountPromotionCoordinator( settingsStore: settings, usageStore: store, @@ -66,6 +70,7 @@ struct CodexBarApp: App { _store = State(wrappedValue: store) _managedCodexAccountCoordinator = State(wrappedValue: managedCodexAccountCoordinator) _codexAccountPromotionCoordinator = State(wrappedValue: codexAccountPromotionCoordinator) + _spendDashboardController = State(wrappedValue: spendDashboardController) self.account = account CodexBarLog.setLogLevel(settings.debugLogLevel) self.appDelegate.configure(.init( @@ -74,7 +79,8 @@ struct CodexBarApp: App { account: account, selection: preferencesSelection, managedCodexAccountCoordinator: managedCodexAccountCoordinator, - codexAccountPromotionCoordinator: codexAccountPromotionCoordinator)) + codexAccountPromotionCoordinator: codexAccountPromotionCoordinator, + spendDashboardController: spendDashboardController)) } @SceneBuilder @@ -91,6 +97,7 @@ struct CodexBarApp: App { PreferencesView( settings: self.settings, store: self.store, + spendDashboardController: self.spendDashboardController, updater: self.appDelegate.updaterController, selection: self.preferencesSelection, managedCodexAccountCoordinator: self.managedCodexAccountCoordinator, @@ -353,6 +360,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let selection: PreferencesSelection let managedCodexAccountCoordinator: ManagedCodexAccountCoordinator let codexAccountPromotionCoordinator: CodexAccountPromotionCoordinator + let spendDashboardController: SpendDashboardController? } let updaterController: UpdaterProviding = makeUpdaterController() @@ -369,6 +377,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var preferencesSelection: PreferencesSelection? private var managedCodexAccountCoordinator: ManagedCodexAccountCoordinator? private var codexAccountPromotionCoordinator: CodexAccountPromotionCoordinator? + private var spendDashboardController: SpendDashboardController? + private var spendDashboardWarmupTask: Task? private var hasInstalledLimitResetObservers = false #if DEBUG private var debugMemoryPressureObserver: NSObjectProtocol? @@ -384,6 +394,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { self.preferencesSelection = dependencies.selection self.managedCodexAccountCoordinator = dependencies.managedCodexAccountCoordinator self.codexAccountPromotionCoordinator = dependencies.codexAccountPromotionCoordinator + self.spendDashboardController = dependencies.spendDashboardController } func applicationWillFinishLaunching(_ notification: Notification) { @@ -396,6 +407,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { self.installDebugMemoryPressureObserverIfNeeded() #endif self.ensureStatusController() + self.scheduleSpendDashboardWarmup() Task { @MainActor [weak self] in await Task.yield() guard let settings = self?.settings else { return } @@ -424,6 +436,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } func applicationWillTerminate(_ notification: Notification) { + self.spendDashboardWarmupTask?.cancel() + self.spendDashboardWarmupTask = nil + self.spendDashboardController?.stop() self.memoryPressureMonitor.stop() #if DEBUG self.removeDebugMemoryPressureObserver() @@ -434,6 +449,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate { self.terminateActiveProcessesForAppShutdown() } + private func scheduleSpendDashboardWarmup() { + guard self.spendDashboardWarmupTask == nil, + let controller = self.spendDashboardController, + let settings = self.settings, + let store = self.store + else { + return + } + self.spendDashboardWarmupTask = Task(priority: .utility) { @MainActor [weak self] in + defer { self?.spendDashboardWarmupTask = nil } + do { + try await Task.sleep(for: .seconds(5)) + } catch { + return + } + guard !Task.isCancelled, settings.costUsageEnabled else { return } + controller.update(configuration: SpendDashboardSource.configuration(settings: settings, store: store)) + } + } + func runProviderLoginFlow(_ provider: UsageProvider) async { self.ensureStatusController() guard let statusController else { return } diff --git a/Sources/CodexBar/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift index ff6bc043b7..11d08b6cc5 100644 --- a/Sources/CodexBar/PreferencesSpendDashboardPane.swift +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -49,19 +49,12 @@ func spendDashboardModelHistoryPresentation( struct SpendDashboardPane: View { @Bindable var settings: SettingsStore @Bindable var store: UsageStore - @State private var controller: SpendDashboardController + @Bindable var controller: SpendDashboardController + private static let automaticReloadInterval: TimeInterval = 5 * 60 private var selectedModelDays: Int { self.controller.selectedDays } - init(settings: SettingsStore, store: UsageStore) { - self.settings = settings - self.store = store - self._controller = State(initialValue: SpendDashboardController(requestBuilder: { mode in - await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) - })) - } - var body: some View { ScrollView { VStack(alignment: .leading, spacing: 18) { @@ -74,23 +67,21 @@ struct SpendDashboardPane: View { } .background(FocusResigningBackground()) .onAppear { - self.controller.refreshDateWindow() + self.controller.refreshDateWindow(reloadIfOlderThan: Self.automaticReloadInterval) self.controller.update(configuration: self.configuration) } .onChange(of: self.configuration) { _, configuration in self.controller.update(configuration: configuration) } - .onDisappear { - self.controller.stop() - } .onReceive(NotificationCenter.default.publisher(for: .NSCalendarDayChanged)) { _ in - self.controller.refreshDateWindow() + self.controller.refreshDateWindow(reloadIfOlderThan: nil) } .onReceive(NotificationCenter.default.publisher(for: .NSSystemTimeZoneDidChange)) { _ in - self.controller.refreshDateWindow() + self.controller.refreshDateWindow(reloadIfOlderThan: nil) } .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in - self.controller.refreshDateWindow() + self.controller.refreshDateWindow(reloadIfOlderThan: Self.automaticReloadInterval) + self.controller.update(configuration: self.configuration) } } diff --git a/Sources/CodexBar/PreferencesView.swift b/Sources/CodexBar/PreferencesView.swift index 3a398bf1ee..19e8567f0b 100644 --- a/Sources/CodexBar/PreferencesView.swift +++ b/Sources/CodexBar/PreferencesView.swift @@ -43,6 +43,7 @@ enum SettingsPane: Hashable { struct PreferencesView: View { @Bindable var settings: SettingsStore @Bindable var store: UsageStore + @Bindable var spendDashboardController: SpendDashboardController let updater: UpdaterProviding @Bindable var selection: PreferencesSelection let managedCodexAccountCoordinator: ManagedCodexAccountCoordinator @@ -53,6 +54,7 @@ struct PreferencesView: View { init( settings: SettingsStore, store: UsageStore, + spendDashboardController: SpendDashboardController, updater: UpdaterProviding, selection: PreferencesSelection, managedCodexAccountCoordinator: ManagedCodexAccountCoordinator = ManagedCodexAccountCoordinator(), @@ -61,6 +63,7 @@ struct PreferencesView: View { { self.settings = settings self.store = store + self.spendDashboardController = spendDashboardController self.updater = updater self.selection = selection self.managedCodexAccountCoordinator = managedCodexAccountCoordinator @@ -124,7 +127,10 @@ struct PreferencesView: View { case .general: GeneralPane(settings: self.settings) case .usageSpend: - SpendDashboardPane(settings: self.settings, store: self.store) + SpendDashboardPane( + settings: self.settings, + store: self.store, + controller: self.spendDashboardController) case .notifications: NotificationsPane(settings: self.settings) case .menuBar: diff --git a/Sources/CodexBar/SessionQuotaNotifications.swift b/Sources/CodexBar/SessionQuotaNotifications.swift index be0af031c7..499cff2815 100644 --- a/Sources/CodexBar/SessionQuotaNotifications.swift +++ b/Sources/CodexBar/SessionQuotaNotifications.swift @@ -334,6 +334,30 @@ enum SessionQuotaTransitionReducer { } } +extension UsageStore { + private static let sessionQuotaDepletionProvidersDefaultsKey = + "sessionQuotaDepletionProviders" + + func persistedSessionQuotaDepletionProviders() -> Set { + let rawValues = self.settings.userDefaults + .stringArray(forKey: Self.sessionQuotaDepletionProvidersDefaultsKey) ?? [] + return Set(rawValues.compactMap(UsageProvider.init(rawValue:))) + } + + func persistSessionQuotaDepletion(provider: UsageProvider, isDepleted: Bool) { + var providers = self.persistedSessionQuotaDepletionProviders() + guard providers.contains(provider) != isDepleted else { return } + if isDepleted { + providers.insert(provider) + } else { + providers.remove(provider) + } + self.settings.userDefaults.set( + providers.map(\.rawValue).sorted(), + forKey: Self.sessionQuotaDepletionProvidersDefaultsKey) + } +} + enum QuotaWarningNotificationLogic { static func notificationIDPrefix(provider: UsageProvider, event: QuotaWarningEvent) -> String { let windowSegment = event.windowID.map { "-\($0)" } ?? "" diff --git a/Sources/CodexBar/SpendDashboardController.swift b/Sources/CodexBar/SpendDashboardController.swift index 38b8ce7956..2fe3d94093 100644 --- a/Sources/CodexBar/SpendDashboardController.swift +++ b/Sources/CodexBar/SpendDashboardController.swift @@ -1045,6 +1045,7 @@ final class SpendDashboardController { private var loadTask: Task? private var loadedInputs: [SpendDashboardModel.ProviderInput] = [] private var loadedAt = Date() + private var lastCompletedLoadAt: Date? private var lastSuccessfulConfiguration: SpendDashboardConfiguration? private var phase = LoadPhase.ordinary @@ -1268,6 +1269,7 @@ final class SpendDashboardController { self.configuration = request.configuration self.loadedInputs = nextInputs self.loadedAt = request.now + self.lastCompletedLoadAt = self.nowProvider() self.lastSuccessfulConfiguration = request.configuration self.failedSourceCount = result.failedSourceCount self.failedSourceIDs = result.failedSourceIDs @@ -1341,10 +1343,20 @@ final class SpendDashboardController { self.rebuildModel() } - func refreshDateWindow(now: Date? = nil) { - self.loadedAt = now ?? self.nowProvider() + func refreshDateWindow( + now: Date? = nil, + reloadIfOlderThan minimumReloadInterval: TimeInterval? = 0) + { + let now = now ?? self.nowProvider() + self.loadedAt = now self.rebuildModel() guard let configuration else { return } + guard let minimumReloadInterval else { return } + if let lastCompletedLoadAt, + now.timeIntervalSince(lastCompletedLoadAt) < minimumReloadInterval + { + return + } let nextPhase: LoadPhase = self.phase.manualRefreshOutstanding ? .forcing : .ordinary self.startLoad(configuration: configuration, phase: nextPhase) } diff --git a/Sources/CodexBar/UsageStore+SessionQuotaTransition.swift b/Sources/CodexBar/UsageStore+SessionQuotaTransition.swift index 77d0d5bb5c..2f284b13ff 100644 --- a/Sources/CodexBar/UsageStore+SessionQuotaTransition.swift +++ b/Sources/CodexBar/UsageStore+SessionQuotaTransition.swift @@ -69,6 +69,10 @@ extension UsageStore { return } let previousState = self.sessionQuotaTransitionStates[provider] + let persistedDepletion = self.persistedSessionQuotaDepletionProviders() + let suppressRepeatedStartupDepletion = previousState == nil && + SessionQuotaNotificationLogic.isDepleted(currentRemaining) && + persistedDepletion.contains(provider) let forceBaseline = provider == .codex && self.codexSessionQuotaBaselineRequirement != nil let evaluation = SessionQuotaTransitionReducer.evaluate( previous: previousState, @@ -80,9 +84,12 @@ extension UsageStore { observedAt: snapshot.updatedAt, evaluationTime: now, codexOwnerKey: codexOwnerKey), - notificationsEnabled: detectionEnabled, - forceBaseline: forceBaseline) + notificationsEnabled: detectionEnabled && !suppressRepeatedStartupDepletion, + forceBaseline: forceBaseline || suppressRepeatedStartupDepletion) self.sessionQuotaTransitionStates[provider] = evaluation.state + self.persistSessionQuotaDepletion( + provider: provider, + isDepleted: SessionQuotaNotificationLogic.isDepleted(currentRemaining)) if provider == .codex { self.codexSessionQuotaBaselineRequirement = nil } diff --git a/Tests/CodexBarTests/AppDelegateTests.swift b/Tests/CodexBarTests/AppDelegateTests.swift index ffc7656a5c..a0f4c0ec79 100644 --- a/Tests/CodexBarTests/AppDelegateTests.swift +++ b/Tests/CodexBarTests/AppDelegateTests.swift @@ -44,7 +44,8 @@ struct AppDelegateTests { account: account, selection: PreferencesSelection(), managedCodexAccountCoordinator: managedCodexAccountCoordinator, - codexAccountPromotionCoordinator: promotionCoordinator)) + codexAccountPromotionCoordinator: promotionCoordinator, + spendDashboardController: nil)) #expect(factoryCalls == 0) // construction happens once after launch diff --git a/Tests/CodexBarTests/SessionQuotaPersistenceTests.swift b/Tests/CodexBarTests/SessionQuotaPersistenceTests.swift new file mode 100644 index 0000000000..e52427e602 --- /dev/null +++ b/Tests/CodexBarTests/SessionQuotaPersistenceTests.swift @@ -0,0 +1,82 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +@MainActor +struct SessionQuotaPersistenceTests { + @MainActor + final class NotifierSpy: SessionQuotaNotifying { + private(set) var posts: [SessionQuotaTransition] = [] + + func post(transition: SessionQuotaTransition, provider _: UsageProvider, badge _: NSNumber?) { + self.posts.append(transition) + } + + func postQuotaWarning( + event _: QuotaWarningEvent, + provider _: UsageProvider, + soundEnabled _: Bool, + onScreenAlertEnabled _: Bool) + {} + } + + @Test + func `depleted session notification does not repeat after app restart`() throws { + let suiteName = "SessionQuotaPersistenceTests-persisted-depletion" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defaults.removePersistentDomain(forName: suiteName) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suiteName), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.sessionQuotaNotificationsEnabled = true + + let available = Self.snapshot(usedPercent: 20) + let depleted = Self.snapshot(usedPercent: 100) + + let firstNotifier = NotifierSpy() + let firstStore = Self.store(settings: settings, notifier: firstNotifier) + firstStore.handleSessionQuotaTransition(provider: .kimi, snapshot: available) + firstStore.handleSessionQuotaTransition(provider: .kimi, snapshot: depleted) + #expect(firstNotifier.posts == [.depleted]) + + let restartedNotifier = NotifierSpy() + let restartedStore = Self.store(settings: settings, notifier: restartedNotifier) + restartedStore.handleSessionQuotaTransition(provider: .kimi, snapshot: depleted) + #expect(restartedNotifier.posts.isEmpty) + + restartedStore.handleSessionQuotaTransition(provider: .kimi, snapshot: available) + #expect(restartedNotifier.posts == [.restored]) + + let nextDepletionNotifier = NotifierSpy() + let nextStore = Self.store(settings: settings, notifier: nextDepletionNotifier) + nextStore.handleSessionQuotaTransition(provider: .kimi, snapshot: depleted) + #expect(nextDepletionNotifier.posts == [.depleted]) + } + + private static func store( + settings: SettingsStore, + notifier: NotifierSpy) -> UsageStore + { + UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + } + + private static func snapshot(usedPercent: Double) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: usedPercent, + windowMinutes: 5 * 60, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date()) + } +} diff --git a/Tests/CodexBarTests/SpendDashboardClockRolloverTests.swift b/Tests/CodexBarTests/SpendDashboardClockRolloverTests.swift index b609ad751c..80c2b833f6 100644 --- a/Tests/CodexBarTests/SpendDashboardClockRolloverTests.swift +++ b/Tests/CodexBarTests/SpendDashboardClockRolloverTests.swift @@ -89,6 +89,42 @@ struct SpendDashboardClockRolloverTests { #expect(controller.model.groups.first?.dailyPoints.count == 1) } + @Test + func `recent background load prevents an immediate foreground rescan`() async throws { + let loadedAt = try #require(ISO8601DateFormatter().date(from: "2026-07-16T12:00:00Z")) + let clock = LockIsolated(loadedAt) + let loadCount = LockIsolated(0) + let controller = SpendDashboardController( + requestBuilder: { mode in + SpendDashboardLoadRequest( + configuration: Self.configuration, + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [], + now: clock.value, + force: mode.forcesLoader) + }, + loader: { _ in + loadCount.setValue(loadCount.value + 1) + return SpendDashboardLoadResult(inputs: [], failedSourceIDs: []) + }, + nowProvider: { clock.value }) + + controller.update(configuration: Self.configuration) + await Self.waitUntil { !controller.isRefreshing } + #expect(loadCount.value == 1) + + clock.setValue(loadedAt.addingTimeInterval(60)) + controller.refreshDateWindow(reloadIfOlderThan: 5 * 60) + await Task.yield() + #expect(loadCount.value == 1) + + clock.setValue(loadedAt.addingTimeInterval(5 * 60)) + controller.refreshDateWindow(reloadIfOlderThan: 5 * 60) + await Self.waitUntil { !controller.isRefreshing } + #expect(loadCount.value == 2) + } + private static let configuration = SpendDashboardConfiguration( costUsageEnabled: true, providerIDs: [UsageProvider.codex.rawValue], From 833d74682541fc4d67e1f692c06efef56c7f0588 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:30:51 +0800 Subject: [PATCH 09/14] improve spend model and tool readability --- .../PreferencesSpendDashboardPane.swift | 6 ++++ .../CodexBar/PreferencesSpendModelsView.swift | 28 ++++++++++--------- Sources/CodexBar/SpendClientsView.swift | 18 ++++++------ 3 files changed, 29 insertions(+), 23 deletions(-) diff --git a/Sources/CodexBar/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift index 11d08b6cc5..879fa41c2b 100644 --- a/Sources/CodexBar/PreferencesSpendDashboardPane.swift +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -611,9 +611,15 @@ struct SpendProviderIcon: View { Image(systemName: "circle.dotted") } } + .foregroundStyle(self.brandColor) .frame(width: self.size, height: self.size) .accessibilityHidden(true) } + + private var brandColor: Color { + let color = ProviderDescriptorRegistry.descriptor(for: self.provider).branding.color + return Color(red: color.red, green: color.green, blue: color.blue) + } } struct SpendDashboardPanel: View { diff --git a/Sources/CodexBar/PreferencesSpendModelsView.swift b/Sources/CodexBar/PreferencesSpendModelsView.swift index aee3aa31eb..5bc272b595 100644 --- a/Sources/CodexBar/PreferencesSpendModelsView.swift +++ b/Sources/CodexBar/PreferencesSpendModelsView.swift @@ -35,6 +35,13 @@ enum SpendModelsViewMode: String, CaseIterable, Identifiable { } } +enum SpendModelsListStyle { + static let iconSize: CGFloat = 20 + static let primaryFont = Font.system(size: 14) + static let primaryEmphasizedFont = Font.system(size: 14, weight: .semibold) + static let secondaryFont = Font.system(size: 13) +} + enum SpendModelsEnglishFormatter { static func dayText(_ day: Date) -> String { day.formatted(.dateTime.month(.abbreviated).day().locale(self.locale)) @@ -610,25 +617,20 @@ struct SpendModelsSection: View { VStack(alignment: .leading, spacing: 8) { ForEach(SpendModelsRanking.visibleRows(self.presentation.rows, showsAll: self.showsAllModels)) { row in HStack(spacing: 10) { - SpendProviderIcon(provider: row.source.modelProvider, size: 20) - .foregroundStyle(.primary) + SpendProviderIcon( + provider: row.source.modelProvider, + size: SpendModelsListStyle.iconSize) .frame(width: 26, height: 26) - VStack(alignment: .leading, spacing: 1) { - Text(row.source.displayName) - .font(.body) - .lineLimit(1) - Text(SpendProviderIdentity.displayName(for: row.source.modelProvider)) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } + Text(row.source.displayName) + .font(SpendModelsListStyle.primaryFont) + .lineLimit(1) Spacer() Text(self.rowDetail(row)) - .font(.body) + .font(SpendModelsListStyle.primaryFont) .foregroundStyle(.secondary) .monospacedDigit() Text(self.shareText(row.value)) - .font(.body.weight(.medium)) + .font(SpendModelsListStyle.primaryFont.weight(.medium)) .monospacedDigit() .frame(width: 58, alignment: .trailing) } diff --git a/Sources/CodexBar/SpendClientsView.swift b/Sources/CodexBar/SpendClientsView.swift index 78e2b385ec..38790472b1 100644 --- a/Sources/CodexBar/SpendClientsView.swift +++ b/Sources/CodexBar/SpendClientsView.swift @@ -180,13 +180,11 @@ struct SpendClientsView: View { private func card(_ group: SpendClientGroup) -> some View { VStack(alignment: .leading, spacing: 0) { HStack(spacing: 8) { - if let icon = ProviderBrandIcon.image(for: group.provider) { - Image(nsImage: icon).resizable().scaledToFit() - .frame(width: 16, height: 16) - .accessibilityHidden(true) - } + SpendProviderIcon( + provider: group.provider, + size: SpendModelsListStyle.iconSize) Text(group.displayTitle) - .font(.body.weight(.semibold)) + .font(SpendModelsListStyle.primaryEmphasizedFont) if group.costIsEstimated { Text(L("Estimated")) .font(.caption2) @@ -197,7 +195,7 @@ struct SpendClientsView: View { } Spacer() Text(self.totalText(group)) - .font(.body) + .font(SpendModelsListStyle.primaryFont) .foregroundStyle(.secondary) .monospacedDigit() } @@ -216,16 +214,16 @@ struct SpendClientsView: View { VStack(alignment: .leading, spacing: 3) { HStack(alignment: .firstTextBaseline, spacing: 8) { Text(model.displayName) - .font(.body) + .font(SpendModelsListStyle.primaryFont) .lineLimit(1) Spacer() Text(self.modelMetric(model)) - .font(.body) + .font(SpendModelsListStyle.primaryFont) .monospacedDigit() } if let meta = self.metaText(model) { Text(meta) - .font(.caption) + .font(SpendModelsListStyle.secondaryFont) .foregroundStyle(.secondary) .monospacedDigit() .lineLimit(2) From 7459bb2fb9c5cabf10e7acab7fcb208af7a06105 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:59:34 +0800 Subject: [PATCH 10/14] use official provider icon assets --- .../PreferencesSpendDashboardPane.swift | 7 +---- Sources/CodexBar/ProviderBrandIcon.swift | 15 ++++++--- .../Resources/ProviderIcon-antigravity.png | Bin 0 -> 90331 bytes .../Resources/ProviderIcon-antigravity.svg | 3 -- .../Resources/ProviderIcon-claude.png | Bin 0 -> 2918 bytes .../Resources/ProviderIcon-claude.svg | 3 -- .../Resources/ProviderIcon-cursor.svg | 4 +-- .../Resources/ProviderIcon-gemini.png | Bin 0 -> 3845 bytes .../Resources/ProviderIcon-gemini.svg | 3 -- .../CodexBar/Resources/ProviderIcon-kimi.png | Bin 0 -> 1835 bytes .../CodexBar/Resources/ProviderIcon-kimi.svg | 1 - .../Resources/ProviderIcon-minimax.svg | 11 ++++++- .../AntigravityProviderDescriptor.swift | 1 + .../Claude/ClaudeProviderDescriptor.swift | 1 + .../Gemini/GeminiProviderDescriptor.swift | 1 + .../Kimi/KimiProviderDescriptor.swift | 1 + .../MiniMax/MiniMaxProviderDescriptor.swift | 1 + .../Moonshot/MoonshotProviderDescriptor.swift | 1 + .../Providers/ProviderBranding.swift | 10 ++++++ .../ProviderIconResourcesTests.swift | 29 +++++++++++++++--- docs/provider-icon-sources.md | 16 ++++++++++ 21 files changed, 80 insertions(+), 28 deletions(-) create mode 100644 Sources/CodexBar/Resources/ProviderIcon-antigravity.png delete mode 100644 Sources/CodexBar/Resources/ProviderIcon-antigravity.svg create mode 100644 Sources/CodexBar/Resources/ProviderIcon-claude.png delete mode 100644 Sources/CodexBar/Resources/ProviderIcon-claude.svg create mode 100644 Sources/CodexBar/Resources/ProviderIcon-gemini.png delete mode 100644 Sources/CodexBar/Resources/ProviderIcon-gemini.svg create mode 100644 Sources/CodexBar/Resources/ProviderIcon-kimi.png delete mode 100644 Sources/CodexBar/Resources/ProviderIcon-kimi.svg create mode 100644 docs/provider-icon-sources.md diff --git a/Sources/CodexBar/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift index 879fa41c2b..fa336a5149 100644 --- a/Sources/CodexBar/PreferencesSpendDashboardPane.swift +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -611,15 +611,10 @@ struct SpendProviderIcon: View { Image(systemName: "circle.dotted") } } - .foregroundStyle(self.brandColor) + .foregroundStyle(.primary) .frame(width: self.size, height: self.size) .accessibilityHidden(true) } - - private var brandColor: Color { - let color = ProviderDescriptorRegistry.descriptor(for: self.provider).branding.color - return Color(red: color.red, green: color.green, blue: color.blue) - } } struct SpendDashboardPanel: View { diff --git a/Sources/CodexBar/ProviderBrandIcon.swift b/Sources/CodexBar/ProviderBrandIcon.swift index 844e46770f..5bdd4e2de1 100644 --- a/Sources/CodexBar/ProviderBrandIcon.swift +++ b/Sources/CodexBar/ProviderBrandIcon.swift @@ -26,18 +26,23 @@ enum ProviderBrandIcon { return cached } - let baseName = ProviderDescriptorRegistry.descriptor(for: provider).branding.iconResourceName + let branding = ProviderDescriptorRegistry.descriptor(for: provider).branding + let baseName = branding.iconResourceName guard let bundle = self.resourceBundle else { return nil } - guard let url = bundle.url(forResource: baseName, withExtension: "svg"), - let image = NSImage(contentsOf: url) - else { + let extensions = branding.iconRenderingMode == .original ? ["png", "svg"] : ["svg", "png"] + guard let url = extensions.lazy.compactMap({ + bundle.url(forResource: baseName, withExtension: $0) + }).first else { + return nil + } + guard let image = NSImage(contentsOf: url) else { return nil } image.size = self.size - image.isTemplate = true + image.isTemplate = branding.iconRenderingMode == .template self.cache[provider] = image return image } diff --git a/Sources/CodexBar/Resources/ProviderIcon-antigravity.png b/Sources/CodexBar/Resources/ProviderIcon-antigravity.png new file mode 100644 index 0000000000000000000000000000000000000000..d9c43c5d5cddbdccefed8f09fc58c5a9981dd642 GIT binary patch literal 90331 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

6;knBaFdF?ygmJ^P{oh#hZ{g5m?1)Hh#pM` zar+H;mLu!SyXqR^nStz3O)F2u-T(OvyhGmlhaJttne!6TJ?&quXEl{sXN?2mCKS*z zl85-n@!aCh0j+XOi{tiMg5!&^_q;*9osyy|614>HOy{4RI(q3XZ57tV5j#a&<+ARw zrn37E0$CbBw&{UQ!<+#y2$oo4N{4%o$5jHUtOjd1-hT4fJU{(5z$;m8=Pg9oOtZY z=y5`Q<0{2)Yk8ko zeH)hBDQ@7KF=pG3a~dev9yyHF%J9!~f=z>D5SYBEra~mhGIgW5HIYJ=WvrYl%=F^i zMBYT^S}r9*CVmFy`tYqBiebOJ{XS*+osz0F^%?}q?$7XUH7c=~c;j$59IfG(NYBD|lEs&o?TpS| z8*qw#I%#*{8j880@nx5xcV-}o`$dwQCLKGz+Hm#vjRd%kx`wyL zQVyTlv^f5Z@DEduajf#l(ilD%fE1NF{Ms|5IaSjdHjLc(6X$Gsh0*SSz2;c}4tDcV zUlSXZBPMD;Sr-%nDO&oAcl&MDaXX#9;gB^I_!;pMU$lrw?__N40gsqJ-bPNox9TFo zL(=L2ZsDZ*`zf~bA_L~_gUsXlvM2iP;rG=mju!A@2t@z4r5>+iU(h&iQ4Qz$pdE-e zHjcIktMtUW`cNa_u(lk0H*VscO0h{vRoaS9#A+fOYvZO{xdSTtxsM1n``EteC>A_HSySR583;;-;uV?w2z{r@XLxiKF z5y^!eTJQX9@QORjPn9if4mmO2=9d^g6Bd)axWVGHKgPGG;^jPsg&PBHXq1p_9sN-ljyrty_~*UE1ljI87mkbPjF4>l zef(wN>afCBxio=-QYS=wxtx1N?~zJsuTyhcN4?|x^UIbH`v;1yZC0OK5CRc;)|&!1 z#_bpesjV~GVR-_F%pR_OXJT)v|99i}3S0HJBMI|Cxe;5d6zhC&#*GvDi;43Jr1xi1hx!9ii{A{n=RIqDEdfUT0%o7Qb%u+LMwL>{ zsnj!AvRkTrc+(9aCFwr7@5vcCRjz_H2CN~0F}y{EFhk%aDai%}2hNr9IwP3oQzG}b z^TcIhvz#A z%Q~13HSY%rWE+$M$y8!zCU77L z95|__aEy-x{qn%{;)QadDP>bWO&Z>GKYc)dyIp<47y0pkPtEo(cV}Rxg-_qhjr_$e zygN7iut;v>qL&68p(e%cmyJ5tzDs8hvUeAV2f`ld=84Wb(Uf}`)Jsp>>iwczPNb!J z7ss+xD12SJp|??lskXw$0PdxG_0Wf>A_DK)5T&PoVDP`(EhT@nl{ORp=h5+Pjt~g_ zTM>;1{l&8V(kJ{r-v1dg+)rUCQovWyv2rEteA*#BSy$ z7xMIc{Vn&?se*fjm(-D_np}sP2D@>3wDZ;GNA4R7qN8%B4SUbnM)3g^$~5(17Hl`x z5<}!k>H<&B&tFr#fyy7OL`$h)dE|i~Nnhs`oZ`#Z?mt87K_t;f&p{F|FBvSR{LEjz z-f8jp7N6>id!R$KU}DIwxO(s`VKgSZX?Bc?RK%S*g+;iXVuF{cCw5~z1txa-%2X^9 z-ore&$Cfi^H;*)xFNKuo=hGzAac?sg3YgZlD|Fph1PVAIoGv$Z#K{|P^F`3kT`c7-$JXH_|NCqd9 ze5iW@8p`o&zR5oh^ZvmOV4*{Scm_b?6vaJ*1FXe3M|tcjz=fO&x>qV9H*b=lU1oUc zB&B@)6XR+XW0oyRzEXVFPvq{o{*O}ceO>b`qw9w9Tt!DL<*lEVYdb|Cq^CJ7;ZnkZ$Ib; z&u8kO*u}}4JH|Ks#!dq)Dk93e>IotsE2n(!S|eQ=z4@p{EdmR4a1A(Z{g3!5CWY^Y zK*{-9pLD8@WduW)VrCH|R|8*L!3a>6JC(A0&OHnMMjFZmuX$(q5}7}Tghm$*e^BFl z_wqqE=Vbj^g+bxrh1>8A-M$3YN_7<#d?4(Dy<}~NF5+wZ3V+v%ZrA#;I-4qVlEE-usH*3qq&{rGRXp3){@|j14`GzmLXYSa zNa^{hW+Ngk@~hKmd((N}>k=Vcl_mR$?g4M}Ve?2P%kchA=8QqIxnJjbP^P-Abj{tI0jAkUIc<^HYX_xJMTN&Tb!XncTrcdVkMY5OzOI7G)`bG2 z4QA`zAW~LF$F4&n1T=KFJ#K@HC}A-;R#rGV{*B!p6}Ge{PPl6a$huxeDZ`08H+NzU z**PD6^cDD$eVQ^5(QGx1tDW?e9rn|2c1sp4JL!-?f1E;ku;tJ=Lo^yH8?33Ig8W}e z67Il+-XsLsOF#B1!UZ^|#jF=tC|--RB+S1`i1Uyw8REva(n3bj5Cl5@{Xz5YhL)f$ zdty%D<=>kttJeX$F#))psuF-47RV z>h~of*Jg>)xH0YSmqmP#PGhVBRKGIh}x*D-? zS;K=chfUIbL{e6f_(BrzQyn+T&yU9C!yMDNs~GWmyd2~9>F9veS~DwvosX_USVcYe zWTrvS@G>XGubg|z=gi0vXwloBMcJ;n7H{hg)}&v44U(|u=K7IY{M@$Hu6A#-eT7~u zq2qP#&dzb;0&&v!WISMkSb`2rmaUW0C#8y{%NY->32?8}*1FGdr6=Y2C?r-tqVQ`I zh!X(9Hhad)-XBjY93)kX-Eet^FZN)Az;h;8fzi?6I0tL*;JsMx`as0w^?G zoJy^Bn0)7*BJx1!v*Gq5)8a&fW%c;q;|y6P`q#+CJVfT46*y2X_S}peJbB@)PmSA_ z1_(~Dv}ZOpL?_38_OvQg@Zgv#rvCBUjM~>qm#&{}YG=r<-#H50L6w*oxXT} z4kexZP-A!Fubju<{c+*kTk71xFhxyto7!`E_Lp>ZkOe~RG%8;@!jS$w#XS64j^WUN zVS%=qMrA+I&BE|NYu3~$`KqzZ#`V%hx60MV3;fqrRHp5A#qVfL#$4girpP1opXFS` zuoLHA27S6sFYv{GUI!m1@Rcdc$}dKyizL>d1}(S~Hlj`IeYREHcUYBzfiCdbp!m=@ zCj7D!Q5ArgDXpG5klRBPEm2N#bkA#K4>tg;cn1bLY}Z9M)^uac?9-B_vv}uVQB1;? z`wEiCfy`+l=3B($eI%IIPOw0U5iX}AkxplHk}Y;Jnxkg&I8$%4xP2Z?6=+h%Wg&4Z z0fd)v`4-FN+07+w8sY@-N4{+lkmdB&7S`Cze%DVD-r5l14Ce${r~PqXkjVXQIb*Fj zhn(ED1XEwyD5M(qEJ|~BnFK0`2aZVA2f;FXs?AsveqT^Omk5(%*x=Pn5hk(jYFt-U zD6E_PagqI6;M2#hI~wjsGMxr`6|1dQzJqHu?ZF=A-X7EU<8MzNB;tP-=Jtk1>4}^) zXvNXPL@oM_NGJ|83Ow=CzA6+41(-Ge(%MmQI)~8Gjom8;GadV^Lde#6AE`-vi1hcS zoZQs?^G6A$I?S6~cM2mggnb>;#CS&sB?45K2X_JA2^|SWxkX6m#EGR zULpf8GB{XM^nBAB;`gB0Ppy6Xn!^H10!ryEy*p(Ve^mO5y$)|B?0e||LJWoT3)y8IXj=#(J30edKeZv8`+pEq0mHA`#ofHxVz zg}><(fVwJscog?6Uw%vsO;gh_S%AlDp07*P!R-j}IHvN_5o2#T%UU&S}?dVXK`1-MEkx&9J4nXH1J2w=+#yG4hWn^Kio{zO+Z~rAOuaTr5j>MsTYlP| z9Bf-Zj3uV9=J=0PluOw|#qTOzHTax0?A+>khZ&FG=jecUFUn@s&3S5Q*59t=(k1%B zykX!r5>Me*?}d}HJKf##-KQuu?Pe-Hwdhu$9ZvcfR;uiMqY+qlK9FZpeXDEXb-`y7qi-r+jIss8V9!sL43v+Cmj!d0g1Vs8_#NLeb&OmD-H( zF0)&c?`X^Zb~`CGXe5tWDBLdawU>VM@nr?s8FHGcnlY)!0wJyE9G!}UQq5UPH+O>JCDcr__H5M0l2 zE^3#EM_64BmCs?$;3JXnT^}#X-%!>F?^vi_K62!9yET*S*(OQyf6c9x%};<#YzmP@ z?@m$tpn1NBaO~;JZU2%s$#`H4&=N=yE~zF!LBa zb{h*WS`s~<>O}L$T+Z8_KKtUm9c7n5rOXeJpO#L%$fOSG^W9lKSDYSJx?3w2w{JXYJ3O=RWZKh~=TyYeP(wuH21ji; z4Ft4QWZv5VSwX&NGvkq-)~)N-zUh81b9nZ!x|{iis;x81yLSe~&~@XS+jjo#zk-bV zuONp!W`O_x82a4sNdM!$<_GkZUI5kdappsVS>2?YnHa}dE%pYBjyhlrdov)a6s@D) zt;piY27ad3ra$*@m_-SS&6oUq*SxQrczR57aRy+-Qjmt|Hl5(x(82pQ_LvDi4~xT1 zx$__8zEHyIwqHp2mZa2h`f5tooGMtf!avnBEv z`0vF8);1$jJK|<(8EJN_O#G9lF9U|O6Tr&S49A0${|3fT4PW3+N1Iz%ZGgm`_acKo zw!?fa<(^`?x2Q5*s|adE=3V%|cXPU`_;M+ghaf0yWXlf=zo7bqNUrgU88dTcA<;oMjU&C%M|5#EN- z)>_j<%nun~77*UVomn@#{7e#t{$<$JK2T^Cpomr+)_;GJ$$wqw>{eJUwJ}-poppo{ z@LnhItx|KsG*O({Mp84Ro?7s$i121(kZ!d)3E@ICsg6ZtoeRkWqgvv|r_oeMtI3nW zNE;GNv{csC2lI!O58CMkhuS;tt5JM7NofhY&XSwQAc(=KbKBqp>k`K$KcRX_>TtQG9jyx}x~UE~mkf zt;B5TveH72yKGw8OnE`(G<83P@_e6C6w)<2_aJ_e?1s!+2`lydo1^pRuh{*=L5I|k z*#c}-%Tx}|c1+q?URzwoSmO0AhspBd`mv>i>U{?;AQA3GZ7`_j(V z2-~(`5#syxwr-J4bGi4EMOPH>%>t_l2Z;~oX3cpe-|#%FA!U4SmQ)l*-I!12->p_S z__&oGh~6$8-%re%{?r0~`L_lr1bmd60vHA1ApQW@ZhU@#<*fLdxEM@?6N}L^RwVR+ z1I8z0mTe7ybs`Y%Y23`;Vs)A4?|B(BD=1Xx+o}OyJyNLIX_KB-ciQPLB3#Y3G?l}x zk6_r!pASNg)6U%G`9CD4^Wb6MS4a*=hd7>p^6A_P(AT0YVuG{z)ST|pFYKg^TZM23 z#-F`XE8Y^}2%UZYa%XOI50j(=4B!^`i`d>u>!>u^Ep$h7rl{Z{Mv)=Hc>{$X%DCxJ zF@sF5Lf>bg+w}>?fq#Xc4RDctAQ$p{L@D6O`Kk9HTTbJPGirmX?w=B+-)pBm$MD$* zdZ0`74M2WGo?L{TfeZ_&UU6{7^pVG1nbZx&&%CT0hpjp-D)ut4IlqI;^9|O2@H*34 zd3Z7I&vZJkhACZ&ofWv@EUw!(tMGmEcBkji)Q8ny6ns$3r#t2T(;Y6-ZT?SP!s!Gk zbi1Br@+4={WMOcT0Uvt(YVkCgqZ2wD#nL&CNIfj-nd8X8`9epUKUe!`NSEtESCVXP zB9th4@1;l+qz7hM-J0kuG&ByZmdPnyFWthLaK=F-p|QS83fK z<&*obM5sVuNlF0q!@1lO_I~Di9_&@QzUr{W{hDTDJ!Mpk3om{!-;gs4a-yl3uKh7e z1jo|vOD`z%+sgzp87jIXe* z_d);*&PSQcOr|O`)Cje`r=ZT@EQ2E|fY%jVu;Y$TE5=6i$uVkRS#yUgxkZT7ppT}r z8NJiI*}GyA>EFd+FX=U8>@n`9ItkuE{#h`JDs0~Izytf<#@w64`aQhB^}9YRp>UXw zf5)B1vf|9@?`!Q7ldoWz-Qgdc*SXEv(Wetrdv;QLc*H}NceGaC8IkkNMyBiB6YUDb7+`4yyl=A z{589MdXnNspB6%I-}`pN-uv}G!hrelgw}h2R|GcVPBYq!z>kVy%LxzDCFuM62Vp$E zP`0Xx&nrK+DG1MVo(8XxmY_;`I1;~*%o*g*M@ihSmMIpNGY9Q@B##}E+j!>J;GW(z zJ>2P@KY(RNZ=k=ni32`4_|R|fdWaFP26QPMxlIYkiswt5Wj=b1ElS*H772tiKeZ)# zXb|<3V9Mxc=)oMYUwDDUK%#>p^V)A0A?c$%HnksHG+Fw43Fpy*9*ClB)BcZbk;W7G zY`S_ox$|r*b-(OP6N7EOj1?7OIeo%Ml`UXAuTYSGSc5(J7FBmEBMzEGm%Y-_VWgUx z?UPgq^!wfuPv2mY@eZ6pmkC)<0mDlcs;Vr;1j(qGb!I3X7!PNH`B=Gz&D?jDQ5LV;SyES9xzZr_ zq2{gC$A6;BXHA;;Dx>$0KalI{9E?)i z0+ezI>%|y|d3KQJ9g*>js|tYX)KDf~WPNgP>1>069k!%xIDjU+BBoN+2rSzOu{z+v zdN+eNQsPfLWrs-K&n_VC^`Bn<$Wj!q6`c+# z$EK0#ZM=x|;Hy4uGkoQ)-cVonzSbt^a}h%6gvR1h1}d~Oy_er35xO(#FnY{&NY)oG z&FJsLjeTGb122g#zmqRj+qu8vz9N3#IwdD&5B*ASyA2`+-zW0f*7QZQQ#?~Bx+Ht8 zm(YVs$wlJdd9<%-s<6|}*R(x(6#O+MVoGc0zRvo5A@#CqeHF(}gsaXMwW&M$;jHl>wYx)(R)O&8{#%dmm^M;OapfOn;byD2tkV2|j zL`KEQdT#H+80(?wr0nB0u091Q)O@KEI(zIx`p#ImQfjkwz^Yp?zEPE(r~E0Z_+nYu zL2p9tZypgrO#{o&+>Y^ZbH`hu5UNh26OAZaD?g!NYz3s8gLa-88L#wM?EUR(c z{a^iA5QTS#i~qdaJ(2#fm$pjfqeZ@MuSY^G4zt=4ktPFS#wodJ(b9t9xG+c*!=I)V zkEm1wyyX-}{FB?r777m?t^is+L8pD}sOXRMMrOlj@&+gS5WG>n33eZ}mP7ZNwANU4 zz*wHp<>{6EvJd!}?Xo@_+lPW!^m(6!V@6D(;ws_s{fVvgp_tB;1+E3NTZubGid)y8 zCXer1wN0;n&gQZx{ZU~1VD4zTd-Dc|j(RYyOZCN-@FazvK9U%b97su^ffv2Q0>Oxi z@~}#54h8mM9y)pYYV>rqN!%YV{awD)7#2X)TTy!FNJVAvyxkAec+lG5-TZ0{Vi z#Z+F^S*$(ev3yP2r+#1|L5$*LcEF7pTNk8bi+-}Z-I!Pf9aEV9g8k2u&HEA@RT6QU zl>_Ejl23IGqbHY2S~L?=Nd`M%3#t#G#{h$SvQb1_$x_kO32cX` z5U1!~kX@XcI0a5D)1F)XYOjX}m$3MoN>X(dmK3epS((>IRz$*jzH8I;TzA=2tN67Z$v6`;S1X z;A1avDDX+>%6Ev!&&%s@wKcK<&s{;i}Dm+EoGEnlb7QWD|FI!Q4dltn7 zb6f&%HD5GVa7O1~_S2PWg>^=c_;6{)^6pd@i^t4`nNLiR6KNTEec2VmAcfo+YrXV@ z7| zkA1I;-ni-$ukAD#P&-Ta1~yy@j7)VVnmLV~$Es>PqTx}=16X2aK~{#GTVTRVunXEE zfZuJzq{TB&?~bggH^gZ&@q@Aroo)oBC0!&3F+vch>DRcs{hxlql;@9O$3dQK<}Rdi z`zmW@2Jd{aYbNr?kb!u+$_q&!uOM5`CD0(O9^;Ywnj4}%HWU?4o>VdgXE98ifur^) zX}wLXOs?902*Btvn~WayQrBr={k?gk**Y-C3;Bt2VRg^CMhvJnXr}Q2=Yvim2KW$LjBnK%&V|6ENaW0^0o>ql zXUbKHo~h;Oph@iD2TrQ|1$E#EHxUHtN8u?+bXykK{$|hx4fYo{`CT>(zolA9p68o; zpa10Wz3BBX&Qb`d)`-UI1oZy;&CgoLPs@PhkC2(7kld}mY?0VA#X`b++OOjVMd8$a z@DOEX(nR$gJ0vla?}^2m<rV5i3D4=`CT|^ZmlH^oE*(*f`imIr=^a z=XFajk@olX#QSqjjsy*moxsl&TDLP{eXP$aUMsihOrN* z)e0mZ<>c+p3JXM@w(^g7%JOh}i_3!fzg7GQRi@Xrh?^)SkA5?uAmo027ylklp9$-B z<42j59J}2Y6>&2IBrJVUTLES?jPMT#f(TT1 z)aK1Ow%#}o)Z&f<82vp|*CW$!zr(DWab^k&jM9@59A>O19O|tLG}U#q#BAbe*X8dj zWq_WEnRURwdvkxc0o~n5Qg{E9TZ5A35s2k#D~`W?dfM>CjNT7A(n1;d4B(h6V)^%n zCyh3~orN2_40>lEY+r%hQxMIw>p>12q4MXzHOB$tOTuC#UZM12^E;(y-|dk=KC;Gu z^fNgPp^|GULM+n)_eS~L3svd|jql?Q6)moX6R1^f`+-?Lv#izdWDKlZc(~kCNkcdy z4D9=|&{05hu~qhizq3$yF=6|axcm_FFLF*xcHsDgi0`*lgSp6xQt8 zCVX}UcmUDCG94R;-a?)JQk=nqy%L$>AL#|?FAL(Q5BAxI3abL)zi#Yd-+AfMF*TmV zB}lcZI78cdzu!&%855MLYv@I6TA2v%Mr8J6lr33xzUx~@#ox8E2KgN1{V9s|oeHp( zNsbU|9hyo#vDVF|3p$*u!^q3iQ1kUwort>!OM6lpz)uu&xp1dWz}8)zP5~_!MMn9Dby4# z>0J}!MibT8o+_p=zxz67d^cTlY~m#(VQpoZzRx2F4iV~PO2w{^LoHqs`X zSJ;yY%|*bAB1nAy1l~)ybeYdwBGhFYsk<_BimkZO~a5N|6y+)vHkyg@ zSiAnjTtnAo;&k_W*&#`7)7Bwh*y+qD@ z=Xzs7S(?&N=bv$#SZ3tzL%Ll+?=oT+VZORteP3JUSVVJ zjoQ`i=t-#U{L>U;%`1?CMtIDYUW}0wY;9e@W_Tc-<(M(7wc%2I6~{Y{i!eVy3flM~ zAtRr;dZ&cs=+nBowKOipUle@{>iS%HKjc=<5Xmoi59+HsjNk@`r9Yl=_{5?mPObK> z(W{G1SRxxmm4*^IRbLwzd=ACvV?OFGgl!gaz2r)K%jV5) zn>m~rm%OO^eCAV*D$%16S-p`f&*#B76Sff!PS6wCy`ELy`1!gt`$vS%CSaq2qC&OS ztytE^Q0FmVlKg2_D>4#Bw~Hcn@Ok@7{ooFq01acM^d#3d9iMqI#^yl4(KNo$=Ah*pm>#Q^jKBdoS-#JpMzAAb@-EWq&U$E}l99G>MyB#63)<%)mU;jS~z$8Y$ zac8ejqLX~%*u8i>;23-JW{{^IF{qK;sr>HqY#g5)nz)k|bG6Q-2lM;zcCLN{*+fr?=Tmp&#wKLRR_V)Rq#`&G9bELP?T6hG5}F-uU(~ z50(G;=HnchNdN2drGEMCz5x`)EW2oU_k4~6Cd$y|zw?6TDZSb)9AL}bKLO1hm{l)7 zT%e~HoK$uzaVkCQSGMj_x=8EnwlVZI%W(Snif*SQVkR-GwblpN?QuU*9 z**rfRfLU@n@uk{{N(*&S>eszkE1F|TjHjz5T)9 zNBaSzAjxk|e$TQlrz`DEW)w{iM?7fT+zvC>ojZ4QZMM2WeRw6__b0SUW`*^wd%7*d zZ#1rRUR{LKwZov5Do@XYpgDb#Q%`)y;_H^*e^mPl$ME7iFTC7ua*N!xO-FIs+4T-{ zk@bbrITol$Z2Q1D$1D^mWP~lEPS<%l7wPGR3|0x$HtL1Ng?(F2NW$W~&ffX|x(|Jt z^?y6ugV|%#qNwN7Ny2G+Cz$^@M{eb>>tvFbylt?pI~L=;UIXblaGp!O zLh39uGGu`oxUeG}vjsd&0+-J6Mobn~Oqat%lmAfM`ec*cM4Q>pYc@f3bD9bwigP-* zB)41ecDGm+*?}o=zWW^xNYjTk%3QkKl|UT^u_G;0Iwg2z1sa{ma>yR{)4iVMA%S?x zFQRnMz^4yfvjxqs?0M^jOJhRXabj?HDdLYFJoEf-{ZI!ut8A6!q;JgDKRybj&Y%PH zH~Y=IW;O|KOOzCFCHW0Z4#gKE#gw4h5YNDF1TI^qZS|8Qvf$NbiY1W5mAX7(&-q=?x!a5o&VNB31?#E%ZWR5* zAlHiVsmf=Jweg;Kfcy8pZgt| zeCuT;ptWpHa`}Ws-x8(IrLpo;L^}wQVYSIw5#y-o*{oOR0wlv;-ttl_r;?b0eQ{SW z81J{jnoiDatAKas<{Ev&1+A1>N;(qJYZu#g%{H;KWtwj;!#ggQIfV^g!`=tEFXtNl*DGME z4?30+8}a)M8@dUxi<5f~9|>k11g?@{s5TF%o@D<`_e+oJo$omkCf-EN+J}fQBXHA6mVl)5#!TedwHDfVznzE)KI1syovFx{x+KY=6 zD4`~tUYy-v9k8*vqaA$M!t3N1OR23EAO&qAeE(Z;mpZR^*dwfpJH>_NHk{q^_XUVd zg%Uvdfkfgm^LJwiuDuh9hGvX{lWL4a+Nn)`=`mF*KFZU8StA8IFK_N_0fWh~lHA7S z)06Ql!p5zPf+Y^zcodDcPkvIGiTu8c#-`2>j~C}%{w2$W$ajkLeJ9?h1$`GAw#lCy zh=S{-MzB#2r~e5OevB~&zYFwxgelH8LTx|~mKe_UL>cR(Jt=$A_IP^PEdgJ0D`UKe zf9U_wbl%}?|KHzlRn=CTTCEWjRin0O%?6=p@v-+FwRdREh#h-xRjXE_Mo@dNsx1h$ z_l|Yz_jlcYzW;o`uGcu{d7kHSQi)+}WBT}0j^XY4sD^uiQ{aQwj_m*nJF7lduvu`! z43Zps_bSH%EDSjASJs+JDSJu}lM81N8ZBmW&ANI|0S6$R@#b&T4yiFU0_AQ* zP6(v{+=n8Uz{TdN(9e`73n9YZfJhUGLnS0#Kd-Z87uYaJx;nD;EHTCG*Snb2jWYBP z7vk5oNtG4?RQyIe&r9Ut4sQ&Kkq7;*{Fzr~gd|30j2qSMgr@7ndY@kUya#Rq#| zERb|hIN4Dnp`pW?>hP96%M*VE7*Nq-=1=N@~MUakV$j64|r z85s^fiH1(w!A)N1K4_>RXT;X8j!~0aKDC5r>$7JQ;U!tCj1KA8uNClwO}_n1zKwL_ zdR!BkzqsoOx{eH}dQ!!zg7?LLKO*6Pn-ojE0SL6!|4N5hDzlAtGvRAA4ig+2AFXqNn>j|9c5<` zN}l8&CtuyLn{Il3RILWYqRhMmdLthQe%FuVO};rXOSQspzIUvR^{IOX=THYp#N!Oc zSEp0|1ZW$^KO<`2d-P+xel24S4>HU{X6Y7YH=X!|hN3E~`F&@sGk zEH9#?F%%^?Vdq*t6UBw|B2{QKk?la16-Y$!ciyqaVE?y0W0oy zE9dn{E!AQgJhf3~u%e2auQp1FvV`Dk-H@tOX-(?JTUE&s{pagja;r+5pqWi(-`7b5 zu>~h%v|gLD<0G?z0+WjHk2i$sEM0On5_8?jC}`i_h5so&qvMUpIUN(Sd`q4%tNd*Q zXs)v;;P#XA$}jKVr>W`0jqgm4BA{spZOu^=rMU zRod%nwab6QoaOGPZvwbh{WS`Dot6j|o)GDCIvTJBV`(@teye(K85{vZ#=w=Lr5~PJ z1XtdizRO}Cwis%sOY`qGXauT=ZraR29)OuA;VEPl1qJ@vj}jv4Tt!7bv2uD`lVwA{ zU9>+M6-fC;Cj4L1IpWK-%1A}Tq2K&h&KiB-RY^U>nxoTz@RX@bQ7?#&KS$&8Q+84= z8F=RTVKLLyp5c=ROqZnd=CCKZ{Pec;FNbOSQca<5E0Ljt7e!BNW~9PJ=g~Xkk?qx#w~*5hDWqaot%XB# z?Fh3Ev^bO zLM)jDO9y$Ljy-jD=O=D%qlYm+yh-5l`R4kI)G3nmb=_vhG~#sXhqS7|b6*kL?uh50 zV_G0dp1w|4ZikJm$eE6W&eDtyM7bwugr~t4J;z+R60t76H|=;e7`7g~PCMAS0|>A* zx`-xDJedOL)X!Ct?8d~BSNA%w{$Vy+opkG;gtu#>*0pvPGkb+7g7^FT%6&hlP|;G9EC|_=j9GWKh%cWfkjYZ*g8=rr@$umR(6F%mpG1`WwaL*C6o-o3x z(hm?-04!*Y>QpS`#O@LLc!|`#*EmnrbU(z@yHo(YihmBw6P)igwA5;0BL%SP)cTw-qe=IzC z`t3-(cc)V9D6irvClB=*^xbCfHV+mlK%)3MuP0^8i_sg1Dy?m0wp&gzei&LpO8wTK z2O^L(Qo-za^gz|UewR55n~^>gO&CMdhqflnDZ%p13b_pq`o5W`MJ(+_siH zGULjmNx87D9BnuczIR@i2`MK(qx@>eZw8Rpd3aJb#`hHykb}PI<%`*X5oj*ZDDVWq z_^O%&o~j%9Gl|1}n_s^ez_gILezj%v-psM5sl#s3)~gFg#^W^`_4kLRhEM3X&8T`5h;{|5Yn4*(vi}#l0RDw8f+Z_r3HZ7Wah;kUQXLZTQ#;eVyk2@Hs0(`7 z74JGpfHWyPW$%rxzl6xSrhYaZ6`<8;C)EWges1xjN>wuWSn<$XVUCoImuv(|0U|wl zEipJ*AG$)rRLAqI*s6vP#2UHRS?>_=?>L(CUC1B`~W~zWnGEjAav-G>gm5N5O zhe77TIap|;F$nywx@I)LlB;Un-%ix_S?zp&93g*lfDn**K@4CFQ|}4ak1o=8GS#J$ zpE;4KVXU_%HscSVbO?xH6xO&taV~5IEhCDL>-D_1{PeAvA3}4mn7^0m<9}1ptq(Lv zD4Uo&WolZHHhRQs{r!&2fzBPD*kF$qVo3yMZe~0aH+BC8kz@;AHt`Af|1@S*+>4v& zDZo#FX~{IAs-TaJAe=kPoRD%N*bxAUy&slzl(Hy4E4=m*c+oFWt)%26kcQ%KtU+b& z)h6b!<~P{0m68(2a_fHmeW$l#EGak!<@NM{BP6i7cT=gXWh{To3;FdJk3qVsNGv10 z9}VH{1F9&?lYCQ-AV#1MQq$kkd9pst3&lyLbl!#LGu;#H zaQQLt;XOJxl?Q%jEem-L?&9ewNiQsGrfAo`O5P@-=Y;$3ALbqtZu{k>S1(gnIKgg% zd{10BA${X=vCP;)# zFxO567JZ{Zjm6%_Y$B1=61qQuhozVyu@U3gArH63Y)xDV9w0|-48O4_?xKvJDuwIO z8*%Z{lHHI&Cr!6sYpI=G@`DZr+3*JFyUSjLqhLhLh?e{xU_%`rg+KDfio#|`wkiwo zYd-rOz!P3*uf^Ed4s-6e;XJ|W8FDabId+TePL3r*tW@8>+~onim!9KMZ!-U?DCmmY z4=tVYRVZDh6PEQp41$=C--wMnFX*8iUNt%@AD`kv)KJCm_}%x zN`^&I5-Yp{T3%}xOb}ROHAL6ah@XzmQ!Ma5CulB=9{^jnN|8|Y7 z51Rx<_buSU`({^7Q_S)~Q$T`M8)%@^i=*P6J|?5r2ta_)H;gf(aoZG!h$rPr~XcVHmIi0!opp~ zbf)s(e?Joe-hAYxrtMG~4tZ09Eg&;={1J+`a$02W<{#~R$%SuS>R8 zp4z8*>{s5hVM9reoUeRmt@B_!7DczhJ?R-4dP1-C7~>Zuj^|Zw9QxL#z}et+sU8uu znnmX1zidog-~=e2WbZ;y%DE@vX~rmP&jok`wc=-5Lzr-pfj{}h*;wY>2R1pAM19F8 zJ38hf(5_RW@OOGtM5-!2dSXTL8&E4R3c+w5NeT=NN1P1&Di-Xunl$mxvZyqQ458xL zcn){zxc3|$1h0uru1|l>cctZj{1D5i6%kPo*q-L1P>emT^7mjBMXdgpS@*R1$4hN9 zdC9Zz?w{*bJ-6N{<^dnz+(bm%Wpe+77@2W1E&8@MXA*LEu+cdaGHijSmx0>@ZY}uX z5g=`pX|1Ex=M_tP&%Uvc1OmFSRFF4-!D3!?bHHO{D_B9^@3NDTJ}5mO32s4z%^{^TnE%^ND)1e@2-3?L9|INYRBjjAHtH?q#-0v-c%B`{vf3?*9Y>xYE5~kaJDN z_~hA_D&{l*yiSl$=rSXua#QlySxXbr`jtScd9Xpt{Q-OT76;Q%6r+UO(W_R@t~Qkf zL2;ofSjv4fNxSIf`%*qFvq&P_4X*i>AjB<{Ql{%2XDDHXWK>LjDxX!S;N?N-LSeg$ zWv8ZMQ`aP3!+5}pXE_rebOQhAP$I~=j9_H-op069qjRjhGW4YOH|W(C1^H8Zq`*^r zAIFj8you!1eobZxkDZ|6r};B(JsRQ`M=5&n2Bs8($+1ZN4+26)S1W_ws}kcU%Ov`) z!|`-Yj~|u1tWrFcRr=Og9ZBMUG#^Q9-jlu(gp&AK`Ef-O;Y@E+)sRiL7R<@;fHIJf z`pFB=jpmZ)-?9ucj&ivt%)W5};ieBa>$^TZmByH~Tf49g-!}hz<|JO#s~mW|(=y_6 z&HgX&%aOn_I{arWJ0bVJpgzw4mjjq7Ui5LDZ)BmkAFPU}t*7v$^)?k6VF4Yg>l^*% zjU=QK&L=Q^e|S3l8Zc)iTCJ?ErwD*%dl^rPs;X`CJ$RMr-^hCwZG3}uGE)LK^>&NK zo(GKU+hGx;If@ZT-8W`~1 zWCcKV*C~@8WA}WC=T7Xs&LZaaS?S}CSQ<0$+^Y7EB9YPmVH~_|stemnV~w)!QV+{A z4!2VekzDvr_l5Aoj=UH{2YG%8tX@{8s7-*bmS1qm{`e-^^Y=mDw!%%Pxc|X7S>}uH z$RAoK{BtuN;m27p*S_3kPou`;2M2cdu~FJnSkDc%4R3+oj>Aoo`kb(` zuRsYPQK%vPs?bAG4GDVAv3H`};??|46yu@$g?xy6Ei_{7(GQR{mkZV!89*4kL=AsxfUjwH}!-mVs6ULd0}cdqdy5a=?(s>%QfOq8zz+q5U6AkpyE5OOBtzd^ z5bFGkR{Gd})DCibt%@D2DK5>i+_=q>RULug1iwE;u{!|VD1#`yAc%QYIFM4vm0N@r zV=>6y?8}_TV~;b4E7QC|QbjO3GAqd zpXqZ^*A;(c@{(u-*VNf@!n8JSGjK%zsM4UkTXao(B-zjLuc&C;q4wLz%2A!17i|+z ze;nE{K9Cw!%9WCi{uENCt~B)9R+G;(h6JgQ25d;m2AoYO0r_K~9Ma}4eWb}q>V(Xm zzujV8J(&}o;AYSn%|nKc@|cJ$Iy0Wh@dI0DmzOf@nhQJFj^XFqI+kvZcx1c|={wOb z36ek&&)&!^XZU-D+Y55~U2%6vs4!xArd3=27X#zTwpMIB{oK#Ru96%xa|Qirb3CbCR)IBx%J7|&P94qz(aCx$^OAY@l1NGZs=O3~t za(JCDDiQ1zt2HCZ#BKc*GnE@nUK$#=^G}w8`BGfvD4J&+O6+oiZOX0( zX=r8|_#H{Nnys2@R(~(xH?w+!_w%B`mHR^-2SVyJk>8Oh+OP{Bg@~_mHkUJa?Zsi? z-1K=!TCni{&S_@2qRb_?80l;7K=YL&OIZ}apVLr>R?v1}+d>)Uc%|ArW?7S@I=4U| z>Feb_} zcuD#+9>x{_ynW_(7%BCO#ZXSsTb^JNol2vK;s}NdNZBu^I>I<>FN$X;r*mdPfgQLE zKDrc}{zQ+8sGMX?zm@%mlTqgr&T=Po28|Mt+MP{i=oqI9HGj`YMMyi_O*JOQ@fow$ zphTD*_z16+6-{@Aw7O=q$L9zO>R$AwVDS0yQ9>|?YA$stTtMi|iH5~!ITG{+al}WO zV=lR#=9j>n)6+@{&UqKW6nA`odZBDTrj9v1F{yVoYzGIatI6z3>`sa%%2}u!Gq9Ra zXoBi02}>h%#7DY}s8>SVtN)4p$spVK*+c>N+h-G=M$AZKNf`Q$mFHXv=!5v)T3YCD z!yaS;r5$9h&nOMt7@I(jp6?)Yzg68gUZl8Qn|o0rJ@udVjts2UNK#G2R)0k8`o_t# zFta`Gcb5HD;s9}eq<8bCxJv|eKlyRJ7`PeJ)EqsNT{6no=G`};G}0fb{bYg1h{?FS zSnK`zgyfLPyLhBRsCT#Cj{Tc(Plr;;GP3?I?MOEFHcy9zC~(g#HSLCx3O3DqqiT1S zW*{gOv76LmRS60h#p{u}%3D|nE&|)u$5RtvFGEL{z2@sN_47%@Yb`WSM%dQ$UH+a8 zdSqA~CP5bm0#C{AR;tZ``}Z&HD;(=k%tY6%E&%7Qr}e6pneVwO6*oQwH9%SL3;Zl# zIi4L?y4hc>B3lrY%Knhj0$}Nqo8ch8$l+{dB61~7Gl9~awbxJEW8*ZGZlaMq0zFe+ zqYqzmNi449Uz3A6RBy9szCLXNdJdXxb!@>_|CoU;T^+>2>hlHz0(ZBpolCg%>GhN9 zS@wN}Gr4?$N1OlsjQc%L;tvoUqb*O`i`ZLiy>RFR zv(_#398gq~N{6W7!@Wz#%(|1_c+~J-k@-wCk!&K7AdAa+(gy||%D+^LZi>t!yA1m{ z)O~I{PV<(;s1Ej`$o;0@IR3P%NAWwyp}sb|tK=Xx$>~OuY(mm=7!~!iQVTZsE404_w9K#@ID)Gu@ zlZ(3Ig0nQIGfBvl+gixk2a({foz6p*poH%9pP@`9(Hu;hVkqhF(F(MA1nq*oCfr*3 zyzva*%B+N=hj0YgbT--h;-uS*tw|wD^Na3vr$zvNPzxvAZ_YCQXpSzE)LHFYskDF} ztfyg=Cef=(!8zLds^d5*pTH`Rh)rj!_a#`Py}=yR^jw&bDFuU{tq|65NL#vu8JK=8 zmVLe`OQE!Sc#5q(H`}1Z?m6&f3sP)E4HGKzFJ{fXBpCI%DkMK3{3kJU!rD5T!lwXD z{(Gw9A`gGop-X2R{&wzgim!jZ!VX;W}hxrjFtpYQgM#TX4MFy!g}b(wIdI7bF{? z2_!HQr3&6V`--l{5BL!UTkr8csOs@|lV=?J&RKg{rn>SpwBB$Yt6^x_Nfv_b<`FY7 zlWTnZ_D53aQl%syaBbh&*_(lFDpM(`Ju)WMh@rrk*t@zYYnHF2(YA-C+2j*iU@OCW zc34qHAph%om-2vJ?gBKz{OYfczhU4X^W&$N9STFvI-uZ7J((o%0gGz#T{* z`Ndfei%~oSYIt?K(&lT!Qp(k|s`7F%uwC~w8@tf(*%kZcEmOIbGV}}9f z7q~5`nb!<|TFbyPU4LeAwX&-^qs+~}iTgt#)s?!iyxPuy)<&MNK7o_edIBvJtSS&uHM?LKYa6GPebj<^<-@F1BLPcGnWTTbuBdUgDN=GCHeV|5F zWSihUtU(kNg;s>#D4`BgFrNWH07QF3D`H9r&|~w4)+bv^_vFE#!lLH~5^gNU2EK76 z^Y!KhBcIy}Di$tK60Kr-T%Ujgblma>Pv||cZ}eU4rUbBjNnX8OqW+gGcQ;izRXt~# zQu{evIJeHy|284CZ9folmO3)47Nc71LyE@TDBN5nS&;ALO$Q3u69$2Ai-XveWBNsH zb8het z^-OwpZ!gu_AxczXoSo=$TYv7J70xGEN5N7}ekblEXs@Z0-yG!=keFFrJeEYNx&#ri zgc_|RsxN*3N5MXEe~#ZyJvASW7I@p1!yBu<1y&`QZee-mF!rJ@)X*cCfcxUGwYuEl zCZaOt?Vv5<9!3ab>1smS%BuYup3)Y7lETND=^x_5U<^ob5&e@ccbh`Pc~cmj~cy4(V_nTOka;#!^cESV8v8AP)}ibZk0l zdZ!2`eD3o!dGdq6|Mez=a4$Y~kG#sDn$B9vipX+L+vJlG(GV8@CHjafxE#a__i&ed z7mn0bTWzN-p>#h%JBro3==oeW*wD)I8s-wetW1i4S|#y)^5p^J^Sra7sA^2v0`)s^D3>Wz!}e zTI4h_X(TsJch!=~C~o#AJp34R-deNO$7qlx`1g@yCDr42ent2h)1T=+k7rDH-2#0^ z%wdRKxqML><<`gtIOD(a?#y?Y&O1wZZ)Q;BY)np>aQ@#1- zw!g=1HzeOZd7QiR8IBQ3=ym5$wZ|bkeI{b zECe{0dwq5aE6N?MR)}~4ek7y@h^F;Ug^1)X%w4SYgWzd`C|7x7|MQ{>mULyN~ zX!V3{&ztX#ltX5Ca{3!RJ-s~Z+UNjwHi$KEaH;wUxJLOZQxUTLa5*MEQAHA@OK%aa zJVK6Jk0@g|^iS$8D6U4&&EH(gF{w0tC27yNx1qseaP+ z_RG4Ir(im$aHe+SHpE}yI=F4vDc?YyNnOoq%Gd+`bc!v9rxxVku%Sps>{EeP12R{f zcVw+w{~Ec~DZd=^C8<-M^Dl0pZ@li#;zO|Gh3Tz~TfEzyt?)`ETS=&d?YQV;<$J>} zin0PppH4R?@{Y?;{NvY=7j%>GoT1gXLX;u|JV@6tWaw94ZXkP94)_9AfBP8!zp=E{ zV9Z!W9b-M4@o7<+9u6iRT|t+Wl7P-PF&Qsl7WkoPdWsv@>e^0!Z^{3(xUJL8600e} zLy||`<#ow4Kr(Q>o-C!y20iWXqTh%{lEvtK7AE!0CV|tin00!=m@Edz{1#W|{)F%d zk&Rs9MFecD{PtrqWtoQf;f|l!za_6^is)Xx@f)uX+dt8kLOqVMr(`i+8om0M)?ZNJ zI1EQCY&s(3Y0xjHsw47yu65H~tN*ho+_+r)^($Gok~iAxbFQhId7i&R4@g+d_*VU( zoCA+;sDd(vCc1K1`hX3SxK;BZkN>E<=Y{rsBsN^wo{3fM>Z%c&W-XdAZ0uOuFpZfd zk?un@j;~hbD66QrU{qrrIQ=Ff&tT-dE&>luB@nxkbQM0To-si{U$N1Z>Mt zt}bK9l*9mvd7tkX^RRr*$CK#_!irZBvN6ojgky6xD&wmw)XfO1VreX&eRgOO`Cd?| ze|y`%VHFyLaCK4kKOOU6T%K^s{QFHVo=DH(HY2fc@##Y`>T;mV!+klT0v4ZW(^<)ho%oV9Ij03S~)v$rwp zpFVkYbTuF5ds;#1d-F1T7rRD|3bKc>Di86|NdQP_)Wz?m&Y5v(JGUzxSC-3dxKv{o zO_46bVV!}?9uUm9eX-$~j!e4QT$ z2XJCk_W*c+DX=QU%p*a>huiwQ0Lb#F1nibz>9ax;xH?HM?E!uO)~U)bQ)P;lzzszIr^O>;e?uRn9E2_mgLBWoprPJDOz`t zD&wwK2=}(1e7o!4LY8r(9GYO<`k6cNZ)i?zr*5{msHLj~J>NKxC12ylyGbFn2B9yY zZ?1dgd*-FrlZqmvv&H)fjO4H%b51$v z;+fJ@Pki=0-VP)sPLgB3aOCY;8^nJR;i1r{E2N_)&CzILl*N}` zp`>T?jm$V(qRy?*5OQV9Lo3lwHe!tOIiB>12~N$~#V9RFbsQd=-*)L=7sy=K_aIec z)qb@Uy?t+0BM9K;K3R#o1&O*6X_1u8AL2mOW+g}5xIau2oN3BYjqx+` zaAxQJYN!^$)rxW`D^0UQ3jgdDjqwos{j8Q#83WK()DOl(#&bPbSqihBtsfPIPr&j5 z=G6y$w>}c}zo{AtIyx^B{DVUYl|FgFoL?M>^vCDfFF)1(C3f8@yNh$_Hou)yF4~!g ze6Kn!Z}3JzC}z^$fdr;N@WjDIF+RRnHbK}|MaKCE)-|(oMMaOUn_xRBT$`ipRkSlg6EeJ`VYCU1dtAP_XjL7akHrK%K{FPqNMve%{k>o@XC z7=^Yw=6`4S8@cz@!7W3{I2FM#tPa^bt?ml`JDN1`Q95T_@TW45v?~qmNmE)w@jkEg z5%}Oh6>^ybWqmEP-L{cI@q6FM!F$OAf<9ldd@9|KOxz`nysH`7m*@``dD1-@m$Zbmj+5qddJhI6)tcmmBv1h(;b{-<0`^ zh>x%Y>K!`8lXd^%agEF^FADQi#@vhgLD;(Le>tbUK7*WB_uKI_`QfC(ssr9!-h}2t zK?V;oWUK1it?;QNvzIY8)mkoYDjVC~K^^+!?rpl@&LQ%~D@)v~{?A0UlTQN}B}QC6A7rmS2vu~TmSc&`V@dI^NleSVt!QBnZBJOwUUPH-bG5%R)Vyft z0~=%QzE%;NVc>n<#xV>PC&wUz92)x#?5C{fp>_#7HX0AA@YgtJVk_Pk8E(eEzji<#L+*T>fml%ev3hNn zH#km7yH-#u9FTU*9Xw<$l1L#Mnk+A&^D8_hiKtin9N=;m@QO9^CEL{YfO&Pog|zX3 zR$V!9PzwjEnhy`$JLdZ6r227)?#00LxhG64Z}WJmHD&*fsJf_DKgxSM6jLU1&r}Kw zh5g@lI|!Wp_hjhd-VWd0V5X>Ws(jUR2;$OapkwX{h5P($E79(-Fm|6>&d6jsv!bIkL&DRu0N6G zjcYTrb~kIwPEq+0GCMl^S-46UlE?uvrc^dz8q#n#3TzP%;<4-+Ee}xTUKdn+t>c;b zE!+J75s^nO9$-^F`b%!GH>w;#@%bxr)3jCz51krX|A%+hRzbkrQX$u}m>|q??C^Gd zUK_+Xe4nfJL;6JdX+;$Gwg|0-<9l`hJk4 zm}>t)I*v|HL5KOIyd**iNapV3_X9@sOLeE6+Rtm%52opdcDKG5av1D8p#>jRvhZ^3zi-1&k z7_2;D@E!d{xB%kqu>p+PpXQV$9~5+eW5t)$jWdy1oY^Opj@pUE`;a z_`Zf85}&hNHTS3!?A4ZHoW@VJ!x_<2v^~$~^>aGnwo?WBw~7X8Xix$5r#;&>#0vb~b56K6F#MbVemp8{JyH6DUg-F~&fhOX#KZLRomGN}N zIUqqR$DH+bG73rO0OItB`xfkN{cB})zXM+F2Lm2?$**c)BbLL{(T;b<%}Ywd zz5!Ib0q*qZ>2V<#SrR4xra=(R+RY(D8XvX*Uw%+KU@zh-L`Q0aAal85U zKy(|1?Am9i^=Pj(9Y1pi{2>d*jjsyIOM9KEdGmY2!0u~#bJ^pxZ+UMH#n&R2E&U68 z=GglzHjezck-p>K*GuF?yyKS*>37%R?%!5;%+p`qPggu!(^ksV_sv1(Kol;sG+b+lzUuaPMt za=|G*-5%VgtzVwhIeLx#CXzTN8dKxz@d<6;Zn$s(hfmy6(|X50Vw)mWVot)&-R)ep zUQ(9-+nu*VoVKdlqB3D?K%%FjXUVT-%HK zoLG%{>9cf2Z9jVJ%c~zYP6fSgw36#jGQG$k(r5R@brwFH_2hmfqEi8R>}5Tr(_brq z3=H3DL@h*ZQ8ff*+*G61zy^nUVO0jIJ~nnTNy|%*@3TwwvCg+Epp*TLu@vQ_;5*lu zUvx}1E%lEU`T}2@@%$>FtY^h#_}PVYsnzkm0`rRcv3i6Sq@rLNA7|Y?9paiyOXgZ0 z&k7i^{L0G-iT2ihL8o#U{{@L~NSlB&J{+zAD%r{^#)Mf96m9~f{mq4P_82hzN>Aw5 zrr0RG>c>i|1uIQ-S0hq}aACo-TJQBy^}V^J*J35gY8pinVbBpb;N_K6Xnwz#4QAOZ zpRqM}jDvgq&y&eEH=>A-G}y{)g||@|2&F3X{+}WD2>xIL4RP{*y`Z#%Xku3a6GqeP z1GzU6y$@G*i_Pl!lzeZsqZ~cO8Wsc$Jzvo4_fWpq6KB=gpg#On ze>bZQ?9tew`6H^qzu?ask!_`CBd_Tk7ccS3VlbO!1E7M3+_RjI@cfjA)hO>kTIr#<3&1hg z6a9!_etuBk#t9$$Jc%r3O14v9geKkU#&;lI@!+}Npj?63vo)8IPb57pK-f(98$OYj z19=z^Mq=FnpY8Ce!V4p%6!bD%Y>cc@2AY167=ogideHvy32gFgNt<;lvx6XGf5KaxgxSDR4q{|;UXc99Xor8m)ekL z=}O!}X_p|t=mP;J?W-_a->8kZrEwI0h}$bW6Sv^m4TU2S1@^3Q|CKW?dz3*EY$$%k z?fREmMr>eu^>3Dd#32Vyb)WCbYkRZVMD*~{b+H>Ko;;KjI#B8 zYAz#ODWK1#ZS#Dh+UCaw+P@$)H6fagwub)Zw{$XRC0s`k=84bsK;Pco1v2XQ^wTnak-3;MP0P#pohAJ9B(vW7 zt4F@G>_*7!2k}FZ|I#&i1mM-sFzFFri?1~5^Z<8-OdCVPINIB-Bu*KG_*txnZHplD z)tiO9yw7D}vFH?PdlhzZVhLmRhz zzVQuN4%G|7mwq16`z_hFGi@)4MZZMP&-DTR=R9c%5a1Js zfUb%AN;6^XiO1u{-DS>0&;TjL2|Fm}GUOX$ZIf%BBXA3sVt|@Oy`I0ZlN8I@4O0A^p#Lm}9b;g2O%VMP!@yY$7@n2<`Wz zwxEu=8lQOzjmm6rFmOtA4OTjyjnmd>lpF&0=NY@M?KZtcbg6T7t2KX3>&$w`Z5^-q zlfCyt;+I;%m$RrjiuLOF!!{lgL!AeLs0QYoTC;rB&s%fspT0%siKzZ!jy8;p+{K({ z^Mm3^DjMsNKpGh!tTZ!~3jm>^puEd$GDtYUI}h{&_L<1DwDW@V$VyW$RyfLtFJ?dB z!6{>Dl&~o{a`d}pi7WZ3hHFdL-*XPPw>t**Tkq6k-~M9=1uL};{G%M+6N>}4(f4vJ zXwwp)%(=X6o+fj(mV0Gb<%VDm=F=%Uc@JmTGRj!5(cgf0`km6(Z1= zalI8O8bJ9ZMnWT8MMern$VEdqMHotnxNtxz$<5{#8|3cWD~I=XJ0*O4%rZ^k+p0E(FR%*8+|N>MVlwj#aqUn@b|eNbPir(7R)Vm>I3|4 zskKD|K63S#1jRj6UkqUaeP{gSX_@!iETVP}01|Kf7*a*lJ0 z8#m{^Eprz{5tvgk{fk3`IL7=hFR9fRjr<(@nfQZ|fE*cev{{~U{ceTKtDLRp4WaU8 z^vFc&&RPWq!-rGqyZq8hUupClo^K*;PkQX_Bcp+Cn$ngS{=m*%Jq>!9gKcv2f9doR zxtKBRX6XeG@BIo|kc|SL6XYersd_e8#+Xy<^{XPPW{Uae*^S76zSVM}#+RkXUNLIZ zWAH)MXZm?#N<6*?m1c?#Mo7LQIg48PSI&Syumh{3rYkYn2+)GfS3R`TUo}3{YIrM@ z7Exe8vA6LC;Lx=*M{Twdg)GSu!5HA6i!dRkO<9SQxA4g(+s=$41A= zH7f9!4OEF>Y|KsZ9EKk?9(*cAx;`=CqF+~dGbV|upD|M+`uPcWL3e$-V8h$5ghHDC z84=0T8-c~!)KrDHyl$h$0j?gLb%zBC^S<=jbX1urY`Wil> zSLdqlASU4a6`e2F!tUbo+;Lm@z;t%|L9R1xHbzfpthiAfB z)lUg>Y=20aU9M0dC7&Z;i0Toe;n)XGeJe}2WO^Q`YA@-c&2i-cUI6DGljo(2^$9_! z&s)1f)Y#gLTLqT9a`z2N$3xFGHqYE$8z<~(9tIlUEA}ejvzH34XVh63#D#w+#{5vk z^F}^4y=(gUBCs9sxC{OGRgwANg`!ephuiVf^3yJJ0}Nl^5Cq@U0COm8;eT3$GP6|R z8LA+=Fj0*3)TZ%1UOQ_$9W69s-il6<*rdCgjc9hBK9$;g^SsaO`Fe7}nbbbtmL1w{ zDrZj=bo8x?Bo3#?uj6K z_V7;h-+LXbuyi>x!kjgoS!euXWl4U6Cx^6X!s>qH0Nm*>1V#x^wphrRF8ZX_yg__N zySt&IgPq;mdc0uL>#YC(c)F(WN`q|~+qP}nw(U&p9ox2Tn-flKO>9lfiS69XIp4WY zd%t$~LUsMCs|@!*Q5&SC{mjlNwW+8kwA#^rzSfPKX5v_%G;ZCXVRCDCRWeF;;Eg`f z8cQ~`Cl>h+CU8@0e??`9kS>FqaAo!3RX?Ki28d5!WcQ`o`TtS>GsQt8zhgCWi~+-B zITV|S$v^J%a25AT)Vpuk`Ea50u~I4(kc?f4z$UFpS#JJL<5M54hdUQoRfvy!vB|TW zu_vUF#1|s{`04Yj-JV_vM|(7LZ$LO|lvuoS;B%5N*BKmsa4kAHBS!ANZ|U$k19@K3 zFd^q@%+06c$mrPgH)$3g)Ay&-~EWd>>&}&rA;2J z&cEDI>k-K00TOC>&5iys$6PV1jzV#sh`9g=h6x z5M5J<1tOo=#ocV&jnIZtC!M+L;eB|)=#FG-Y;zoGvLH}&W!~K4#pTAPOWF;&*MS|_ z$pojm!IG-&0WQuY>x?^FxBl*)#0YxZ?~KMBSXqdze-F6Xv=Oe)CQ1VrsLBs^EANf}y6TT}XVz2K(@XjO} zd>xVLo->MB5Gg5{wCJ5#liQDPO_;lrdvBK?t^;ZUT5~(|`1lW(gwMb0sfrRoL?TY2 zl|SUhRIgJsHz;sXrSMObz0;sC&upBN^*2-wJ6aCK2&3FmXZ}7KPJK?5>Us|aFRr3F zZxE2~CvMS8lhePxJM)c`&;A(od%oB>KlW`0PHt*PlJL7t{B1vA<9t4X%ms}pVWTMo z-g3gA*BGl^aXSGsW1h^{RQ2=WZ*8iqF=;HnwYfLW#Q8G^#2locxs;0<3aP zojFx?-${k6!^P+m7K2iuEoQxmC{YXsFyIGU7aGuPW8&YcpcY}?{gE;gIbh_MIYfPb zy!ugGiF(5r^9NIxkFP*ExAO<>_{ZVtnS#kt30var-dYyl6Q9x-dCH(!Q089{D6wg-fv$F~Oe?9|tF3(%a}!XykQ44b^{5H(l!g)FH+2;dyG#BkjL# z&={zBlZ7}R8Be7e_LlW#s(iWZ!!J-9UU0eWYS z-F6hvbN@Ay7Le7Trbkz2&E=6p$m*t8g5uVgMbmPiENta_XDm5bW9qy{Yl}&FlMSad z;ps^2{6tL^6mId-BIAqnUU6l2yPL5%>*GDME;vcsE%$BN*t(iVw*3vBIgv(W-up$J z?O`zotxjxZd)(D9AEe_?)WsdkzGoVfrfK>Byyz^&bCZhk76EA)VE300@_P|oQ63n2 zDLnqGjuA3bJPM;w9tyJy{bR742}rKzDA~XCz3U79nG`;TL3&t^TTZhEZ@#lj7K|eh z2YXbybg5kswENnO{43<*;h}1Nk!YTpZ$;(5Ya*K4vH8U=-ux5P2ksuU_8eOgG5<;1 zx3BumjrqS1UKub)``R_lkWDgw4|NfCo;p%aS3?PpXbJ}62$=p{H_nc9)ZniJYfN^! zrenxqOI-}Rm0RAh;8?b&@OwEk*Cc>X7NIck!OfFYcHjzT&iHED?!bBKYMcZl5o7dgySPI$07^P9MdhVEvWZawL6hT^buuOU+5Nh4Hn+q1Cb&tXpEO;b}W!3+0&%k(x!$Jp>($9bKY z*P?H?&-%~qBpcjyIQ|3O-$OwMb@FkS6Gv1_9NxsF8DzE~t;gjLCuPSSg}DAw{b2Pb zr|oy=z?<@W{c61pfRwxgft6rd3|}Jcj>wf&C=Q5~u>Hq%B*D*9Ql7UrLVaWeSffnUt1)DUq5780jqoBUw>yl zH#@Ag@o%%@eav(cJP7{qL!5Pe_l|&ns*9jt4?;W--8+5_!(j`Daw&LkH%S8Yr@8MN zZX_+K<0%>_MadqPx*=-Ii*=uV`bB!9(>9jJ?zurR(=hL+U^1hO+2fnM%|+Iv{nFo_52(z-+cyDe^cq*x3eIDu zD%LrWo4$}$b$^R$mrh@PYPC;u;Nd4k?gCcO4C2~akK^CbS?{NI5ih$i9@h=2Y(rH} zFe39L)y7?d66nI4TQj8{ZqS3yHE*R^sFC<(XYfAMyvE}fiP{BTkZ)twrO-u+NJ?3D zES`%-{VVHEPt+jzG5Pr+Iq}g=>F`j!?YMP+_F1pDMj^)gVgI(gnrdY=RkGT*Nc*mF z%O>QDalL4EjPdW82@QMPjiSB>BBNjJ!@t%W0d@X$hZyL~)Vl-4G|qo{Ee|&ZC70*7 zlKONPfj^Y&^@p)^JoWWXtduC`g8kziwfnP@JFS#$1z-j~8e`reft=Zx=UB_|T z+WTY-;xo-KnHoX{yZ*olpU9Bs)Myoa;otZqAee@)nYZnosh)xZ5pxy_JTtKt4Qwi9 zl73E5JWAl56lRR|CjzAFdH^Vz_eu<8;jFB=f(C9@tXj7K3TxJAJ)oku;Itb!l^M$= zzC`(W8kRBT6Bv%kPPD~3v1a1w{llmCc&J;uWL3PWr0O?jqNjscjv?~XCKu&&toL@e z92ekKt22H8oI#=gSvAlXKb~9C-7;Fx|5umU07&NJi$>V>Jmwv^{3FD$!pyi(I!2X0 zk%YCeJh3&t*a2dZG9eFmsY&6VoT;ULWUK-Fd*Gmo} z2?Bmm1xS^k6R{ge-}$OsVR~(pGwIdatc(qq%P=x27U!R85*^?wj}#c|oYT*Xrzj1% zU@zK;XEr2v(e(;)_Q~{Y2y$!+eQ#536Ws%<_jnG3oT8bx9%aEJ-8{;zMXyj@Lsxza zoa0Y_=j0U)=PlMl{@^-Q3~FZYw0+(PzPwb5{bpmo{>{z=8fQ^APkrs~e_gB!4%Wu7 z#Zei#sv6!!2rNpGE;l>azlVK@)UiIX{BW?7aVuJ{M}WcY_Dt?RRwlnd%2>H!>|{(H zYzKxBbn30WSL8o#Tw;cTDt<5vDXgztxzopTLuS}}Kki#)M9V^&`gt%`1PS2AV7Gk9 z(qXp&(S0RmpwPbt`rBT~l$s#K&O;A`k5ZMAHYaY9JrF?26(vW1AEF3*s;gLaDvA~8 z=;O=hiLH6kNjuW1;!+H{^v090oV;OTk!QUPEJdNX8a>>PO&Y+Xb2C(xvwy<8;7JC( zJB}AiEbaS4(X@Q3IUrYz80$FIlm~qa!=%b{3G2cuhHB;}YuNV}RYtL-2Uq%ayXc~L zwU@)l;4~nRK<%)su)gYNzs`@a%|Zv$sIa9pFrr54FWbkH6Sc9-GKjnmh=$RBzm*UN zUmwc@T>5w$G)xJ>x}O1~H|#aJhdfWLczZCmyN#?Eo4`R%In-=iPku(IS+kB2^q`Ui zULMcRDD=Hj{n2-r$9R5_+=t!#*v*uP<(f)fNnOEU$GcPo4nybBv+UL|PgQ;wuurpH5D+fA?5 zs(?EZup%psCIAufNDfz$*2SmuwH<+&{5kf>n8l7ZP)8Pxvzp7+eDWF=5#c+MVR|k4 z<8|k8(n>xSPRiF23~`_;sW47t2|3#9evQfRTlF(7pem^L75s6U69@Om%p5eZcM`7m)hJ9v*CzdD)k9W!o;Hqd%?IqUTGXu zyb%FwW@bee^GQ&u^$&Z};vTPH1P0C3Rlw8z{Z`=U2z*kL7Xp}YdO9rmlycM2UqPZN zW|&rDPs+RqQqD*xM0d_YvR>fwBk{ctQ8;eO(MU z0Xj2zaTsym*77}SYxB*?cHB^>pOIrcEmMcj&NZ<9f}$dSc%15JVm;&%tL3+PxP5*7 z_*+}}?<^{S>CDP111-t|H(t4G^uFwJ>dJ)=-43jY7f2TD@R}(N6s@_s!YCD)27E0k zFixQP#KJ8e(;^YH-Fm>k_l?V8ZD&Vw%Wksrk+A9AXOw0&&|cc{+g(=YwJ(hBKuFgt z3s~aP`ws1QB15@`8Nio`W`P8kC=xIa?ryXQ1EI?{cP|VCDI#PxhP0Qb%LM4NZ=EzY zsTcE@yB)H)GO8<3w`~NXa`wY;Eybk3sN1ljdSDQw9<7Cv7I;>3mji+;BoBxwzV!_6 z*TPg=#@3j-r>Wm?IiV4YLm}qYyng1=K|`T02Fql)P2BJe(yLLI)R=_*5kop>s)yEt zF#D?vdI^{~U5Hplfgp2FH5?C4ip>`=SSCN`L-VSQ2z9o)BdmxYpmga6@)sT>Zp|sh zp$FjXrz>uqak^%`Gx8IivlRaqguQ*7;s;S~S2`uwq1>%o9nCN_sBSuHKr`xtDS`or zR@?~kKIrc5HehL`Y1W%!!eIo#+VVY7Ul@ge1RFNqwoZ?K}EzS5TF zNcL0*0CDLtv*J_n^sP|Cjo=J;{cdzLp1|*B??`mP=1IWr>?@jqR`bO-VPguqbN^$Q zzT5aQZF)Vj>MPIaz^nV?=<&5(!fn(s%Js3`Vjz}q%#g7P(10GuqjwvFKy*fJAv;&;(2)n>_2Nh;@r)83PciuM$ znco*-0(lu+1(n=bu-trF|C*`{AJ1gImq9Wk%-9zE8(n$PLs9xbQj*~qshLtbz>fG8 ze-z&QZ~bUb^`4w6=!_gF>yC$cRZh@sfFJgCi`>vf&jHPakmIJu@;Vi5#)&SjFE|!o zHQxL9%`U1ute|EPy(pVg#j~8{d4#yN1tZRCf?2t(bipF!9V~c0iw=o}bF_;MxbvHk zP2tzSc?0ea5~XDn)RT#GhN6OCaEdz}_r>C+4TgkbOF8%?W; z61p3#%I^+d*LSwgChn~-+1bYyPpzZ{P&XFq`CoQ={$IRr8*ii8$+>?X3i8cA!+BoX zTx;5V(3S*UlAO~_SQL(Jt4|<(3m}dB?8Q@0$Qzos(E^o&sc03hWjm^z4;5?G7y>5l z1M%r*sW@YG!O{foaaFKo?xE8VG_>eRU$D@m0d+4HC&b*x667BiJ|&w?gSlx_RC$-G zCoLFMX)&>55*V(K2IwAr&i%}h`8g_aJ#pDM=WL8RCwM1PGbduOel>uuGQ0oeKk&%f zfA{!n?(2QVpBS^uu)@^xMoEv?o7bSOB4%)RT>5P0&&s6|U#K&~=nA60$O0|F&bd%% zd|UiobIzV53j(^LHKy*2CmPj7gj>5I@m`+t@C2QV!QHSU_)ysuh%I;Azs~dX&&Hp0YvDB*&u4wCAX1=JkrbOkaN5}m-qiz? zTDWYCxV~Ri1?#~nCF|zx$RR1D$#OddcJME;azm;5%r?yhU`DRRESVq=-^Z7)w}f( z`Z{yC_%$ED^GItW7xI6R&m7YS(+kZ#Xjy4s@j(pgn3PT1;ou3D*8xL>d+Zbw%NiX! znrEyRO3vP}n#3EA-(H~94+VlEur>`$=egU`wIz}T8m(Vc0MGUB4PmJYXU1b{6I>yk zWO)AB*D{NX6?is0tXZbozEl){5!(0{h+_Yeox(atWHunL#~;01{5qikH-?`uz*$=;>+Xc=xM6e$l@1y?B{^EfPv?{-=7i+~>@V%xT zgho}^R*u;1s?L!!?)t>wK>6qg4`;E+^u1Cm4run%f>4jMNV>qAd?rBzMqx4++J!%P003-5TSZzf1YQ5cM4n5)j`|N32288W{(UXsu>6nt<)FK6?>R6? z{-XJ~XnWX~X4G*jM5%st89)$TfsNk@2q>g*5DB-}6R+#jx2Djps*A9QCRyEMvFNE3lds*V=GE^l zs@l`noVW96hJ83o@;NW@e)W8=1+ogHZpEy2BO}^12pjivh(OKlvBc5{?2g$+nj?j( z8NS7iCrAUXuvs0BkSW?*cl!PH69Z&1pyRDB>Ad4Yqw#FE7lOSqkF<)$6BmzXwa zuzVL?a7AyFf)Tuc3MdvkHQp+%GLQN6X6Vv4X46Onkx3HRe2Ev_?XO_}mA1~E%3jO$R-55y`3&XXwoSXWcEEHR_LUP==$zi=6B%e;{Gb#O{`9WD`BY|o~qalC)tC7fMt3##*2))fI1sI4!vA4 zaI55=>cO4alz4KpzvE6YoNUsBKg!?e35|2Uw`k2$h}7o0n||B=ZTLT83ML5(wy>tE z?#DL;BX;EzL*u|fI#5QV9xu%V@l+5&w6?hrKoWfRCCWNk?&sjRNVTDs`ZhMK3+wVj zXArKDd0vCjFCl_fZ`%hr<+xFxKn+ahxuxN7sSxRxxt?T{qzTkC(s=(sE_ZrTCt^!5 zDIqcki3Qc4OMnnyZrmgO__Kum5;@aR(c+iF3f%W_vVv&y>a*zCm?p|fRr(x*!whi} znRD8Px)YPz$K@VaX$MKIREDU;#PH#!d-M?`nopgJqE@8o|AqymO|i=&B(-|#uRUc1 z$CE=a6U!D$2%%mAifhMGmLsE}p=n9{ae|laqC`$$^7Izo`!I>y%j!_ArSAO1&7Oe# z#2qU4IJn$*arHGEnTF^$T{1yf7j?RwTq@JvwWzM9G*`FSfnQ*mp%tzxw{`oTfakNW zlZFRapBO?6TM7%)*5E#EpGulB?Mu}iFVY-yO=aP$7CJytc$PCwOx-UrOEQufD-7|O z3~N5f4Ep6bo7EIVl690pGTeohdatz2n=Q$S;KWXft~6tzRG>g2hiTK4jec8i$MLPYy#HUwfVnr#WW2^pXI5GJha2 zLo9Tf6G-UwQ0#gvq9npaf;J!5VAsCr1w3{lri4QDvt@d$*!7;N``Ke5zW;1xUS#i@ zh_2*b56MbN!r%I2*(HoSh>yK=s60`z5Isw|fjB=}T+IdvFpAuh^4hobnI7M}2hlE9 zk`|w;`+d&%EBuRA!zGwOME<(GooVg)l1WePT^5YY0ah3m-)ITZQq9J`kT~b$J5*#I zW0VsIAYcGm@?G{_Rx1@{LcyUcBuTu0cHIc5Fw9J1oCy65=LTRkY6Ftr$CZ=S4%6+Chybl9>Gvue92*sVT&CU;J zQARtG-A#aByHFTL9vcE2lmQ}_nP3j19-h7>)te{WU38bzGoXk|!M-=)IMfJeAx)^T z4O7Bkebn>TBHWflNBeUqjLE{t>t`}X_Y2s?CM|Lq4E+ZQ@wy6GwCCt(xz89gx+@6T ziF~d!s5u!}iLL)veUIz=QvUZtIN`>rb85EhbO-wxbdSwMd&6Xw8D%Pe0#y~BLj_*} zUzU*lbb5hwSNnnepDfer3g%=)k;)^z!^>E=WbhDNP#>xXnQ-yoODE8x{#K+e0I@tT zn0zb|)GqN>ZDAOpkg+0S%eOU3q7hasGuDlZ#vtf51Ut@rtSv@M@=?K-GW|1qNCP{` zY|X@fjs18DXwdp!fhh&1U&&+Whp1DWF&*oQ<9pVY8V_9{Opi!ZcH2@c__a=RuYzUtmqk*W5|w2*|8H=#!*c zzhE=fnk6CZ?_t5{j)(qCgRQ0DTscMaz3G%DQQs2WCcNsI04)ss{b4Z&gqS8X#rT(U zP*7}xY?}B`rL(Y~N!j9V?a6bR+oaBH(jyg#h9~_z@WIoLC%%(JcE|X(fnCpzc(Cio zZ<|q84ZhPAt;qA)Ig9!}Di5svP`{T9n)Dikux`F$p3&odb!uS6xMT!kMty02uDnal z5z6NI^tlb)ul@Y@y+tx-PWEeVwg!*c8Di5Wf;tHMuQf%5!YKH6kUJefPGjFuWHeP6BVLD0&L5aFT;@N-=x<%Mh(rJ1OdVGdv@^ z6?w?pSyj)SL$Ck)(%*N1SiBbp zb3qN>$BnnGAMyPH;oPJ(q0(Qv{FuAW$&SM>fuAD4#@7N5?0kdcFQzdbHX+ z12?i5eJ{h%w?W|>pnbtY3|c@z@J^G9c|Agc5{cJ+Or%2%?7oN;f&$i=AsWODAN{6l zeXJ7tk`%6!YW|>jYzQ)zs@J2u-F|-mf}%1I8{ne=PQcTdb5Y&nHPo^0yopYa6~cDl zt_xI%O3Z~47%wYox|0UZ4ek(>9FNwM2Fhhg^lDPKtA5b=f%|nfm0d6B!9l2uTRSsg zez(ur_T=*EFPVJA&0}#ZH&Jpfy~Jc32?3isF0dGb3khp3CL=rh;z9%@rc2C+rgrm_ zds_5LONienhsFE^xEa6N!&QNWE}3~WD(v%}FAVDhUcPT1Ga}M_tmzu3%}%q(nQ7i7 z4I8Vep`Kg{nh`k7RYdft7?FlxWy3%{@4E^AwEpG=^VOY`zwi6_xv#!xZlRAVhZu+L z2k;k7HXx&pyF=$3;6Eu*ne6o|id3olf!mo<@tC-TE)LaDhbrK{{b*peKzW^+y)!#Z zv8>j4Ji`{q9Xoh|J#didT7J!JxdK}Si`j5lw2dR=IAMO%ql&Ypk*B2hNK~hOZ!edY zPq#BY5A!~aDr&bjYd_Zuwf4;5M}Jlr9JeY4=^XGmhw;r-iQtc(Fq?(tNUq3Jo088W zsHNO3Xln4%$#a{nuwg*KtyXX%mSEKPa1}U;gURf4&7q28Zec<~?fq&Wdk#U&oSi`O z(ThKYY-m1kvlpF`F#%qyGo)zyIp%88eSVoi*1QpqiSGeqYh(;AQ&%$~BjgtX9 zYN4@Z+V$~~pL2G0MHZJFnZ6t1oHBTOFyoBlWS-}9F{~FxaBL%@3I<74M?jE6Oz^EO zo>a9<7Z~`&_^HM*)*Pz3JdN>7Ve_kXvj?O`dbDX#@@b*Tirat8kx}F0=0FHHtQ7c8 zgv#2(LsR4qMBWe_84qSIf4sAo&!Mm&*UyEz3YC)KS@z1w;Cw$+kbhe zZ50eGjd?%|j?gnb(ZXP)lQvByD8P(09)g~Ir0!7V?+w)Nl9&_{BGpi@OrhA`v5=Zz z#K}aub>n8`r;1=hTv|$PerR}MW~V}E#S=J8h7P*BFhOlaRo3s0LIM9fV4t_Q%|AJUt+ZytAWR-u>lNp%*mT=UReld z=Z7Mlc=%v#9FwQVVJMGCOKI)m@1^@4aY*WSPxTIcuHUJ72Z#c+xCwMT1)RUhlI{jS z!eKaE4)sOMA93gVdfx~4S6o@H;_s^5xO-zla=5Llc#YTqJAvMG5m`TRCIH?|=C1?c6pdppin{t769rvDg~ z;=pGSFYI|PZ(aqu4|3fpdiD8xQuJH<6%J<>ko7Mgtab#S%LU(P<%eFwqV|@ZjNFE4 zM#(3^Iq+uG5oV?}A3h^#$?Ra5x}gmmuQS+a!rt3EB9E zT5eT5ZpVlX7o=JNT`PMm{MClnOTq%koLIF^-2+mHF1{ukF5{k6IU)cKBr*<8$-fNhT^2TV+)C z=YDTnoJXUZC0UsQ!4S(Px2z3{X`)O2@a9sa&(?*Tq?>f!<~~B@T{<@R;e$cCO3OSE z4qsi;fhT&unTKrN9GCITWrs{N-I6zVhK6Hm4=4Y#m|32+w%Iz6dBX{Q0#Gp>w28E~ zE0EjR*usdR&iEfZCTi@z(7>%qS?f(!3t3tH5{nO;PyRQx7f&viy>+O?h`Xm`r|++D zFM3BMO*6V8>QG~-tS04>q8e;m3)r~hQCdnUd(zkUb};KdkRAv0>}$0e2%#SI1#b=> zrDNsQHzmhO$Bc%jXh{fux9q8G74UEWaNJmD=CD%q5VG%m1$z61Z@X|IL|ei+r?a?U z%scj<`e*sC{)yWpTijXpdYc)}hzu1@$&6nB zK@4hJE)cd+w%5lEn}lyWJ!WIuN?|gpyxz36URi2#w^JVK_dj2$RU{? zY_c$k3|fu@Q04Zq%Olf)YrXAao{8N&R@imR~07g+^4nd3Wur(GmzegzAp+r5$ z;uY%8r$NWf1mm1+hPTx&piF_Z8FV$S>+dikLXd12SZr%rQJQ%}V4gwWeeyRVbd%JD z#1}}<(R{507D|xA9>Z!A#=a^f(X-Dq<$GxjMY>i#f-h4tZAw!z_aYN1nCg$oqgs}o zxTVDm#9VJijOgp5TW3Z<vjN5x{a|{ez4~x=+@hAv6&)+zhpUGpy^6XI)Erghy8FFR zM!a!?WD)q%-Syo(RM?ynz{nP5>Y_;5`QkWysjU~t5~!VSbj8}J8`6_1Ws!XQ6DgUb zy=TjPjjiCiK9?g~>BB(|C#Yp_;W(6rU~}+XEK5Z#UWwaixNH!DxV@_|h9nz!S}078 zH*jJI8ecZOJv@#bmk2YrxL0^}IC4-YZ?rh2s^I}cfT!g2)4X@9DTCVj{K~57Oeiovw~r=4vt6j zM$U-rZsqDh#X$@Uay8vdo7_ZUKla|q`OH*uOAJ8(*Fwn{G`-SIo3lCrkEt83)kgFx z(MF&M?hoLlsGWOQhWZ*bzx$t-^c`*i=TE6`qK*x`rM^-61NsYW&=$FWri?)4^+LsF z3w5pe77CrGGG|y(?)h7Ai1ZDs5IW>3wMOVch4zVKXu>j>_Z;_lF8aWEu-d7(6QO}1 z$E&>quqGy=VIn@WSp!1?2%^V0a}R~m+_ap96F^Z39ZnKJ_&3DUfmIkfqwoo5HfWS5 zH~nScn|r@fY?I#;*bBS|>(}RYsk)V;Sy^*f1ZwLuVx~&sDZ!@yOfGVe47PFN)w#HZO_OTaXBf2|letfB({X|;O0pFQGviS2n1|KHDV3x4 zyxdSNroT9z2y<~xP5QHRBSd-C;yB>T;0i`i{MWgbRe<7(XP~OQ6^D?<>zrG24K!?r zjin1@7m9t~)Y6DVEJ$g`!ugxt0iJ14| zX#HBh1a9e@3YN>EIJL)4oupNE=t(7H7<17+)SHM0=#Xohy!hWDmCp|5w= zDWHE0RkH6mBST0t&n`#AMDXBC%;01%I9>S6eOb_Kc6>+*igSO^PUU35S!GS*s^|Vo zFrtX3QOKtuw6t;Y$0#DKbE0>8v&&q*N7}C&hIu5q{rTN{gtZ3hB7r1! zF#<0`?lHz$Dp^x=K5VCpf2E8V1yZz+t*M(^D?!aSi$Gw+ZQ-^{V!ax&Bf9nG;H>V& zlJ`BapZOxL&F?gS>>EmYSpPfHN_BSVxqzJ66@WPydXLBe_HMwn6xkBBGi?#Rc@7=l zVZT@~+vx6-&`#m%A~hq>_#f|Qny-6~-}c_0 z^t6;?1@X-}1Z6p`zBxoLP|YnHheZKALLLkJwmT>41^+N3&c9VISpMy^a_IfgY&%dg zG{zYPzQH84cF6JCzm{{-iH?V#%9=rd!%B6T)idxa9j!?MAhokqIhTb6le%O$p zyUy50qOSvI%oTtG9-zLj5pIPB#-tGVlo3o)EJaSt64Ynz)tiG!z_S6IBh?^;L#?e2 z87|~x*DRY?AlbY`pl4U!GezPF#)wsoYWR*%yC1m)KCrlp5&tYaswin%;6<5Gckj!s4RT}v^+fhWhY!L6`hhGZ81yM zr3pT=!(s!X`#WL@1aCd;q%WA5Z=Sl~>eKg&I8>^h*00hYeK-lA?>KQWJNcVL>{T``BHRu-$GM7&<{^OgOpnWBdIo7=zR&zd7G8b`LCBxuam+026SeKxU3qHA>1l zfc~LA_?oy8Z;Tx}@+0*g7~vGTA9|teb)iAUG<{MCnZ2z`=<3mu7qR8#hE ze-rpIBEk}4X43K>xXv4XxZX}V?WCRI)2?UmbG_nyvAi0au zCoF9glMifcZ(fjlf3heA6)j_>6~!Y6|HK_9+luo(M%@x{2(F3mDLY&E8OpW)?cv3- z8;*m$W#7%Op6eFC0^nA#Mb^2M^eUVD{SVlo{kx~DQduRfp8CeBL*!(1!G9eVr0fIL zmT=Xb&`u+^t;ZpGP)+n_R(cMa!5rwCo6@~Qg8*FKu?=j!dKRRqAjRn+Svs9}UiY!i z>AvM;_?V}$tkRPkO(Jp2e>almmjE3X1|_AdX1|Hf_F?t_TogeMYb0S8>OprY5&oEz zj7W!(djnhNpik__E2jYkrzYZD#UTtpXLDvqI!%H{vVkNiHS=>fYi0*>G6+X>7KQ8Y zmO^^E4Y5P1?<|vuZ-gOK7&1uY4od*0-S1kX)R-4k|B*6^l@+2C`)*NnuZE|Q1eoe8 z7Sk80Y6H;2jeEcL`1TuEe&dmiRjw0fYq?a776-8pm-6(!fo3_hhM;7oB%mTpMB~ls zN569^wENiLmP{6*8LGois=n_+?hqAQrMeTjl%$JJg^SJ2?v_A0EQ*%A9?8V%kWvOK zFA;o!ED^F9oYLZn=1Tvfw7te2ytXu^Vg&(}jo7NV}FnOU8x5`U1U zgQh*f4kCfg-sLA~mfv$d3Vm#M_nrkB1)PX2ofb0+v}WsmeZ058;AEXrvwcrNmMhH{ zCIfiM^PomY!00K-7{#y>PO~P7t-bn@43=magA>zA>HE}jB+aVs}EFdB2K*Gv67wvsry9B|g})$_qfR5udTZ=E*z+8p=wEzNo8 z;IEM??J0Y9b@6cEVvrH{Z~_8NZ3FMxgT>G#7_fE-@@d-P3?BPx(c`t%vF}P^++$@u zpe4fWpidoXJ4v8sA<%DkfT43N1b)lvuD=?qBS(nc&qC!r4jp?vEZ#1RvO(CHz5%RM z)oXu$gE)nZ=)(o4cvQjjtC^NR{mTvvaVp5-neg2`?Xri;8H_ zkvmFBAHZ8b63yYF%!h|yLxo0x;M)Ht6J6ygOOV%KG(HSp!GY-0veva_-rkIfut&ZM zEr&YhV?PMPzxmA&^=$R*H>TvK?h-p&{bq{cYY5gv#))r)V?&fNS`~6Yf~-|4^aw!a zGSVf3gkpk`k=iB0Be%@4VMJJ316}aIAmyf>p@b&&ft1*SxWE6A+vy(!W)89qp#t=1 zZS5?`em(wgF_eFgcN%+g2kKQ1mRU^NWDa67c5E=NA~)h1qye_*V5_08!oT-^E)3q7 zr4AuxII)|1{z%sRO9TWdse913$DrP70Y9WfY~&Cw!})u%$?*mkBL6hXFBCLSRHc7N z)_a%Ri#wu2&=lTh7dG1)hx#~%X@mxphBPG!crWj!^nY3aLFx=3GOft)43I#vDBqq8 z#dZgooAk>+bbg$$48WuI2?t4$f>-2?7vPH^tAk40vpj`M(D&Qgv1)e|)}P*y`)Sdo z8%Xu6o_4ymeRc|FG6Mg!KfXl$HcI_XL}l_e zi^u-c+OKuVF_^$VyF-WM1^f%KN1RlP0ZvS}$#(zJLpl&qf+d|g1@A5d0R@tBW8YJE z7$>V!alQ|ZOF)_zwcH)_K=q!hhR>uB&w(5=;}{=H#7V+Q$lOyaa4%XkaX@67{JWLo%+XlOIZ6aJ;Y`+!DymqqU%3Vi^2r z>ap}7SfqF?ufi&C*FeVK`k?e92BCOI(uJ2>_qD05kYIBHe0*wW>1Wy$c72k(zks;b zdu6P?>G}Vy2r}+1NkF{*m{Gy8#F$mrphnoNoWj2f)3QC|~1e|D-0h!+E^=zU? zhG{57&}=Z#>g@a@fe%>NbNq0(;XaZVp6^aHV1sjEOFO`PO1c0H zyuZoy;Z_ZTJ((=`8bI|F%BQyu8-;g~AdQdfWTfnxm%u^DqQ-=eU&1^Ip5Iz%Vm3Hq z5o*|`PLNy0_5E3@e)0RT{7S7U_MYc%sHb5SLxUsVz=BW*v?($Go_zt~5MDm1f|h2? z^z^qpL`<$qZng+uYFE1K%@;;R>j1^5ys`r4d^Lq^nhKkMk6+HPP=ipMA#zsTW zEEkI!dUqO8Y%B|-HbRI$j+TyXnnyK(^3K&wJ=WNAOhBQL0wAS{$C=P^S4fi(hruZ$ zo*6wDtgofYaIU#9vW$Lon2G34VgLE`qHmJE9*1Mn8do!RY29GU+o(eAFUi&k1IqL1$DGH?--YX|~Ndvu!IgUa*) zARtsf*NGq{h(L?6pw#wAHRI@ihzHMXdpy{n2BT<2Y1`K-@@SNYq#O=Ek51LY`eB%4q)novqanM9(E4km z`*vdEO}BsgW3f+?y^rf37fg!N{!RYp>zzNAJNn4Y+z7qbOcThiJp-}7kX0DA$+5OmmVHp#c5fn4T z-b`NbBigb+6m!TSGIbI>X_F)OMa;f(Kwr!XwCRO3`;wGEK?>fc%!hz?DtHV;57AX$ zUmH-}L)F)a0v^YX_CyJTJ6)T-*$4?*AdWA;5jALB{K7QTK)NmOQ@3g~OqIWCTR(PMzqA7Kwsq*Q9J!e)Ln zOpXI|Vdji-tZ^^f5kHtcVMZNw`6{5neSKcl)e*~7oxDjc>oU0906YIe+fKQH1x8F! z2>O_%SXYy?kf0pRM8WSw8vVz05tgSyCxdSI7gL%z;o;YMC`i~Qe&okili!NmK(({ zh@t5|GBJ{!O?=N|_1x42{ugLoYnnY&rw*N9*Yh)UqYz`quu$0CA|&wRs1qM+67^wc zU}w`L^QB2TqG7EQ{5euwi%wuGpodM8nx6;(bqt=mauW-Q6;jy4+$Hm0DfKeqyMR*Y zkevl)V>Gfmn>_8R{W0}>YxG}I5f-P2LVj$u`TObq*$3la&7>j(7Uw1g2!{=LUucES z`t;t4(0HYaipmo(C?L!nR6$XbcIP&n1=i`H*dNjrVkvZ5?qW+fpNg2iB>%A=tc|1t zoOD(@mXI6wdbY0-{G|@^v7A!zK7V5fHANW+ahSWxX`^fI|V5?xMr-OWcx(UGzM~Ux_l?e zJq>33n%{QfGLP)7ccA~V0??tq&&-|gj)pn$Kvsn*up%av*HGODXEi4L{!$`HA^zZ~ z=|+p2WsvFOs^Kyq+LJfudf@6Cp>9)+5!ePSg|~?xC;r6Vx$ceN*2o)C}WB102>MwZoNh>gy54{^bq0X=NTkX zn=u4@5!RP^%;s3UJ~qA2hWr=_s7c@i8x6gguw@m~bhC4J1QZX!-QCm0+zO81U1i|E zn^fcS2%RqITl58WT?j4X=SaQ60YED6xBke&&T0~F<*zNIOnfywn;c`Xoe<$T(_c-e&D|5&;P_By*@8{3U-+iYy7vEA6V z8=H;M*f~LCG-+&~;KVi>=brE0`w!;X@80jstXXST039gP2&MRo_(Vu{up>)`TUa)d z3iW_n1p5Y*bb*mo=NvCtci))CB1pOx$Vg_F;Q{{K6aUq5R)NF;7mCiG zWEF0NbYL%osw5KRf5E>9{n@*9g;X*k(WOimGKY3~kyH@yxv#Ye@cHj>-0kXumKI4H z0S3xM33-cb4N=@|@4Ck)yhMSrxSaloLcjOt;OXd-jO)iI!MI)v+*@77`=PLf;$Cd9 zb9|FZ{Fu^P6v9D~E>4pFEp6+C2aTi`NNQ<@p9OoVK#;wF3=>+|UfuBvnxLgeo8FD- zD;hJXp_3CC|Bw@+Nosw>smBd$7?>Kd>|9wrk*COwJ$8L2T>KtuB2(=Nidiuqvwi`_HCyq^PR%e1Hr|01EqIsUw_l*Tk)F44BT zD;QQS2!;FDMQ^)0Nr{3dEuvwgx}5Le8X3~0xmEfgZCgaK?~aLcN;k-JtL>k$|3e&A zYX*Up$U-fr3(wJ7Lh)+Q5Iq}pp(ykU?r{UIw-Q_`@VZ;V0sW8d#yJgwNg9ViHbp)E zASMTQ>_Bs+aSOyETfcZWP^ZiC(sy9PmeaoaA2YpIi%}i(3-8xCJNA?4F@o+5*}ktF zoc=vV_tcOmei=O$%QShJDyS(IKm>J+(R+_9-~cWP`psctvUQsG%(V#7AR@Zuux$q7cp1_4B%0vA_W>ALMSNX@JumsojO{>{ zD_q&iMD$>zv!t=))9%cP*e_#LZ=G+ua`78ABwzCqWKkQT>D(qe>OAJ;+k4vm!0Y~> zYRjEm7uv+{^@z?5v*LZ1Rfuxt>G7)@bxu8(RopOHLM-&f55+ISp!|@%oT6}1qTXn_ z^*~tg^et$5Xj*rFu`6MJeQzlFhJ(QoSAshk>wARks`a-5D^nXHIX&XLd?I@Da+5ST zV?WzF5!4pb^$08@q6UB+~L3FO+yEKQE&UDt|Y>cO|UDc#Mvwx zN=yrkVF})zeOY;}Uh&eDeP*lF5|YjS&Qj)gIu;CZ6uP#vl8SaxErQwA|2^G9u~9Mp zt&QRQ8^95&yfctM1q19^ zlW`N!e||m=P1}Lc;3^GY25ig2a>noCrc4>^u+N-ICwafZ?YZFtjOn}yz1qp;Y^V&k`Mxg{zp80&3{%&)Hoja z&IRIsqG()udv*SN!zljg)TxmBvgH)PZlq`j~nMq5c_CMWuWHMZfv~(UZdx-*Nvge=Nwq@WhhBcOn^c zN7#mx5mtt!8>C}b8z8lTZHOWJZ0#sbwZjLL!B9-CvG3;$ri2xJz!SZ|;D!rcWO?&u zyncB;R3}L`?h_`%mS*94ANlXv05>m5WK5k5%1>4`;q2g{NlSSW6Xs04THR}*a%Zg< zX2&CRsA=ZA$1gUtZlI_pDdXh~qE4O~DF%>Ze$=-3Nkj&NuEqLBFPi82sXD{Z_Qf#^ zW*z!CM319XtbaoO4NZa+W%g^DLy{#{AeI)0+i?&+z#9m&uh8&T+J*QvQ{HGN?nMZh z0CBNTC~F+p*w;xdjRozYCY8e5Npc#gLUtZ%urhqJMC~|8NnyELx)(h z?{ZkftDS9Zw3y%YhJ|7j&9I0xJ6{~V7r^Cp`5v@Qs!lMVa)^5-% zo^U5);nKUPH!;u#7q5e?*lW>%Ykswa^SPyvv+x5d6A(m{N1l z48gJ&{%gp_rJ70qF>Eam`+8`H<^Uu=?ImcxQc8|MPUxo(`L|~GT^+VK;ZV&cHmu-v zEK?(1c~e{n%v=L~5ZoFUl65Ut3So;K*8`NK$JcFoLZhkoySWfD^35~UG9GRw!fRQq zI|n#O2r2;!qGom6(HUz9fPm$nT*3XXm0C?lAI;z7IW@Z8l8)FM{-)Y@dtDaT4yE-Q zJu>41iVa>YUNlaO^OXv((bw;N`D(B9oS7~3 z`B7H`Yv$jXkVkI>4h$~VGwRTIvblVm;M{RL^JLi)$l;e3B@i+n^?q$c@qW4ik>qXq z;(t3h(7hFj7Xz>miXt&=&4EL-Z)}+rQmf>=YCI9Ecs7!S;jA9I){vQZ!%q3Pv-h_l zyx_#;KcH+#`-|>VX&GqnJH>>g_6UFF-S;aSc5~R>1``3vT0y z&S&$-q5JDl*6-!%X*b!l>Vy%AD^~Pw)06P8BEfr`I6k-)a0PL2LAO&EYhDfTas-`( zwWKSgLn1pX*>H#HQ2y|^V0gCPA-Xmkq6MFZJi1(`fN`Uk&L`%*L7OQSjR>8Fa-L6~ zz2UV!)iB!DVSCXGgLx(!8!hDPF|Uq5NvGuIOvs+tj4*oKDdL09dCj?c^Dc(xpJG6Z zS+i4xh3Y(xP$mr1Va-w!f8OXZ1XMR&AVrG3fc1R+ek7Gx9NaGn%QEzGbs?wHr$7{P zg=u}S@N<+Twq9dqu3SQ&OTwo?yu?ceF(3Yso*vg)s{ER{GiK-jY>MLnBk4g%;3`J+ zJ3=Qf?i^NnGJ;`aemxyMp7}6(zCZW6J}e3BU2aqgo!O<;0^=2>+u~{+IxDM+Q9)Pu ze-IJcE#R!E{jc}KTU5S^e>Q)?E$;z?3&lI0;LeT4h5UL>#@%?TOL{DH3!vPyZolEd z`1Ty)WIe5;8k#MJ2`MaTPV9wO&|7}F3#=+SaTw;018bbLpoPW!x;Hnx)x=b};|azl zY*dmR(?jD#IQDbv)v7HhVDMe&Q3(f#aO1;KoTdO%16;=M4M@EZnKx8i4j$BDXdhBk z`hElgn29g4QsV0ik!uQvs5L|! zDFT%W+uth=H#FPVhR~l)KRF5q@?!EiCG!GriJ|sLNugjY`{hXtn9bTSeIU428S2mlV-?M^gYzHoo`gO@ayiE=WV4zcaBNQP~m{P?@&Z@1px# z#!a$*ucV(Y8iS{H3zqb-B#T+&5f$0@tJIN2cW`+YE6b`Lc=+$i-MHzvWm@}K4OP0f zw_U1FWe^3zkP?H(VuEheJ603^aMUy!h&6{-wvby`+;JCzc|yR@Xz5SRWPwuEnHLO= zwc00Ok8RWWBF?m1!k5Sk18O~y=rLdZEyVv{Zw*orfUljI&EgW;)nRxf=0_0dbd%E) z2x+@vT>k-*)^sA3+qcx63+WGLsjv(^N%xXw$P2{cw$s!|Q%LLqhRo4M4_vr$4cPN8 zRKN(dd!(O<&*lD$|JhX)il+wsrlc+T06Fcm8Y**-zknY*4|U>~6Nyl46^?Chz9mcO zWx4?A9Ja4q=${e2R?eD7#LFaw+#{`P{$jpw{dyYU1K@Gj zeFk6cvjFFhOW@~><8$~6%KNUVTKG1YIFQm$Q9#8?Nm_e60XeuZ9e*q5rSvEd=b5cG zO>23vaqXEt+=9lrYX}Xm)#GA&z6;#7+2goy7%(stK6n=wMg9iORt-L~4 zFA&nx)>Uu#1%F8KB8fDj4>%l^8#N-y37h* z!RM<3{X9MXn2!dKRkgx~!4negAwV9}b+)f+6yxr9RNZf-_{*yqHD|a#d)P*$xg*c8 zoCV}NgOK8;>93*JtrgnKkJTd}aLg+9DlCHi9^QX( zs3&x1AE;lJ7M*yOMre*t6iz>&=@8`=E;9CAC=0t?GJtEpIW}hWRTkt4rRO?omPF&o z4)}*Mom4Jx5&t%b|A(|`fhV7R9b?|w+#x!&no-GArVif++id@M!BCLK@I-VwR{Va6 zn_=R@QXiLq)LTc#zlA@awK!sU8lMYOji4Sogb% zRrq&xJtN@>2H@~Qiv4EmP9_<`!Uf%oAIM2wu>2&wg*^+|!BS4|6o$duwh*XuetI`? z)6Xl7tJh@TC1pOq4TZN?F!i!ISW52^BEu&6`7@HK2r`JaMm!++;oc_9@gcA~FYczN zSpI~gf{o35F$Z-2xh<_ zdb};ojPK~V64p&G6a=z9LL)>@5LBn+Jh%|AxGo7MLkyqR)QXc?A>H})sV0uyq&jMllJS63(# zlnhnO%SE4d7mB;OD?>?Sr?D>Q^qrdfH;o?JtppIZ`hHlYAz5)-q6@InZ9CNZKKw>_ zUXrQTX-+u1+Plr=4GcpsR-!qi%SE1VYhRTl(1Gl3o`ti<7rNoyYF#Ze3nLpVLlk^M zWMXg7tQPAUZUgACi(ZKb$NqhA8VcK^qMqv>-Q6U6y_kE8VYU-W<_5NiHIgD%Pjp%fr|no6=w?G4w^PMJl!y z-H(@8p!t-uH*YpsI2~DQH-6c&I@QZ1><}b;Vh~LeS^r2bF61x@{2t$c4!uwFN?!|N z4fVQ)_(mJxc8ahKY}m46h@3h1*SN9)qSPb9Am*^kqBwSJjY+pKoKMw-DUWkKvs805{(#Mo4~Xf(#eSTn{#Q0f zqnZJgxptBD)7bu+5DpkJ9#Bvr++hlGLiU9p>>xAKvvs-A00IwI2zjwt zIyrrIjaqYLx!8*sHN+>IQ|XK2<@@3Cc0h+e$xBXs-+$NleWq#B-=FV83ayxQD>f}= zNX}9m>>>j{E^-4*2{oWQ9^di#HH6BoJl6cDKdJ>9?&8Zm^7oAR3RI+op2_wwg*-b0 z5qiO3I3}7Yk0lO=}M+2#i9br?5;INGz6I z*$H=TWO}gDO68Nrkq~YRkqW8447h9_Xx5#q1B;SB3?p!z=gN?}U%uTJ+kJ{put)US zwSOGm7#>6y)c*^DBVKuZbNae*e%bcDF~`0=@7@jbRz}1s7gvIpW+6%`NGdWcE%$p* zEn#NTc7x@*o>q@k?)MW(DMP=JQXmP5?$6>Rw;y-@3~>t9CqHe(9&hxW3im@Cc-Nak z#4ws*fXFBcP)XDXvQ?pjOc1)Vbb|mEeJ^!ET>?XlnDL8DrV!gj9GEJ*SFdSSlCiJi zSXK*F7$zWy1Fg{Sori?FhXwXQML&FeOy*Ccm|qfkf88RD@0D@(cw?BQme~GK$G$~6 z(2By+VspI_&612%9FB*3!UFhN{Tu5!v&~;KUM@Hh zdNuSxNNhq!(Q?Nw%Y!}l{DoDUz6=>g|EwC8tO03QUd~I8PC9cqaTb_#{eJ#zt$0nJ_PwOb-!VZ0<%%wLo%CN2Pt783+R#MoD zZPF~=R!P-#_eElezRC;y{IN*SPLe|JR4cjtWkLL{@9_Mej8WZ`1TAseqC-pmVb-0~ zgvaiI`AIkANeSQizHVFu8*hpAhJ4FkxPX9&j_);tf!18{P-F?!Z%S`$tTF%}b*NSn9qM%LcAbdiLZd7~Aay3r7o z>juF1Uh!tZ!8vsKKL@|R=b}gCoDkZX65y>|gV3Inziv2OvKrGKIt8-ZP9)SSZ9jBm z4zn<0BVX+TO7v`!7g(7&UD+Xk$dQ>nB$@rD%~TT^OR|+zC<-);>#PGIarRyBg1^9W z-1-){trc%=kAS1qn2YcG&EnE;N*`?$Yood~E_xAz|K56yt%%vrkq8NNd6#P9r#$`9 zXgfLM)rGIqVlx>nDNvK!5FA!t1km^FWpM?7U5wKn%K!D3_CDr%o5`((XsX2)N;ViX zrm_phVH+~e!DfPaXfc7$&^SL?J1`*5ilFsfT6glFywgjd!o=z{>XZMlZ5)K+zsMPL z2&*qDjJ+9;e-X@CI;#Q~1Wd2KrIXdl%e)6bT;jI#QETh2Z#hVaKo%fXEl4Hmi?z(} zwq!N=m}}@aKj;iU47Ie;9vt$V$F^V>L~EsZLmWXlDBRkA6`xCm!h*283l9tZR-(c0 ziC5%Fay3K^<0Yzo%_gcF{ktk^2@3Vx)V^0FVyoW`NA_5lhqsV8K)GcguIJ z&U5JX6IV^WH#u?jycET;#oNt(-fg}aBkAXzh;d)FE;|IS2wGUFX>CIXv0?+LiNB z)Q3mo2)xORnN8v%dwqwZxZj#B<{$xsPI#gtHPoV1nGO%Dwqpi>Jh^!s=0=Jgn3e>X z;^uMTC!D=NXmIm!YpeSstWn!T>oZC7h$rMAi(aTaP77nS6j%sd{{~oUw@ty!@9cEL zi0Qv(j4*%N&ub6uN5mX0N6Og@ghH0H3Ms(~ z7g(&X$E#RSL##ddunq?K2=iyq`D7bTrH$!~ryA`+AoGp0?v1D<;%)Vhp$D_iDuVA> zpnPI2VTm*xu0NTapmHXHtP(h3b-9FL0wXBw)UTL zW%xvop?a35CJwXLo%jgK+5+p#N(BC?zhb&mG zC~fiq`!Gl%bTzeJfGQY!?Vw_DOJp~^DlQ|k{|@zM-b4VGRWQ8&ux?J{7awe~wOcGw zEhS6^HuqhK@f7ZQy1+_ioSmA*YZ!KMU2_2x;=h#Nuoh*6(xp+}&(n<;mP7PR2ik>O zTs``#zJKL$sU!qJYUG@SfBk6FI-gh`nk0j+6UI5WQ@U;AbkNvn-*>5L9R&)OZ$*z-L%$`D6~A@d6YBt&Y=Ie7pZ-cLKWeK+#*Fjl~5 zeX6Ov{ip$x&icGKjNZ&JRumQf;!P#ZCVmgK2V&nI%>ErvE=rpitSg6&g+%Jma5yND*h5YYEx{Cj6X{#UuzaJRZ;9QwH zXUhWndBg|j#%1qOMfCVBLmy}v38?P8wKPV{g0QDDXGrBdJv&P7sN$j zobep8LLjRyrB$`S7S<4}ukx&c!8^jdQCa=X>t=f6BGl}X@wLB8AU=&|5uLSyevhp+ z&`?$lHNi)+hDzxy3$n*T_TmF26vzv?1qzQ5Mho&n8S2YELdf;|4N)?92PZpEqYcQl zCls5=c453huG$Hh@*IK}$|e(=Tsu3`vJTRWI^vfsLPMC;ez>K_H6<8ce|y$K5troA zcKj83kDA%b@Ahff4)B;HS#@Cb`s>WMJ=xIqUya>5vmLdBN^nL(7KOqc#Y9DhR)qSM zU5Eq|9#w_t6He_fVk4%F%R8p-LIs@S`l*-FiX-EXX`(?*=N}TktvXniY{nW(h_Imu zd-DqmWOcxj3gIfqEwO5dz1CF+AVJjrXQW|N1Zo@0;iF7lOxVz}!`&*Cb_I}G3KcuU zp_toTETmW3!mvM@?l%b`WlZ`@V=#1h9DuRwLb%IJTU%8s*Xv*V=fst8uf_83uKE84 z(ivX^bEir-Z#MCr;EC`Vyb^!4c;-OYk&BOzR8Y$86*tt8opJ`shXbV}B>#RULpz6F z@OP@v@4zXc+qc{MUF<%|jP96L2y4IeZRY()WiVct39vJ@12w}1RjaN}Oa^r*oi=j{ zcRAt+4NnhR5CMFlJj`Zu;Gi6@Lr7{!8x3SIqwxcr4_zIjk;6|gw0r+@-nwzb2Jvfl z)Q(qf)41HjGEgqwQF^OhT{u?>F5vt(M6ACad%XpI9=6rqs7w}!1iuVMlOSdi;lmN- zi8pVV8iv>Ag}5(7O=D3rr9$12`Z)?;^io&iD*P4XJ`ZER=1&N-hhe!u9YA458xQVU z8RYx-4f-2&p8_;lP@}I=`QtT}&!nQ5fpvyZbU$|}@Gxqp6*cS+R9xlB_z1;}70q@+~`72dv9|NKEE9Ubs^qNDT3$sMr$ zDX{sMxDW+76S0>eRp>ezk`AFCT2mn=rZa=)UFPrcQhY++$EZa2|m^;6IXlY72>eq|0VfCTUm5H3K32|6BT2`p!-OjEdW}-0$yGiiZ zgXBvmOg=9AdRw*~RoSKLB^)|>1NsA?<5F0QTYq=ac1~(_g=%zz_dy*8AKiW^VR@(WxKQk`MBA} zgKw$12dP2<+;Xs=KwDthdcyW`>#~sJOt8ldt5Xd5v~AVE(}Tlg-Rg(^0&!`nQ7i`_ z`+*4wP#yzaJT4z@uWv*;mDl{I+c2EED>pvtAyP1IBF~z$_;RlP_WFejy^j=RBwNEiwyQtI?yQn%_kyX9Yp)B@bK@NJ{MQveUZjfE!|t=|x?0mXt}s}6FTvX{g$ zLIek7d(58M4GACY;__l{y57$!z^meZIv?sN{`d9uk0yHF$AQ6tI*@l8sd_b@FmL~^0@)E z-f%Jf3`3n&PRc{h00@FMA-sBX&SF3x{oVg2k_NliQ0<|?NC|#e*P^SQ>1}6oI*KxrGGF-w zX&xNzDuF-1-$TdCd!kP&MQc5eLxCnNx|H!-y9ES}hk9igPp%F=Z&5!1d_>cIAej@)_>Ty(6jzq^LE6a^SvhOWk|8dp0{)^a8Ww&@rD{6(; zx+O#N6;6-HbLbjg+ES=hnQh7f*ga0%dl=&YmEP<^;Rg#%5IUIim^?C$T*cWHPKEi6 zj9(~St1nhCF!6`L*Y!8h{lznv9@8Dte^6(n%5qlU(2Qxjx8KX(+oXHhaaA+Cp{S8W zao=+uL;SV|!gDPEv@!?;Djv_&bznyUv+wjxf3FwE?kATe43HBu=*{)>nGWKhYMu^u ziGt6$efqzs!HdQWm||fr+XIL3RvjGOU;Svqc!lS(NFKR#IbLg3EI-)7c3XNT9{5-4 z-GOahy1q^g_2*=ij zCXcp9*<8_fb@OB&487Wf%zUf`k*9)3$H7SGLUU^Tb_lkepY_mR__1i!)TXwWM#u~s zS=P)~F0=&_YhFW{DNwkNp5L^Pe&&K1wQDw@iia8;BEIsdRZfl9Xq-uYho*#%=&IX% zoCYHeGw6zq{ixwR4-CjWE%Y!vjX&k_S4AqQIo(r$cP&$yeq2Gw&9cD6kZX9w1EM=f z4W_;EbMV$Dc?#{UVgP6VazdOl@-s}$O*R%ur!A;^3LuFz#XLE-_uuvZ@I~733gudc2l~0YssZ~~~qh%l&v4*_X z8daxO!Szsr$yD!k&k(jAURwWET~Oa+;iat#d?yN6RB@v-fXDnH&2j?JMbx88^Bugl z6u8xDxn8iX-ME&vYtpn)iPv^w37R^%92tk`(}0j;@u*mhC>7POib)K}P1HfWfZuE*92BQEA!9wcNz_#(hEGzIJr$i^BV zm<&O4;UXa^G8zXj{1UXTqAgY4OkBHARo(XU=wzRokE;d$WV=VdSG+vhc)MoVY{BW_ zDx@cEHnqP)I-l{Vcpg|)u>QRb#Uo>;2`^_qOx0GQ)OAURpq!b~l>spl>S!+J;W}xt zkiU7EFQu*v7xpPIL{2%_fmVtjo$4GC!MA`kvyo!m97w%Ta?YhzovtgdrtPIz3^ujl zVqvPmgMpZBpJ9-(f`PlHbQU&6sycNVLsdtrLvC&9D@Lhf%mSjlJNe&-Kf}$pE*o#$ zqVOAUxbGDNK6iZ{PHS%~o~V&e#GG(Lf?X>aGX)s6+MB|e_vji*b9F3C>bMSxSl zwlz9J8~ooB&00WP3Y4SUd&Jl2byxt!y)<06w5+u=)m!Y~?1xGmW7fj85rTfPX8CFP zeG1>QV&$Ksb3VRAZ#L1V_vedW86(CWyM{YHJ1S%I6i&ZJYgHVqt@?*RHBMY!dg-W4 z;jlmSMwE>3L&b;mYtIg_&6muEvJH@~choT8-4R`y#*6fW#8dWX$dS zGVzVh5g_vLH^6ZOpE$VfPrAEYvxMoOz#5}FkEu)1K-ihoZ_AXUkD6pgXf&GFQ^kjg z(abqV+$n{ZqS>FL69GGJu-U`lb|%!M1@7zHufA3{C*)<;OmIp$;?+D@^-E^WP*q#w zM&gRaRmCQoo1|osg)BQU-51x;a5{)6C@Kp?!JzM!AEifLcKaz)u|RF3xzTpRd6&cr$W+E^!Z3>I5qr}YZzS6Dv5fy?<>;SaKMN!CIj9~ydj`YX)z zj8(!^EXA9f#sotRO?f@u-Q-h@%k2>=-JkY4YHK@Iv-!L+k~aSH0nEgThC{f059MoZ zTXlnn%xItcN&6Kt`8@_nmjk^BF4O7NHvwz34lDI$c0w7$zR?cKd%uxVBT&uEhwxYl z&ggjJq2n?e_Uo6>?W3h12w#e~qWu1ezE6L&=^b50E`W;#NRsogqK@>z;nBi)Wfszk27S;lX`(phFZumNue%yI@3ZPUP{mBUBRI{v)5 z0M(833%2phrk#-t8fXq&pEn3ROczE5Is}RceD+YPFM1T*WLB~iu%(1-J4JtPE921o z(&i(MM{p^BYAK*I)Vu>fpUJL{?ueHs;#zYd{~~*QeG6+Sz1T#mCp%NO;LH$tZKceMAthQf+R1$>Bd47b+G5ySjKyBaL4!S_vlHfzi1w05-v z`PzYe+Mhdv&To>n;@ehM`?@2Y;viatC>rYOIz>B1E`Vro0nF86$VRVU>}nmP;27X(~XWnVm3WuZ|*^iJYw1ap0CZt?--HX!i~URZGh z9z7WIf)yYzd0zOuBI2wUYWkIUVZ|3z)&&*b%uST>;@DD;K()}@VU&c8R(HUH%YtCZ zIU33M!1HIsc~Bd?%D}_e1#_2fFxR+)5Hbqc?`t8jQTxB@D{{VnW^1FD>Gk1`_!0g9 zJ9}~^<_s$rz~-dMU_L4_Zk%cSmh)7_%)XB)2SKu{P>FsFJv6s1!HHv% z_h7hkrGqm_YPi#YGK4Wa_8YtDY}I#4c{W`AC+$Vas9_p>!4>y?HnwOm3k6&wq;Ow1 z+zbCWf2b9iWcT%)Kag1cIFm5{T@Y;&(pVoDVB>@EnJV3_cLJeipw4_T9RAc6b3zfF zG=P`m$Svm|>RAN!@C!mJBQp@N-S>h1alGqkW9|3xAB=D(3ZEMnx0z%q+@4u)s#X)T zu^bL*H&opeup-fp?>2{@3*=N@0na0Ff~m31^u8-5IN~WYUZP)=!Q$g04$a|L<)8df z4eSfvJ69HA76i|2z|5bT(7&%!D-Reak`CT_o3nq6%2k$o!#Nqe3dgo(*bd)dO`I4% z6qGVC)Bkzl-BOK8Yfr$U&HgUhPe68AWF52Q=-}9QNhKq3zWUfQc>RL&-EaE8Slz!u z248jc`54H!rk>;nPR1klB+zmFgK}nLuP2Vz&!v&6{q^^2n|15C=D9HVnaUN_h)8>z z!ggtzL`s#GA}8gK%FH+hoQ?QzCgiAsZ)$fo;k9%qDdli_(^JM$5*nLzZ!LKUb)%dv zMjtfF`lD*5RdGqI=~243t5l<$T6+QGTS6krzO2`NdLx}5vhp?d;)s*`nNQc}-E(OB z@q_Q>_x>B=*3S&l_hG)ruKXaVF8pb$v{Lsoa_W4ZKe#ks@mKQ{(7ejcKh1~XY)Y92 z%XPMNs6*Bh04eZMpwu{}6@;kO%yLOgsH~*Nt9&pjQy>T4LXu6`0Y&&!wKYQl>`5|8 z^K-ju4vb}-kjBiOT|3m8mO^Luc%)%$cV>v-T3-?{N@d@>sB{N zFLrZ*!-Ewxralm;hK7Q<2;28Q;5>CCpb$920{&;YYXlYmSGOn5{yE@spuG%LLq~Bz zrFgg@iZmACf64ZO7(i}s^V@TD(F(K3x5;hOzQwrmqyEO5;1##0d(6;Ova1rKJA60w z;UoO+bohUj?G8^-(Oy9i zsmhy!tgE*MJ{j2^O~Q`-Rqg7XT%LD1y76&U5hkEsS$-)w(;Bfo~2b5qtZ zmoN^Paxi7QK@1?1-j>gqag;Iw>!e+Vxm|8jd)u4W@*0#k$lMKHLy;f?Y_zt{c9P)u z*aV1b5luC*`UfI&!oQOhoGY^l&!p{V99LH=fy8GQKE@Vjj~jq8%;>fCKz6R{=TrB^ zyA5!+TM&LqW^%mL^*V)Ujm0#iYez{p+X#s$t@nN=0(d8Q^5}Zb3LjOF1YHYa{^6I# zk|qzGcjDbY+pZF*IW>7KQZ-BKL_w3ftnraPB2C=rF^Ms3`+}x=N{S%+jGRLzK zJ{x=V5a-jykIVWB`2(b&bD3`mHDf~VXLVf>y0(BT36swY- z!!zb@s4yms@35d@!%?bl;3Yqe&~tYd3)EfX+yA7cti8e`7_f@u9{oqh-Bt3UM^EvN zq@=B^tuCEyWDRF1w1cB125(Tm1>ZCT|8uX4&FZ0UM)Ut%@Va;-|M3M(jh z`|(bVR`)e^U&?81RZdm8&$}<+-Y4o*F!_B-_4DFVrv^`d8ygZU(K#0Icvr%TC6ag6 znZPR0oSd;ERiZ6TLzs_k1FPc3&RfrvQaKE$En}8yXx3UF&2mbGYUszUX5H>F!nU#+ zVmPtmdT}&4-FoyFUUN@5W-pEE{}uLIKA1HDoH+B z2R>Us-(Jr_q&{0mKD1{&+~n~HFk~bx%8=9`ufNgm6hQ0FPyz;$dg=7ZSJ2KW z*W2_nVvk7TB2KE6_Hsp9)tDn_O5--yCP%;3XT!!fb8ooKvpZX%VqLvmiK=yfL2o@{ z(S|#U(4I`MTm8X5*(D&UcEfOWUHZ}NiwX71%||OT5_olK=#?;}iBxs+b>d3bUf9sk zpFDub?9HGgYvyaEoMx+he#$zvS#}Wdt3Il|7O?@Er(9E_jb<~4E0f9i`t0W0_xTCMRP>y%ZO~SZQriWA@0gq$smCH#aE+9h8tv2c2&#daord`B7+7 z?*GGHSnnXwIbf3*Tle?OcR25m<7QtK1Nkd}emIkmG4O6|MMeAxRqmEkI_uj`8A%xg zn?0{dr8uHg^W!h+vI-(udRqP}$#C|(EX6x$-^~MW4;}4e!*D$F^_sw%v25@DepbnK z<^JgDx3T?P_#*FO*yvji!#{XqcysCX5SPA5RToW7bmtnp6K)hfA;OjU>)Qm6a|50y zZZux}M@!GJz>gE0^b*((01xX8H4)EOf3l0JI-AYYB!G7LzvWcYc04>5tDqDOn^g&r zzcQ#rbiJp<>eI(O^mElP-c}r$E}+9JK3i~4@x1Zi^p|-eWz0y{td)^xYULlDHQm3> z4~iM*_Z`67Lr0XF2I~if?gYoREF-UHkG@wNZ%PuvnReP8pWXoPA2S}7dyQRsvh;SY zlg-yyP1MMB+6h?b$o7AnS@zbJDUG#%X=>Nm`BT)z$(KGZ(fAu{10qPkIN>2lHC~O+kEP&| zxHx*su7qAwmeDOR(h=`<-lH#3tJdTv^I1MkGwj`LNpb!X0Ag>d$CkVccJLW@_RY#7nZXqXq@u%W2B7^Og9^ zhi^3$`O=aWxPU0!LFPZHR?qjl`rZ4R&R@PHQU7IoAZ%BCVAFTpk^pp%{exQiCAvHO z9cEtOiGbnl&GSi35>$=xG^P6Z{iShw#)<^*u=V|w#oZO5Gg(+Sdtl1FfZ3ic@oQoz zc+YKXU-~n)LcR<`Lblx1pU$ngfMt=m#TI#nKh6&<41IT7jDKyUfr{kzqSFOs7CZmW ziNN6&oJ@~J1q}s>a`oY@x?z56zR7va54lHLW5s4mCcS{&*W&PcFKOtBZ@6E$VxJ8-J!6JM0crEgG>~?(*dNxIdL@?xm`uJTdF=wv=MT~1GC|p)g zg^nn$fSrnE7%jg10I%vmyl@}<>P9N)HA&-ROsFfE&Gc%5lmybmN(T8$Ug zs?6FAbi$a!tQIFKJXjPaavu$3Q4}pwBNz3RKiKTnRx6DsAgB15bP$o}72S47jOgn+ zM0;hMT_I6O5Dev^`T_$#)pqs=0v`Y6#5KyLSE-fiE~{5cMnlh+A<$HpVsV^1^VTz3 zE@38lj2JJ+B-L@fn(S%&wr|i{;0E8ZSpjjCNTessy}RE{J|(=v%Z1&c?yhboNlc`F zsPKB&T=r(^#(urem;QH3P}Mm<8ZmQTf1{V(7IU*}MKH5FTU6M1j74^{9mlNEK=3dj zFRx$mgH2PrMnvl@Mm*s$_Z@WpVDHTuzYq!mQ_mer_VIl4v13^ooZSBo{;+mB`*7SP=NuX~!or z*NW7JIBMfliS;AZ$3T{Caa3N8Nw+df!J|jb&n#o{cCC;4fLb!MUbJ-c*z)gk#^lT2 z_qEGHRg)M@A_OrAC}oiIw%Vl7IVm;Tv*c!zBn{bWH4{g)&6=iMm73;hTBR<{oMLN$ zvZ1|g*YpGKR^i^ytAU-zyiWCo?V;0!tAVzlC0p`#ECj8Z4&bk!o(VBq14fA+C^_H1 z{S!TDarM&b>3&-IEV3+h)~hEtD=Wg?0&NCl^HD2i4xgxc0&N|PXtkMr(GAbLR$gO% z;})Fh3p**TwaIFCW}Gx|wlk`YT0eNUy;=qzo%a?(DT0rj{F*wHtlKld;O2Zrm=lxk z=D4|9bnX{uoGo~dkCf0B{?`q6&Xb{R=p0iC@Zq)Li`zG?azNaAg5w*ZH^42;&8P{;IUrqRVW3rTj zs_HZ~W=wLa)oiAY1*>Y><0e&% zZ+X)t6}o#`9}1uX(Ky_(b|rM;1RFz)gUeS&e= z=rXQ@&RP89^7(M?PDLhrL}U*A3=-*HUq8<(Uf~T0F4$B-oHK0BzL7kid%>TZ6F7jt~1c>o{X03X!q?<8Ze(|GS8DIca)w*j;BtR&dnD0dPituA;T;G)wQ6sXqJsL->}`#YboP`+o^rLeH0r04dp_4=AVx$b_>4m)Nzr+c5}nbl>x=yjjG zVRmCq{9+o$tVfQxR@wJuzDP?8=x|zaj6DCvc=zMQCwPhD`&|xcQlijA7MCLRvD#19 zcK6NU_zZ>JoWGQppo3G*1`mTwS7+*0Kav$SV9>8GWXoFH@<0Bky=(tx!u$Uv74M27 zy0}&DR#wXWR&Tc`<`QDLM95{xY-%&QkVfvcMK%e!%q^F>o7_d@lDTc})ELGXW_a7&;zAZ}XWbJx7?5k+-YZtDF8*cSjV2^V{Nk?S>Ee z#E?0!`k>RKVQ!-;Snk$HTTk;J23>!l|`3}PNwn+Gn8%7%m&43?L8hKu%m7Iv_U-1I_KfPry7xgqmb{G-Fi$~RXH=U%*X zys2VY`efIT?6*2VzGZOYAfL`5o(52qWHBLJ=C0^oCSBs0gEY6jS8 z5R*i@)p=O>4SiiBEj9PtST<~0X+(&8B+oV*h)4P7jcZXA0he~qr+gxKdY0EncD7)a4}(cj!|@Pc79(%;M|SNEKLs)N z5MY{0H&qe1Qx3BAw-s{xxE5gBIiVet9((oSA?{R>Jk^@R{;UhvI+knuGV;UWHE59J zii(=v-%~c1|MH6Viu>#V+1~0b(MD{4WoiaZ;WW1sx`7kFf9iMrKqk0_QRI_H=V$;E z!z^vMDqi>L^||@|eRZ7>57*F?(S4nF;gO9oq^ZY7uY`;(X^vt>16sw z)mWxF>Jp<(%MSL^Y6oLH|N1+TzGv5q!fusK49vvu&*$U>VBgUP_zL!qfCg%s+$+Pj zK{fs}$t3Qj33ug_s&T0aXBbz|k!`wexYT?K<8$CZa`_kEoeGC5#LP0Ov8t_v!PF>| zdu&>z5YX&pDM6)2%o}>HeA*)ZLpW!Lv(_le!{w88`PFppyloF4$w)I9THtKH+dbKX zk|Et?1feR+_~Z_k$|@YW>=5+Z%!E? z&L#S7mfhWm#n)GZKf@;c9WX{#h3&P;P>L>d)Bvq`^7#SYJy+h{btEdJHd6yOZ}@Er z`u$5v!a|td064>q@>shE#`JAQBbP0f-WrSlXxOzaXo!1{nPGsEr&fUkYD ztn$?B+8(ntC{p3o4FJb$$h#yUg7My?gU{m~J&2ay`Nbj|XSkWM&Fit5tl+ z!Ww5E7|K%wf&uweMexP$o5t!NM?H?|{8=P@-E17X$9`5IwmPoK?mXrt2IMmJLB8E@ zd>Vf=_8*0qH5L2Y*@x`ul-ujO3l+> zQky70(#EF8%8`0SyyVV+!bjbi%9P6`t;UD0dmhC(#TTD*gsz{TF3vA$aqS+oZdiJ zxhiyGD37iNAFn`Z1M)8A<66t(2w7ut)#f`-SX=5Wi#-GHul0AION(RCxht7s@BN-? zZM%bEASD7>oY_Jw3I6=42)XzL(k`cm=#L|%d{Yb37FaUOmPf&SIy~R|<*Mp^k!-c@ zFHsiYj8xd)Ry%TBl^b3VnMou}VZ8p!r;Wr3m%pdvM70LzF9r&8wC1l&4Sco!Hh_>U z9fyQB8V#)_zctHsi`lmQK3uc;XTAAwJz_q(F60foG!YcJl~P6V>JFeSkScwj(>-`( z#IbM25qHS`;vpf62K933wlptmd^f%mkH8P&OKk=%$ZFtc=)x6@e7G3_>lre1aV2-~ zi(SB7%4%@$-j!~e5@+I9gQq>BO!hBj&4B;L zBPChhaavT%W>nU#izT2JhJDw@5t#ferwnMQ;}S5D8auN%mnnAbSCzr?>GsLr4!^NG zI~iSs>lUcQ1N`kqlC|ptpaAeJzkRl+r(A^){~gY@^O;19g?VHDI!{Mk*^|mH@ECC% zGvpLK%JBTyGo-7gR7Ah%>|?J=BT5vSw!2f%#=VBso0fl_{)ZPP&ha#0BlT8S6w>O` zKP-I8ZJRw$DI8X;hSB!KL|iygj%wLS$2;2sk5~hqmBhC;o&fCFu9;dId(f)A+9Rm^ zq~%3>z^ThU)Ndxnw7la2y{$+mV#W4 za|Ydv`JTLZye3gd3?k}#BHmA2+15R_*nX*=!~Mq&Dv(DsKPJ>gzd4O=i(Akn{xlLLB_S~{?`KlKlKKh|l8c za(CK&(7AC_K4h!JU&D-XS$D1cQ)2Cpj!~?nJix0s^;9VCV#Fm=-tUcqQ-8Rf}sY5qJU3akcRa*$8$dc^( z+%&q1Ej1$D#JOQP`PZpc=>5}jE5AWuz;up;H138*oYtvTd)*j$#lVvm%B~iL=_Q54 zIZdPK4`Z)1clz>k=s;@yq4)iM91UXs&f&qYNRqxL073-wkcl|J1T|fsDuI(6>^7U$ zveZE$OXUt()H27@3|oXp(68j5h#J!P+HH_7HN+m7o}1##50LAQDA{WbiDVA#D;J~- zfg5~Vr(^bLwTMB^(dc*tZ|m|G#d}Mzaofq@4kt4dDlGwb5hmUb4bI#UiUjMq7h$*j za)f4o8!=UK&(b4|=|tE}4e6{k{=#Z8YCEiH5_x6oUhnxo=$X#UeN~cADPp>M1=}^~ zPcDZV)RczkcebgBf)ar>v}&iZ(O!-EC2+fyq!bAD@V6Lwr7MExAQz|a!mF`O&Z~#B z*_5MkIL%>KCEtV-(jmexDqq-^YAIR2zSh_Fq+v9=#z&*N#K8hr>Y0F};%o{4m5l zvdZQhZAgF9t$^kFx8L)$`iuzaoXaY6H$IQ9TU9I!XYt`5Wm-WtjIYAVAzrum3u{La zrOMymf685CC{okQsh#zzPgh4>GhTD?Imxr8n8DE8{4RTe4K>Gfm*em0 z>X}^9QA00joysoPRB=mADVocc^Xt@|q8I8Ydq;_sx%lRZ!-|4Je$LDm@4xZNI4wl{ zM;htHHwLWGC-n4}o%{2aSjsIP002>|&L5P1--{`P(B6#GAy1Cjc}UoIFHcU;o=i($ z+`e!nO0btYtpE1nM|r*vvU5T&z~Kyrw0YTBCr0&~lB$AiZ=SHJ&t06y{6@>W$IN60 z!H2*8Aa@+)YGd;ot<4vh!z z(i8V~KVF~8SAK8^f(up!#`>D7y~fSE%fVUls66jc_)zE6&2+u$O*LWoq}9KJ`|t)^ z{c(yMvfscl;5xZe0~g{Z&?N4=$;3y?wRino&I{cxJQDiG@KB7uc7Em{TkxIV>LY)H zP&0=uJ);FP0o(p3J4FJ8luev5Q2tEkbJTgG>;+1bA}1y5oYV=>kg2AblaJJGX3t2y z?+Q@hF_%QS`;_Y!Cl^we;-lC*cC4J}3UvTHq(4LV-f0)MsEoCC19@MCFg@E0A&o9e zt4eLXv+yoKoqyrEdE-4n_e;x6(K3<&1r1)xIPNegcW_%r>vhfDp9;Zte$o-9I{ zqMG-I=|#Fr=}%F3u}A;d`NHaPer~N|rYnjv<#Bjg-&UOT$5%BpdYoiD5_VRrja+>H zbk}>2*&B$(*%yIq&P=_sfNp5b%p2wkW2MYv7x}?S_ZK)IY;$dyxs|`SHkKN$-k>pc zvj6ybdYbxRuOq8n5YBF%SP>vk$ahvD)}u&gxt4{W>JWC)AERh#YK~LW@w@Ei?d30S zo8~C#x@%HzHIp(HY!$P@vt$e_&4)RhV{8h0cWHyb0Sl0eJyAx5{+v1aN_aZ%v72Oo z;)#X~Hf^tVE}?B|OR#CJZ+Uj>X-Gc*PCQu(YThP-Q`jB(ccolP`xj&GqW|l1eeFLS j{_}_bhfdHi$GdlUy - - diff --git a/Sources/CodexBar/Resources/ProviderIcon-claude.png b/Sources/CodexBar/Resources/ProviderIcon-claude.png new file mode 100644 index 0000000000000000000000000000000000000000..c6c29f77ce5242f5388592c0b305cf81f747cfef GIT binary patch literal 2918 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 Q`Tzg`07*qoM6N<$f+FT;dH?_b literal 0 HcmV?d00001 diff --git a/Sources/CodexBar/Resources/ProviderIcon-claude.svg b/Sources/CodexBar/Resources/ProviderIcon-claude.svg deleted file mode 100644 index 9f66bdbb44..0000000000 --- a/Sources/CodexBar/Resources/ProviderIcon-claude.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/Sources/CodexBar/Resources/ProviderIcon-cursor.svg b/Sources/CodexBar/Resources/ProviderIcon-cursor.svg index e97835e4d8..de15ffc8ab 100644 --- a/Sources/CodexBar/Resources/ProviderIcon-cursor.svg +++ b/Sources/CodexBar/Resources/ProviderIcon-cursor.svg @@ -1,3 +1,3 @@ - - + + diff --git a/Sources/CodexBar/Resources/ProviderIcon-gemini.png b/Sources/CodexBar/Resources/ProviderIcon-gemini.png new file mode 100644 index 0000000000000000000000000000000000000000..a2ccb0ffe948d86835aa9526be47036583a3920a GIT binary patch literal 3845 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 Hu0mjfTCGpZ literal 0 HcmV?d00001 diff --git a/Sources/CodexBar/Resources/ProviderIcon-gemini.svg b/Sources/CodexBar/Resources/ProviderIcon-gemini.svg deleted file mode 100644 index 869ae75bc3..0000000000 --- a/Sources/CodexBar/Resources/ProviderIcon-gemini.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/Sources/CodexBar/Resources/ProviderIcon-kimi.png b/Sources/CodexBar/Resources/ProviderIcon-kimi.png new file mode 100644 index 0000000000000000000000000000000000000000..213b9b83d6cd30fd57077aa18244dfc4e6fd0d14 GIT binary patch literal 1835 zcmV+`2h{k9P)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91FrWhf1ONa40RR91FaQ7m0NXcg3;+NIl}SWFRA>d&T1!lnR~Y^N8HTB) zfGCjK)Jl*bY}n9ZN>fW>jKIc~3%hV*!iJU6u)t*(MnfPmftA{Xg_|al7#B?eF+h_V z(y(b-!a_ogmVhV-$N)3*^_)B0ab{p1%nT22a^`>B*ZE%e-v8clR8=CAgM?={Pz2Nh zKLFnXRX{PoyxmMeG(HDR0wcihz!>lr2!L-iVLn@+0Lc{sHNbDcDzE_r05;6Zdja;v zKG`=p3m3Yrua18`ul7sq{fEOpsK2=v%U=@FK}wY<*i?LBa8S0@Bo}r306N71ZyG@g7r)g^DRaO0L6> z-EOz!=H{B({0RgCvazut!62=fINAChj|a-N1VeTFe!pz^(S~tx>*rYF_}689>Zxo@ zkD}nVeYhz=N+~BNM^2qOCAV(f65cZ{EG$TCYpc9|{W@VABwSQfBv-Cnk+WydMhURC zwkD4sKbHIVf0p3NtoZ)=MI4t0>$9K)X8)Ge@%s|=EkHOc*w(I?lN8XrottLUKDPlot>RV)3M}7M@O}yqC(-0nULQd0j%b4-@dKu>+4%I z|LoZ_BOHZHRe=f0W!D1qdAdzkS638y`i^xQ#*Oj$6s@0te0D8B@<)#zHRKtf)@?|h z!T;pR6RoVQ)GTS7^1JS{-YKYaL5j~_q28{{){p}Dy^J$dqEm^?LY-NurC^yrb&=dRmR81w~RIvfI-~U)TErj86QbTl1%c` z7l3ZUC4qGt61R!(xzuQDYtth~j_jrt(igyzjd#UrRhO5S^}&M&T2)nLtg#E0$c_Ni zIM+K2WD<`|9Em0-CbXfULG$wRvh7lt2|)MZ0&jA1QZHP%pjWS6)mN`xMJ@U$3v{m>zOlWbZl(Q2oV_{yr(sWhK97JrY2hf zp1uIo>htH%wY^ZriHo!I<;$0ig(ANrtV=OwXsJ7Q?wId_Jr{s8F|{ABV+wNU&>^j_uQv<1*ae=A zA1dPF#fx#g96H|LcHWJ&T;OPjz**^uCWDNWPY`br_S@57<$l2^pKgEsz zpt4NBi=26eY}|*(H6Pm%)8HoekNc31!zY6Q8}J4VxF#J^QD#3Ahbo>G4*@uGU literal 0 HcmV?d00001 diff --git a/Sources/CodexBar/Resources/ProviderIcon-kimi.svg b/Sources/CodexBar/Resources/ProviderIcon-kimi.svg deleted file mode 100644 index 77cba2eacd..0000000000 --- a/Sources/CodexBar/Resources/ProviderIcon-kimi.svg +++ /dev/null @@ -1 +0,0 @@ -Kimi diff --git a/Sources/CodexBar/Resources/ProviderIcon-minimax.svg b/Sources/CodexBar/Resources/ProviderIcon-minimax.svg index 9055daed6a..aee384af43 100644 --- a/Sources/CodexBar/Resources/ProviderIcon-minimax.svg +++ b/Sources/CodexBar/Resources/ProviderIcon-minimax.svg @@ -1 +1,10 @@ -MiniMax + + + + + + + + + + diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift index d8929f27ba..6331515eb0 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift @@ -27,6 +27,7 @@ public enum AntigravityProviderDescriptor { branding: ProviderBranding( iconStyle: .antigravity, iconResourceName: "ProviderIcon-antigravity", + iconRenderingMode: .original, color: ProviderColor(red: 96 / 255, green: 186 / 255, blue: 126 / 255), confettiPalette: [ ProviderColor(hex: 0x4285F4), diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift index 1ae78675fe..317df49dc1 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift @@ -28,6 +28,7 @@ public enum ClaudeProviderDescriptor { branding: ProviderBranding( iconStyle: .claude, iconResourceName: "ProviderIcon-claude", + iconRenderingMode: .original, color: ProviderColor(red: 204 / 255, green: 124 / 255, blue: 94 / 255), confettiPalette: [ ProviderColor(hex: 0xD97757), diff --git a/Sources/CodexBarCore/Providers/Gemini/GeminiProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Gemini/GeminiProviderDescriptor.swift index 68cd067d40..45f3f5ac6c 100644 --- a/Sources/CodexBarCore/Providers/Gemini/GeminiProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Gemini/GeminiProviderDescriptor.swift @@ -28,6 +28,7 @@ public enum GeminiProviderDescriptor { branding: ProviderBranding( iconStyle: .gemini, iconResourceName: "ProviderIcon-gemini", + iconRenderingMode: .original, color: ProviderColor(red: 171 / 255, green: 135 / 255, blue: 234 / 255), confettiPalette: [ ProviderColor(hex: 0x4285F4), diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift index 964adabb43..32568fcca4 100644 --- a/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift @@ -26,6 +26,7 @@ public enum KimiProviderDescriptor { branding: ProviderBranding( iconStyle: .kimi, iconResourceName: "ProviderIcon-kimi", + iconRenderingMode: .original, color: ProviderColor(red: 254 / 255, green: 96 / 255, blue: 60 / 255), confettiPalette: [ ProviderColor(hex: 0x000000), diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift index e085b332a3..317ea8ab07 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift @@ -26,6 +26,7 @@ public enum MiniMaxProviderDescriptor { branding: ProviderBranding( iconStyle: .minimax, iconResourceName: "ProviderIcon-minimax", + iconRenderingMode: .original, color: ProviderColor(red: 254 / 255, green: 96 / 255, blue: 60 / 255), confettiPalette: [ ProviderColor(hex: 0x181E25), diff --git a/Sources/CodexBarCore/Providers/Moonshot/MoonshotProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Moonshot/MoonshotProviderDescriptor.swift index fc18f20391..1afe4270b9 100644 --- a/Sources/CodexBarCore/Providers/Moonshot/MoonshotProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Moonshot/MoonshotProviderDescriptor.swift @@ -26,6 +26,7 @@ public enum MoonshotProviderDescriptor { branding: ProviderBranding( iconStyle: .kimi, iconResourceName: "ProviderIcon-kimi", + iconRenderingMode: .original, color: ProviderColor(red: 32 / 255, green: 93 / 255, blue: 235 / 255), confettiPalette: [ ProviderColor(hex: 0x121212), diff --git a/Sources/CodexBarCore/Providers/ProviderBranding.swift b/Sources/CodexBarCore/Providers/ProviderBranding.swift index 9d76dafedf..4ad5ba4e72 100644 --- a/Sources/CodexBarCore/Providers/ProviderBranding.swift +++ b/Sources/CodexBarCore/Providers/ProviderBranding.swift @@ -19,9 +19,17 @@ public struct ProviderColor: Sendable, Equatable { } } +public enum ProviderIconRenderingMode: Sendable, Equatable { + /// A monochrome official mark that follows the surrounding foreground color. + case template + /// An official asset whose embedded colors must be preserved. + case original +} + public struct ProviderBranding: Sendable { public let iconStyle: IconStyle public let iconResourceName: String + public let iconRenderingMode: ProviderIconRenderingMode public let color: ProviderColor public let confettiPalette: [ProviderColor] @@ -39,12 +47,14 @@ public struct ProviderBranding: Sendable { public init( iconStyle: IconStyle, iconResourceName: String, + iconRenderingMode: ProviderIconRenderingMode = .template, color: ProviderColor, confettiPalette: [ProviderColor]) { precondition((2...3).contains(confettiPalette.count), "Provider confetti palettes require 2–3 colors.") self.iconStyle = iconStyle self.iconResourceName = iconResourceName + self.iconRenderingMode = iconRenderingMode self.color = color self.confettiPalette = confettiPalette } diff --git a/Tests/CodexBarTests/ProviderIconResourcesTests.swift b/Tests/CodexBarTests/ProviderIconResourcesTests.swift index e25548ebbd..d608e8eace 100644 --- a/Tests/CodexBarTests/ProviderIconResourcesTests.swift +++ b/Tests/CodexBarTests/ProviderIconResourcesTests.swift @@ -13,7 +13,6 @@ struct ProviderIconResourcesTests { let slugs = [ "codex", - "claude", "clinepass", "zai", "minimax", @@ -21,15 +20,12 @@ struct ProviderIconResourcesTests { "opencode", "opencodego", "alibaba", - "gemini", - "antigravity", "factory", "copilot", "devin", "crof", "commandcode", "t3chat", - "kimi", "longcat", "bedrock", "elevenlabs", @@ -55,6 +51,20 @@ struct ProviderIconResourcesTests { } } + @Test + func `official color provider icons are bundled as PNGs`() throws { + let root = try Self.repoRoot() + let resources = root.appending(path: "Sources/CodexBar/Resources", directoryHint: .isDirectory) + + for slug in ["antigravity", "claude", "gemini", "kimi"] { + let url = resources.appending(path: "ProviderIcon-\(slug).png") + #expect( + FileManager.default.fileExists(atPath: url.path(percentEncoded: false)), + "Missing official-color PNG for \(slug)") + #expect(NSImage(contentsOf: url) != nil, "Could not load official-color PNG for \(slug)") + } + } + @Test func `groq and grok provider icons are distinct`() throws { let root = try Self.repoRoot() @@ -78,6 +88,17 @@ struct ProviderIconResourcesTests { #expect(first.isTemplate) } + @Test + func `official color provider icons preserve embedded colors`() throws { + ProviderBrandIcon.resetCacheForTesting() + defer { ProviderBrandIcon.resetCacheForTesting() } + + for provider in [UsageProvider.antigravity, .claude, .gemini, .kimi, .minimax] { + let image = try #require(ProviderBrandIcon.image(for: provider)) + #expect(!image.isTemplate, "\(provider.rawValue) must preserve its official colors") + } + } + @Test func `ollama provider icon uses template rendering`() throws { ProviderBrandIcon.resetCacheForTesting() diff --git a/docs/provider-icon-sources.md b/docs/provider-icon-sources.md new file mode 100644 index 0000000000..8c9efa3633 --- /dev/null +++ b/docs/provider-icon-sources.md @@ -0,0 +1,16 @@ +# Provider icon sources + +The spend dashboard must use first-party marks. Chart-series colors remain an +independent visualization concern and must not recolor provider icons. + +| Provider | First-party source | Resource treatment | +| --- | --- | --- | +| OpenAI / Codex | https://openai.com/brand/ | Monochrome OpenAI mark, rendered as a template for light/dark appearance | +| Cursor | https://cursor.com/brand | `CUBE_2D_LIGHT.svg` from Cursor's downloadable brand assets, rendered as a template for light/dark appearance | +| Gemini | https://about.google/products/ | Current multicolor Gemini product icon, preserved in original color | +| Google Antigravity | https://antigravity.google/press | `Icon - Full Color` from the official press kit, preserved in original color | +| Claude | https://claude.ai/favicon.ico | Current Claude product icon, preserved in original color | +| Kimi | https://www.kimi.com/favicon.ico | Current Kimi product icon, preserved in original color | +| MiniMax | https://platform.minimax.io/docs/faq/contact-us | Symbol extracted without redrawing from the official `MiniMax_Logo.zip` package, preserving its pink-to-coral gradient | + +Last verified: 2026-07-27. From 3b1749a0e8d17ed2ad610e50a39ddeeffd695fa2 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:46:52 +0800 Subject: [PATCH 11/14] improve spend chart insights and tool attribution --- .../PreferencesSpendDashboardPane.swift | 170 +++++++- .../CodexBar/PreferencesSpendModelsView.swift | 229 +++++++++-- Sources/CodexBar/SpendClientsView.swift | 117 +++--- .../SpendDashboardModel+TokenBuckets.swift | 54 +++ Sources/CodexBar/SpendDashboardModel.swift | 384 +++++++++++++----- Sources/CodexBar/SpendProviderIdentity.swift | 6 + Sources/CodexBar/SpendToolIdentity.swift | 81 ++++ .../SpendBillingAttributionTests.swift | 4 + .../SpendToolPresentationTests.swift | 147 +++++++ design-qa.md | 78 ++++ 10 files changed, 1048 insertions(+), 222 deletions(-) create mode 100644 Sources/CodexBar/SpendDashboardModel+TokenBuckets.swift create mode 100644 Sources/CodexBar/SpendToolIdentity.swift create mode 100644 Tests/CodexBarTests/SpendToolPresentationTests.swift create mode 100644 design-qa.md diff --git a/Sources/CodexBar/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift index fa336a5149..b22eeac54e 100644 --- a/Sources/CodexBar/PreferencesSpendDashboardPane.swift +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -434,6 +434,9 @@ struct SpendDailyChartPresentation: Equatable { private struct SpendDailyChart: View { let group: SpendDashboardModel.CurrencyGroup + @State private var selectedDay: Date? + @State private var cachedDays: [Date] = [] + @State private var cachedDetails: [Date: SpendDashboardModel.DailySpendDetail] = [:] var body: some View { let presentation = SpendDailyChartPresentation( @@ -458,17 +461,29 @@ private struct SpendDailyChart: View { ContentUnavailableView(L("Spend unavailable"), systemImage: "chart.bar.xaxis") .frame(maxWidth: .infinity, minHeight: 170) } else { - Chart(self.group.dailyPoints) { point in - BarMark( - x: .value(L("Day"), point.day, unit: .day), - yStart: .value(L("Estimated spend"), point.stackStart), - yEnd: .value(L("Estimated spend"), point.stackEnd), - width: .ratio(0.52)) - .foregroundStyle(by: .value(L("Provider"), point.providerName)) - .accessibilityLabel(Text(self.pointAccessibilityLabel(point))) - .accessibilityValue(Text(UsageFormatter.currencyString( - point.cost, - currencyCode: self.group.currencyCode))) + Chart { + ForEach(self.group.dailyPoints) { point in + BarMark( + x: .value(L("Day"), point.day, unit: .day), + yStart: .value(L("Estimated spend"), point.stackStart), + yEnd: .value(L("Estimated spend"), point.stackEnd), + width: .ratio(0.58)) + .foregroundStyle(by: .value(L("Provider"), point.providerName)) + .accessibilityLabel(Text(self.pointAccessibilityLabel(point))) + .accessibilityValue(Text(UsageFormatter.currencyString( + point.cost, + currencyCode: self.group.currencyCode))) + } + if let selectedDay { + RuleMark(x: .value(L("Day"), selectedDay, unit: .day)) + .foregroundStyle(.clear) + .annotation(position: .top, overflowResolution: .init( + x: .fit(to: .chart), + y: .fit(to: .chart))) + { + self.dayTooltip(selectedDay) + } + } } .chartXScale(domain: self.activeChartDomain) .chartForegroundStyleScale( @@ -509,6 +524,17 @@ private struct SpendDailyChart: View { .frame(height: 188) .accessibilityLabel(L("Daily estimated spend")) .accessibilityValue(presentation.accessibilityValue) + .chartOverlay { proxy in + GeometryReader { geo in + SpendModelsChartMouseReader( + onMoved: { location in + self.updateSelectedDay(location: location, proxy: proxy, geo: geo) + }, + onClicked: { _ in }, + onEscape: { self.selectedDay = nil }) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } LazyVGrid( columns: [GridItem(.adaptive(minimum: 118), spacing: 12, alignment: .leading)], @@ -518,16 +544,25 @@ private struct SpendDailyChart: View { ForEach(presentation.series, id: \.name) { series in HStack(spacing: 7) { SpendProviderIcon(provider: series.provider, size: 13) - Text(series.name) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) + .frame(width: 20, height: 20) + VStack(alignment: .leading, spacing: 1) { + Text(series.name) + .font(.caption) + .lineLimit(1) + if let kind = self.toolKind(for: series.name) { + Text(kind.displayName) + .font(.caption2) + .foregroundStyle(.secondary) + } + } } } } } } } + .onAppear { self.rebuildHoverCache() } + .onChange(of: self.group.dailySpendDetails) { _, _ in self.rebuildHoverCache() } } private var totalCostText: String { @@ -566,6 +601,111 @@ private struct SpendDailyChart: View { let color = ProviderDescriptorRegistry.descriptor(for: provider).branding.color return Color(red: color.red, green: color.green, blue: color.blue) } + + private func rebuildHoverCache() { + self.cachedDays = self.group.dailySpendDetails.map(\.day).sorted() + self.cachedDetails = Dictionary(uniqueKeysWithValues: self.group.dailySpendDetails.map { ($0.day, $0) }) + } + + private func toolKind(for name: String) -> SpendToolIdentity.Kind? { + self.group.dailyPoints.first { $0.providerName == name }?.toolKind + } + + private func updateSelectedDay(location: CGPoint?, proxy: ChartProxy, geo: GeometryProxy) { + guard let location, let plotAnchor = proxy.plotFrame else { + self.selectedDay = nil + return + } + let plotFrame = geo[plotAnchor] + guard plotFrame.contains(location), + let date: Date = proxy.value(atX: location.x - plotFrame.origin.x) + else { + self.selectedDay = nil + return + } + self.selectedDay = self.nearestDay(to: date) + } + + private func nearestDay(to date: Date) -> Date? { + let days = self.cachedDays + guard !days.isEmpty else { return nil } + var lower = 0 + var upper = days.count + while lower < upper { + let middle = (lower + upper) / 2 + if days[middle] < date { + lower = middle + 1 + } else { + upper = middle + } + } + let nearest: Date + if lower == 0 { + nearest = days[0] + } else if lower == days.count { + nearest = days[days.count - 1] + } else { + let before = days[lower - 1] + let after = days[lower] + nearest = abs(before.timeIntervalSince(date)) <= abs(after.timeIntervalSince(date)) + ? before + : after + } + guard abs(nearest.timeIntervalSince(date)) <= 43200 else { return nil } + return nearest + } + + private func dayTooltip(_ day: Date) -> some View { + let detail = self.cachedDetails[Calendar.current.startOfDay(for: day)] + ?? self.cachedDetails[day] + return VStack(alignment: .leading, spacing: 7) { + if let detail { + HStack { + Text(day.formatted( + .dateTime.month(.abbreviated).day().locale(codexBarLocalizedLocale()))) + .font(.body.weight(.semibold)) + Spacer(minLength: 18) + Text(UsageFormatter.currencyString(detail.totalCost, currencyCode: self.group.currencyCode)) + .font(.body.weight(.semibold)) + .monospacedDigit() + } + ForEach(detail.tools.prefix(5)) { tool in + HStack(alignment: .top, spacing: 8) { + SpendProviderIcon(provider: tool.provider, size: 15) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(tool.displayName) + Text(tool.kind.displayName) + .font(.caption2.weight(.medium)) + .foregroundStyle(.secondary) + } + ForEach(tool.models.prefix(3)) { model in + HStack(spacing: 5) { + SpendProviderIcon(provider: model.modelProvider, size: 11) + Text(model.displayName) + Spacer(minLength: 8) + Text(model.cost.map { + UsageFormatter.currencyString($0, currencyCode: self.group.currencyCode) + } ?? model.tokens.map(UsageFormatter.tokenCountString) ?? "—") + .monospacedDigit() + } + .font(.caption) + .foregroundStyle(.secondary) + } + } + Spacer(minLength: 10) + Text(UsageFormatter.currencyString(tool.cost, currencyCode: self.group.currencyCode)) + .monospacedDigit() + } + .font(.body) + } + } + } + .padding(11) + .frame(minWidth: 260) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .shadow(color: .black.opacity(0.10), radius: 10, y: 3) + } } private struct SpendRefreshFailureNotice: View { diff --git a/Sources/CodexBar/PreferencesSpendModelsView.swift b/Sources/CodexBar/PreferencesSpendModelsView.swift index 5bc272b595..8f60ba85ed 100644 --- a/Sources/CodexBar/PreferencesSpendModelsView.swift +++ b/Sources/CodexBar/PreferencesSpendModelsView.swift @@ -386,6 +386,85 @@ struct SpendModelsAxisDates { } } +struct SpendModelsTokenChartPresentation: Equatable { + struct Point: Identifiable, Equatable { + let day: Date + let kind: SpendModelsDayDetailPresentation.BucketKind? + let value: Double + let stackStart: Double + let stackEnd: Double + + var id: String { + "\(self.kind?.rawValue ?? "total"):\(Int(self.day.timeIntervalSince1970))" + } + } + + let points: [Point] + + init(analysis: SpendDashboardModel.ModelAnalysis) { + let byDay = Dictionary(grouping: analysis.dailyValues, by: \.day) + self.points = byDay.keys.sorted().flatMap { day in + let values = byDay[day, default: []] + let buckets: [(SpendModelsDayDetailPresentation.BucketKind?, Int)] + if values.allSatisfy({ $0.inputTokens != nil && $0.outputTokens != nil }) { + let input = values.compactMap(\.inputTokens).reduce(0, +) + let output = values.compactMap(\.outputTokens).reduce(0, +) + let cacheRead = values.compactMap(\.cacheReadTokens).reduce(0, +) + let cacheWrite = values.compactMap(\.cacheCreationTokens).reduce(0, +) + let reasoning = values.compactMap(\.reasoningTokens).reduce(0, +) + buckets = [ + (.input, input), + (.cacheRead, cacheRead), + (.cacheWrite, cacheWrite), + (.output, max(0, output - reasoning)), + (.reasoning, reasoning), + ] + } else { + buckets = [(nil, values.compactMap(\.totalTokens).reduce(0, +))] + } + var cursor = 0.0 + return buckets.compactMap { kind, tokens -> Point? in + guard tokens > 0 else { return nil } + let value = Double(tokens) + let start = cursor + cursor += value + return Point(day: day, kind: kind, value: value, stackStart: start, stackEnd: cursor) + } + } + } + + private init(points: [Point]) { + self.points = points + } + + func applyingTrailingAverage(window: Int = SpendModelsPresentation.trailingAverageWindow) -> Self { + guard window > 1, !self.points.isEmpty else { return self } + let days = Array(Set(self.points.map(\.day))).sorted() + var kinds: [SpendModelsDayDetailPresentation.BucketKind?] = + SpendModelsDayDetailPresentation.BucketKind.allCases.map(\.self) + kinds.append(nil) + var raw: [String: [Date: Double]] = [:] + for point in self.points { + raw[point.kind?.rawValue ?? "total", default: [:]][point.day, default: 0] += point.value + } + var result: [Point] = [] + for (index, day) in days.enumerated() { + let first = max(0, index - window + 1) + let samples = days[first...index] + var cursor = 0.0 + for kind in kinds { + let id = kind?.rawValue ?? "total" + let value = samples.reduce(0.0) { $0 + (raw[id]?[$1] ?? 0) } / Double(samples.count) + guard value > 0 else { continue } + let start = cursor + cursor += value + result.append(Point(day: day, kind: kind, value: value, stackStart: start, stackEnd: cursor)) + } + } + return Self(points: result) + } +} + struct SpendModelsSection: View { let analysis: SpendDashboardModel.ModelAnalysis let chartDomain: ClosedRange? @@ -400,6 +479,7 @@ struct SpendModelsSection: View { @State private var showsAllModels = false @State private var cachedPresentation: SpendModelsPresentation? @State private var cachedChartPresentation: SpendModelsPresentation? + @State private var cachedTokenChartPresentation = SpendModelsTokenChartPresentation(analysis: .empty) // Hover lookup caches (rebuilt alongside the presentations): a sorted unique-day list for the // nearest-day snap, and points grouped by start-of-day for the tooltip. Both replace an // O(points) scan with Calendar.isDate(inSameDayAs:) per hover tick. @@ -426,6 +506,10 @@ struct SpendModelsSection: View { self.cachedPresentation = base let chart = self.trailingAverage ? base.applyingTrailingAverage() : base self.cachedChartPresentation = chart + let tokenChart = SpendModelsTokenChartPresentation(analysis: self.analysis) + self.cachedTokenChartPresentation = self.trailingAverage + ? tokenChart.applyingTrailingAverage() + : tokenChart self.cachedSortedDays = Array(Set(chart.points.map(\.day))).sorted() self.cachedPointsByDay = Dictionary(grouping: chart.points) { Calendar.current.startOfDay(for: $0.day) @@ -483,6 +567,9 @@ struct SpendModelsSection: View { } } else { self.chart + if self.selectedMetric == .tokens { + self.tokenLegend + } if let pinnedDay = self.pinnedDay, let detail = SpendModelsDayDetailPresentation( analysis: self.analysis, @@ -532,15 +619,29 @@ struct SpendModelsSection: View { private var chart: some View { Chart { - ForEach(self.chartPresentation.points) { point in - BarMark( - x: .value(L("Day"), point.day, unit: .day), - yStart: .value(self.chartPresentation.metric.title, point.stackStart), - yEnd: .value(self.chartPresentation.metric.title, point.stackEnd), - width: .ratio(0.56)) - .foregroundStyle(Color.accentColor) - .accessibilityLabel(Text("\(point.seriesName), \(self.dayText(point.day))")) - .accessibilityValue(Text(self.metricText(point.value))) + if self.selectedMetric == .tokens { + ForEach(self.cachedTokenChartPresentation.points) { point in + BarMark( + x: .value(L("Day"), point.day, unit: .day), + yStart: .value(self.chartPresentation.metric.title, point.stackStart), + yEnd: .value(self.chartPresentation.metric.title, point.stackEnd), + width: .ratio(0.62)) + .foregroundStyle(point.kind?.color ?? Color.accentColor) + .accessibilityLabel(Text( + "\(point.kind?.title ?? L("Tokens")), \(self.dayText(point.day))")) + .accessibilityValue(Text(self.metricText(point.value))) + } + } else { + ForEach(self.chartPresentation.points) { point in + BarMark( + x: .value(L("Day"), point.day, unit: .day), + yStart: .value(self.chartPresentation.metric.title, point.stackStart), + yEnd: .value(self.chartPresentation.metric.title, point.stackEnd), + width: .ratio(0.62)) + .foregroundStyle(Color.accentColor) + .accessibilityLabel(Text("\(point.seriesName), \(self.dayText(point.day))")) + .accessibilityValue(Text(self.metricText(point.value))) + } } if let pinnedDay = self.pinnedDay { RuleMark(x: .value(L("Day"), pinnedDay, unit: .day)) @@ -621,14 +722,17 @@ struct SpendModelsSection: View { provider: row.source.modelProvider, size: SpendModelsListStyle.iconSize) .frame(width: 26, height: 26) - Text(row.source.displayName) - .font(SpendModelsListStyle.primaryFont) - .lineLimit(1) + VStack(alignment: .leading, spacing: 2) { + Text(row.source.displayName) + .font(SpendModelsListStyle.primaryFont) + .lineLimit(1) + Text(self.rowDetail(row)) + .font(SpendModelsListStyle.secondaryFont) + .foregroundStyle(.secondary) + .monospacedDigit() + .lineLimit(1) + } Spacer() - Text(self.rowDetail(row)) - .font(SpendModelsListStyle.primaryFont) - .foregroundStyle(.secondary) - .monospacedDigit() Text(self.shareText(row.value)) .font(SpendModelsListStyle.primaryFont.weight(.medium)) .monospacedDigit() @@ -652,6 +756,22 @@ struct SpendModelsSection: View { } } + private var tokenLegend: some View { + HStack(spacing: 14) { + ForEach(SpendModelsDayDetailPresentation.BucketKind.allCases, id: \.rawValue) { kind in + HStack(spacing: 5) { + Circle() + .fill(kind.color) + .frame(width: 8, height: 8) + Text(kind.title) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .accessibilityElement(children: .combine) + } + private func rowDetail(_ row: SpendModelsPresentation.Row) -> String { guard self.presentation.metric == .tokens else { guard let value = row.value else { return "—" } @@ -660,16 +780,15 @@ struct SpendModelsSection: View { return parts.joined(separator: " · ") } guard let value = row.value else { return "—" } - if row.source.inputTokens != nil, - row.source.outputTokens != nil, - let inputTokens = row.source.inputTokens, - let outputTokens = row.source.outputTokens - { - return L( - "%@ in · %@ out", - UsageFormatter.tokenCountString(inputTokens), - UsageFormatter.tokenCountString(outputTokens)) + let buckets = [ + (L("Input"), row.source.inputTokens), + (L("Output"), row.source.outputTokens), + (L("Cache read"), row.source.cacheReadTokens), + (L("Reasoning"), row.source.reasoningTokens), + ].compactMap { label, value in + value.map { "\(label) \(UsageFormatter.tokenCountString($0))" } } + if !buckets.isEmpty { return buckets.joined(separator: " · ") } return UsageFormatter.tokenCountString(Int(value.rounded())) } @@ -680,22 +799,56 @@ struct SpendModelsSection: View { } private func tooltip(_ day: Date) -> some View { - let points = (self.cachedPointsByDay[Calendar.current.startOfDay(for: day)] ?? []) + let detail = SpendModelsDayDetailPresentation( + analysis: self.analysis, + day: day, + metric: self.selectedMetric) + let averagedPoints = (self.cachedPointsByDay[Calendar.current.startOfDay(for: day)] ?? []) .sorted { $0.value > $1.value } return VStack(alignment: .leading, spacing: 5) { - Text(self.dayText(day)) - .font(.body.weight(.semibold)) - ForEach(points) { point in - HStack(spacing: 7) { - Circle() - .fill(Color.accentColor) - .frame(width: 8, height: 8) - Text(point.seriesName) - Spacer(minLength: 12) - Text(self.metricText(point.value)) + if self.trailingAverage { + Text("\(self.dayText(day)) · \(L("7-day avg"))") + .font(.body.weight(.semibold)) + ForEach(averagedPoints.prefix(6)) { point in + HStack(spacing: 7) { + Text(point.seriesName) + Spacer(minLength: 12) + Text(self.metricText(point.value)) + .monospacedDigit() + } + .font(.body) + } + } else if let detail { + HStack { + Text(self.dayText(day)) + .font(.body.weight(.semibold)) + Spacer() + Text(self.selectedMetric == .tokens + ? detail.totalTokens.map(UsageFormatter.tokenCountString) ?? "—" + : detail.totalCost.map { + UsageFormatter.currencyString($0, currencyCode: "USD") + } ?? "—") + .font(.body.weight(.semibold)) .monospacedDigit() } - .font(.body) + ForEach(detail.models.prefix(6)) { model in + HStack(spacing: 7) { + SpendProviderIcon( + provider: SpendProviderIdentity.modelProvider( + rawName: model.name, + fallback: .openai), + size: 14) + Text(model.name) + Spacer(minLength: 12) + Text(self.selectedMetric == .tokens + ? spendModelsDayDetailModelSplitText(model) + : model.cost.map { + UsageFormatter.currencyString($0, currencyCode: "USD") + } ?? "—") + .monospacedDigit() + } + .font(.body) + } } } .padding(10) @@ -808,7 +961,7 @@ struct SpendModelsSection: View { /// click support) and adds day pinning: mouse-down reports the location, and once the view holds /// first responder, Escape clears the pinned day. @MainActor -private struct SpendModelsChartMouseReader: NSViewRepresentable { +struct SpendModelsChartMouseReader: NSViewRepresentable { let onMoved: (CGPoint?) -> Void let onClicked: (CGPoint) -> Void let onEscape: () -> Void diff --git a/Sources/CodexBar/SpendClientsView.swift b/Sources/CodexBar/SpendClientsView.swift index 38790472b1..25f1b41a1e 100644 --- a/Sources/CodexBar/SpendClientsView.swift +++ b/Sources/CodexBar/SpendClientsView.swift @@ -3,26 +3,20 @@ import SwiftUI // MARK: - 按工具分组数据 -/// A model's usage attributed to one tool (client), with the five-bucket token breakdown -/// taken from the parent model row (buckets are tracked per model, so the per-client split -/// shares them proportionally by that client's token contribution). +/// A model's usage attributed to one tool (client). struct SpendClientModel: Identifiable, Equatable { let id: String let displayName: String let tokens: Int let cost: Double? let costIsEstimated: Bool - let inputTokens: Int? - let outputTokens: Int? - let cacheReadTokens: Int? - let cacheCreationTokens: Int? - let reasoningTokens: Int? } /// One tool (client) with its models, sorted by tokens descending. struct SpendClientGroup: Identifiable, Equatable { let sourceID: String let provider: UsageProvider + let kind: SpendToolIdentity.Kind /// Tool name, e.g. "Claude Code", "Codex Desktop", "Kimi Code CLI". let toolName: String /// Product family name, e.g. "Claude", "Codex", "Kimi". @@ -30,54 +24,31 @@ struct SpendClientGroup: Identifiable, Equatable { let totalTokens: Int let totalCost: Double? let costIsEstimated: Bool + let inputTokens: Int? + let outputTokens: Int? + let cacheReadTokens: Int? + let cacheCreationTokens: Int? + let reasoningTokens: Int? let models: [SpendClientModel] var id: String { self.sourceID } - /// "Tool · Family" when they differ meaningfully, else just the tool name. var displayTitle: String { - let tool = self.toolName.trimmingCharacters(in: .whitespacesAndNewlines) - let family = self.providerName.trimmingCharacters(in: .whitespacesAndNewlines) - if family.isEmpty || tool.localizedCaseInsensitiveContains(family) { - return tool - } - return "\(tool) · \(family)" + self.toolName } } enum SpendClientBreakdown { - /// The local tool that produced a provider's usage logs. Providers whose data is read from a - /// CLI/desktop app's local files surface under that tool's name; providers with a more specific - /// `sourceName` (e.g. a Codex account name, or the explicit "… CLI" names set at load time) - /// keep it untouched. - private static func toolName(provider: UsageProvider, sourceName: String, providerName: String) -> String { - // Only remap when the source name is just the product family (no specific tool identity). - guard sourceName.trimmingCharacters(in: .whitespacesAndNewlines) - .localizedCaseInsensitiveCompare(providerName.trimmingCharacters(in: .whitespacesAndNewlines)) - == .orderedSame - else { return sourceName } - switch provider { - case .claude: return "Claude Code" - case .codex: return "Codex Desktop" - case .kimi: return "Kimi Desktop" - case .gemini: return "Gemini CLI" - case .opencode, .opencodego: return "OpenCode" - case .minimax: return "MiniMax Code" - case .cursor: return "Cursor" - case .copilot: return "GitHub Copilot" - case .antigravity: return "Antigravity" - default: return sourceName - } - } - /// Groups model rows by contributing tool (one card per tool/account, e.g. each Codex /// account, Claude Code, Kimi Code CLI). A model used by several tools appears under each, /// with that tool's token/cost share (from `contributions`); the five-bucket breakdown is the /// model's own, shown for context under each tool it ran in. static func groups(from analysis: SpendDashboardModel.ModelAnalysis) -> [SpendClientGroup] { - var bySource: [String: (provider: UsageProvider, tool: String, family: String, models: [String: Accum])] = [:] + var bySource: + [String: (provider: UsageProvider, identity: SpendToolIdentity, family: String, models: [String: Accum])] = + [:] for row in analysis.rows { for contribution in row.contributions { @@ -86,7 +57,7 @@ enum SpendClientBreakdown { var bucket = bySource[contribution.sourceID] ?? ( contribution.provider, - Self.toolName( + SpendToolIdentity.resolve( provider: contribution.provider, sourceName: contribution.sourceName, providerName: contribution.providerName), @@ -99,11 +70,11 @@ enum SpendClientBreakdown { if let cost = contribution.estimatedCost { accum.cost = (accum.cost ?? 0) + cost } - accum.inputTokens = row.inputTokens - accum.outputTokens = row.outputTokens - accum.cacheReadTokens = row.cacheReadTokens - accum.cacheCreationTokens = row.cacheCreationTokens - accum.reasoningTokens = row.reasoningTokens + accum.inputTokens = contribution.inputTokens + accum.outputTokens = contribution.outputTokens + accum.cacheReadTokens = contribution.cacheReadTokens + accum.cacheCreationTokens = contribution.cacheCreationTokens + accum.reasoningTokens = contribution.reasoningTokens bucket.models[row.id] = accum bySource[contribution.sourceID] = bucket } @@ -116,12 +87,7 @@ enum SpendClientBreakdown { displayName: accum.displayName, tokens: accum.tokens, cost: accum.cost, - costIsEstimated: accum.costIsEstimated, - inputTokens: accum.inputTokens, - outputTokens: accum.outputTokens, - cacheReadTokens: accum.cacheReadTokens, - cacheCreationTokens: accum.cacheCreationTokens, - reasoningTokens: accum.reasoningTokens) + costIsEstimated: accum.costIsEstimated) } .sorted { $0.tokens > $1.tokens } let totalTokens = models.reduce(0) { $0 + $1.tokens } @@ -132,11 +98,17 @@ enum SpendClientBreakdown { return SpendClientGroup( sourceID: sourceID, provider: bucket.provider, - toolName: bucket.tool, + kind: bucket.identity.kind, + toolName: bucket.identity.displayName, providerName: bucket.family, totalTokens: totalTokens, totalCost: totalCost, costIsEstimated: models.contains { $0.costIsEstimated }, + inputTokens: Self.completeSum(bucket.models.values.map(\.inputTokens)), + outputTokens: Self.completeSum(bucket.models.values.map(\.outputTokens)), + cacheReadTokens: Self.completeSum(bucket.models.values.map(\.cacheReadTokens)), + cacheCreationTokens: Self.completeSum(bucket.models.values.map(\.cacheCreationTokens)), + reasoningTokens: Self.completeSum(bucket.models.values.map(\.reasoningTokens)), models: models) } .sorted { $0.totalTokens > $1.totalTokens } @@ -153,6 +125,11 @@ enum SpendClientBreakdown { var cacheCreationTokens: Int? var reasoningTokens: Int? } + + private static func completeSum(_ values: [Int?]) -> Int? { + guard values.allSatisfy({ $0 != nil }) else { return nil } + return values.compactMap(\.self).reduce(0, +) + } } // MARK: - 按工具分组视图 @@ -185,6 +162,12 @@ struct SpendClientsView: View { size: SpendModelsListStyle.iconSize) Text(group.displayTitle) .font(SpendModelsListStyle.primaryEmphasizedFont) + Text(group.kind.displayName) + .font(.caption2.weight(.medium)) + .foregroundStyle(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.secondary.opacity(0.12), in: Capsule()) if group.costIsEstimated { Text(L("Estimated")) .font(.caption2) @@ -201,6 +184,15 @@ struct SpendClientsView: View { } .padding(.bottom, 8) + if let tokenSummary = self.tokenSummary(group) { + Text(tokenSummary) + .font(SpendModelsListStyle.secondaryFont) + .foregroundStyle(.secondary) + .monospacedDigit() + .lineLimit(2) + .padding(.bottom, 8) + } + ForEach(Array(group.models.enumerated()), id: \.element.id) { index, model in if index > 0 { Divider().padding(.vertical, 2) } self.modelRow(model, groupTokens: group.totalTokens) @@ -221,13 +213,6 @@ struct SpendClientsView: View { .font(SpendModelsListStyle.primaryFont) .monospacedDigit() } - if let meta = self.metaText(model) { - Text(meta) - .font(SpendModelsListStyle.secondaryFont) - .foregroundStyle(.secondary) - .monospacedDigit() - .lineLimit(2) - } if groupTokens > 0 { GeometryReader { geo in let share = CGFloat(model.tokens) / CGFloat(groupTokens) @@ -256,21 +241,21 @@ struct SpendClientsView: View { return UsageFormatter.tokenCountString(model.tokens) } - private func metaText(_ model: SpendClientModel) -> String? { + private func tokenSummary(_ group: SpendClientGroup) -> String? { var parts: [String] = [] - if let input = model.inputTokens, input > 0 { + if let input = group.inputTokens, input > 0 { parts.append("Input \(UsageFormatter.tokenCountString(input))") } - if let output = model.outputTokens, output > 0 { + if let output = group.outputTokens, output > 0 { parts.append("Output \(UsageFormatter.tokenCountString(output))") } - if let cacheRead = model.cacheReadTokens, cacheRead > 0 { + if let cacheRead = group.cacheReadTokens, cacheRead > 0 { parts.append("Cache read \(UsageFormatter.tokenCountString(cacheRead))") } - if let cacheWrite = model.cacheCreationTokens, cacheWrite > 0 { + if let cacheWrite = group.cacheCreationTokens, cacheWrite > 0 { parts.append("Cache write \(UsageFormatter.tokenCountString(cacheWrite))") } - if let reasoning = model.reasoningTokens, reasoning > 0 { + if let reasoning = group.reasoningTokens, reasoning > 0 { parts.append("Reasoning \(UsageFormatter.tokenCountString(reasoning))") } return parts.isEmpty ? nil : parts.joined(separator: " · ") diff --git a/Sources/CodexBar/SpendDashboardModel+TokenBuckets.swift b/Sources/CodexBar/SpendDashboardModel+TokenBuckets.swift new file mode 100644 index 0000000000..27b55c2b0c --- /dev/null +++ b/Sources/CodexBar/SpendDashboardModel+TokenBuckets.swift @@ -0,0 +1,54 @@ +/// Shared split-resolution state of the model-analysis accumulators. +protocol ModelTokenSplitAccumulating { + var inputTokens: Int? { get } + var outputTokens: Int? { get } + var cacheReadTokens: Int? { get } + var cacheCreationTokens: Int? { get } + var reasoningTokens: Int? { get } + var sawTokenSplit: Bool { get } + var sawCacheReadTokens: Bool { get } + var missingCacheReadTokens: Bool { get } + var sawCacheCreationTokens: Bool { get } + var missingCacheCreationTokens: Bool { get } + var sawReasoningTokens: Bool { get } + var missingReasoningTokens: Bool { get } + var invalidTokenSplit: Bool { get } + var overflowedInputTokens: Bool { get } + var overflowedOutputTokens: Bool { get } + var overflowedCacheReadTokens: Bool { get } + var overflowedCacheCreationTokens: Bool { get } + var overflowedReasoningTokens: Bool { get } +} + +extension ModelTokenSplitAccumulating { + func resolvedTokenBuckets() -> SpendDashboardModel.ModelTokenSplitBuckets { + let hasCompleteTokenSplit = self.sawTokenSplit + && !self.invalidTokenSplit + && !self.overflowedInputTokens + && !self.overflowedOutputTokens + return SpendDashboardModel.ModelTokenSplitBuckets( + inputTokens: hasCompleteTokenSplit ? self.inputTokens : nil, + outputTokens: hasCompleteTokenSplit ? self.outputTokens : nil, + cacheReadTokens: SpendDashboardModel.optionalTokenBucket( + self.cacheReadTokens, + saw: self.sawCacheReadTokens, + missing: self.missingCacheReadTokens, + overflowed: self.overflowedCacheReadTokens, + splitIsComplete: hasCompleteTokenSplit), + cacheCreationTokens: SpendDashboardModel.optionalTokenBucket( + self.cacheCreationTokens, + saw: self.sawCacheCreationTokens, + missing: self.missingCacheCreationTokens, + overflowed: self.overflowedCacheCreationTokens, + splitIsComplete: hasCompleteTokenSplit), + reasoningTokens: SpendDashboardModel.optionalTokenBucket( + self.reasoningTokens, + saw: self.sawReasoningTokens, + missing: self.missingReasoningTokens, + overflowed: self.overflowedReasoningTokens, + splitIsComplete: hasCompleteTokenSplit)) + } +} + +extension SpendDashboardModel.ModelAnalysisAccumulator: ModelTokenSplitAccumulating {} +extension SpendDashboardModel.ModelAnalysisDailyAccumulator: ModelTokenSplitAccumulating {} diff --git a/Sources/CodexBar/SpendDashboardModel.swift b/Sources/CodexBar/SpendDashboardModel.swift index 199bf583b0..1c262ab946 100644 --- a/Sources/CodexBar/SpendDashboardModel.swift +++ b/Sources/CodexBar/SpendDashboardModel.swift @@ -60,6 +60,7 @@ struct SpendDashboardModel: Equatable, Sendable { let sourceID: String let provider: UsageProvider let providerName: String + let toolKind: SpendToolIdentity.Kind let day: Date let cost: Double let stackStart: Double @@ -68,6 +69,59 @@ struct SpendDashboardModel: Equatable, Sendable { var id: String { "\(self.sourceID):\(Int(self.day.timeIntervalSince1970))" } + + init( + sourceID: String, + provider: UsageProvider, + providerName: String, + toolKind: SpendToolIdentity.Kind = .other, + day: Date, + cost: Double, + stackStart: Double, + stackEnd: Double) + { + self.sourceID = sourceID + self.provider = provider + self.providerName = providerName + self.toolKind = toolKind + self.day = day + self.cost = cost + self.stackStart = stackStart + self.stackEnd = stackEnd + } + } + + struct DailySpendModel: Identifiable, Equatable, Sendable { + let id: String + let displayName: String + let modelProvider: UsageProvider + let tokens: Int? + let cost: Double? + } + + struct DailySpendTool: Identifiable, Equatable, Sendable { + let sourceID: String + let provider: UsageProvider + let displayName: String + let kind: SpendToolIdentity.Kind + let tokens: Int? + let cost: Double + let models: [DailySpendModel] + + var id: String { + self.sourceID + } + } + + struct DailySpendDetail: Identifiable, Equatable, Sendable { + let day: Date + let totalTokens: Int? + let totalCost: Double + let tools: [DailySpendTool] + + var id: Date { + self.day + } } enum ModelHistoryCompleteness: Equatable, Sendable { @@ -88,6 +142,11 @@ struct SpendDashboardModel: Equatable, Sendable { let providerName: String let rawModelNames: [String] let totalTokens: Int? + let inputTokens: Int? + let outputTokens: Int? + let cacheReadTokens: Int? + let cacheCreationTokens: Int? + let reasoningTokens: Int? let estimatedCost: Double? var id: String { @@ -220,6 +279,7 @@ struct SpendDashboardModel: Equatable, Sendable { let models: [ModelRow] var modelAnalysis: ModelAnalysis = .empty let dailyPoints: [DailyPoint] + var dailySpendDetails: [DailySpendDetail] = [] let totalTokens: Int? let totalCost: Double? let coveredDayCount: Int @@ -385,7 +445,7 @@ struct SpendDashboardModel: Equatable, Sendable { let completeness: ModelHistoryCompleteness } - fileprivate struct ModelAnalysisAccumulator { + struct ModelAnalysisAccumulator { var rawNames: Set = [] var displayNames: Set = [] var providerNames: [UsageProvider: String] = [:] @@ -417,16 +477,34 @@ struct SpendDashboardModel: Equatable, Sendable { var overflowedCost = false } - fileprivate struct ModelAnalysisSourceAccumulator { + struct ModelAnalysisSourceAccumulator { let provider: UsageProvider let sourceName: String let providerName: String var rawNames: Set = [] var tokens: Int? = 0 + var inputTokens: Int? = 0 + var outputTokens: Int? = 0 + var cacheReadTokens: Int? = 0 + var cacheCreationTokens: Int? = 0 + var reasoningTokens: Int? = 0 var cost: Double? = 0 var sawTokens = false + var sawTokenSplit = false + var sawCacheReadTokens = false + var missingCacheReadTokens = false + var sawCacheCreationTokens = false + var missingCacheCreationTokens = false + var sawReasoningTokens = false + var missingReasoningTokens = false + var invalidTokenSplit = false var sawCost = false var overflowedTokens = false + var overflowedInputTokens = false + var overflowedOutputTokens = false + var overflowedCacheReadTokens = false + var overflowedCacheCreationTokens = false + var overflowedReasoningTokens = false var overflowedCost = false } @@ -435,7 +513,7 @@ struct SpendDashboardModel: Equatable, Sendable { let day: Date } - fileprivate struct ModelAnalysisDailyAccumulator { + struct ModelAnalysisDailyAccumulator { var tokens: Int? = 0 var inputTokens: Int? = 0 var outputTokens: Int? = 0 @@ -470,6 +548,7 @@ struct SpendDashboardModel: Equatable, Sendable { private struct DailyAccumulator { let provider: UsageProvider let providerName: String + let toolKind: SpendToolIdentity.Kind var cost: Double? var invalid = false var overflowed = false @@ -506,15 +585,17 @@ extension SpendDashboardModel { let modelHistoryCompleteness = completeModelSummaries.count == summaries.count ? ModelHistoryCompleteness.complete : ModelHistoryCompleteness.incomplete - // The spend chart answers the same billing question as "By subscription". Its series must - // therefore be the vendor that owns the quota, not the harness that emitted the log. - let dailyPoints = Self.dailyPoints(summaries: billingSummaries) + // Daily spend is an operational tool view: it answers which local app or harness generated + // the usage. Subscription ownership remains isolated to `providers` above. + let dailyPoints = Self.dailyPoints(summaries: summaries) + let dailySpendDetails = Self.dailySpendDetails(summaries: summaries) return CurrencyGroup( currencyCode: currencyCode, providers: providers, models: modelSummary.rows, modelAnalysis: modelAnalysis, dailyPoints: dailyPoints, + dailySpendDetails: dailySpendDetails, // "Tracked tokens" is the subtotal we actually parsed, not a completeness assertion. // A source without token detail must not erase known tokens from every other source. // This mirrors Tokscale's aggregation: parsed token buckets always sum independently @@ -766,7 +847,49 @@ extension SpendDashboardModel { } } - let rows = models.compactMap { identity, aggregate -> ModelAnalysisRow? in + let rows = Self.modelAnalysisRows(models) + + let namesByID: [String: String] = Dictionary(uniqueKeysWithValues: rows.map { ($0.id, $0.displayName) }) + let dailyValues = daily.compactMap { key, value -> ModelDailyValue? in + let tokens = value.sawTokens && !value.overflowedTokens ? value.tokens : nil + let buckets = value.resolvedTokenBuckets() + let cost = value.sawCost && !value.overflowedCost ? value.cost : nil + guard tokens != nil || cost != nil, let name = namesByID[key.modelID] else { return nil } + return ModelDailyValue( + modelID: key.modelID, + modelName: name, + day: key.day, + totalTokens: tokens, + inputTokens: buckets.inputTokens, + outputTokens: buckets.outputTokens, + estimatedCost: cost, + cacheReadTokens: buckets.cacheReadTokens, + cacheCreationTokens: buckets.cacheCreationTokens, + reasoningTokens: buckets.reasoningTokens) + } + .sorted { lhs, rhs in + if lhs.day != rhs.day { return lhs.day < rhs.day } + return lhs.modelID < rhs.modelID + } + + let trackedTokenTotal = Self.safeIntSum(rows.compactMap(\.totalTokens)) + let pricedCostTotal = Self.safeCostSum(rows.compactMap(\.estimatedCost)) + return ModelAnalysis( + rows: rows, + dailyValues: dailyValues, + trackedTokenTotal: trackedTokenTotal, + pricedCostTotal: pricedCostTotal, + sourceCount: Set(rows.flatMap(\.contributions).map(\.sourceID)).count, + tokenCoverage: Self.modelMetricCoverage( + hasValue: trackedTokenTotal != nil, + isPartial: tokenCoverageIsPartial), + costCoverage: Self.modelMetricCoverage(hasValue: pricedCostTotal != nil, isPartial: costCoverageIsPartial)) + } + + private static func modelAnalysisRows( + _ models: [String: ModelAnalysisAccumulator]) -> [ModelAnalysisRow] + { + models.compactMap { identity, aggregate -> ModelAnalysisRow? in let totalTokens = aggregate.sawTokens && !aggregate.overflowedTokens ? aggregate.tokens : nil let buckets = aggregate.resolvedTokenBuckets() let estimatedCost = aggregate.sawCost && !aggregate.overflowedCost ? aggregate.cost : nil @@ -774,13 +897,38 @@ extension SpendDashboardModel { let rawNames = aggregate.rawNames.sorted(by: Self.modelNameOrder) let displayNames = aggregate.displayNames.sorted(by: Self.modelNameOrder) let contributions = aggregate.sourceContributions.map { sourceID, source in - ModelSourceContribution( + let sourceSplitIsComplete = source.sawTokenSplit && !source.invalidTokenSplit + return ModelSourceContribution( sourceID: sourceID, provider: source.provider, sourceName: source.sourceName, providerName: source.providerName, rawModelNames: source.rawNames.sorted(by: Self.modelNameOrder), totalTokens: source.sawTokens && !source.overflowedTokens ? source.tokens : nil, + inputTokens: sourceSplitIsComplete && !source.overflowedInputTokens + ? source.inputTokens + : nil, + outputTokens: sourceSplitIsComplete && !source.overflowedOutputTokens + ? source.outputTokens + : nil, + cacheReadTokens: Self.optionalTokenBucket( + source.cacheReadTokens, + saw: source.sawCacheReadTokens, + missing: source.missingCacheReadTokens, + overflowed: source.overflowedCacheReadTokens, + splitIsComplete: sourceSplitIsComplete), + cacheCreationTokens: Self.optionalTokenBucket( + source.cacheCreationTokens, + saw: source.sawCacheCreationTokens, + missing: source.missingCacheCreationTokens, + overflowed: source.overflowedCacheCreationTokens, + splitIsComplete: sourceSplitIsComplete), + reasoningTokens: Self.optionalTokenBucket( + source.reasoningTokens, + saw: source.sawReasoningTokens, + missing: source.missingReasoningTokens, + overflowed: source.overflowedReasoningTokens, + splitIsComplete: sourceSplitIsComplete), estimatedCost: source.sawCost && !source.overflowedCost ? source.cost : nil) } .sorted { lhs, rhs in @@ -810,43 +958,7 @@ extension SpendDashboardModel { reasoningTokens: buckets.reasoningTokens, costIsEstimated: aggregate.sawEstimatedCost) } - .sorted(by: Self.modelAnalysisRowOrder) - - let namesByID: [String: String] = Dictionary(uniqueKeysWithValues: rows.map { ($0.id, $0.displayName) }) - let dailyValues = daily.compactMap { key, value -> ModelDailyValue? in - let tokens = value.sawTokens && !value.overflowedTokens ? value.tokens : nil - let buckets = value.resolvedTokenBuckets() - let cost = value.sawCost && !value.overflowedCost ? value.cost : nil - guard tokens != nil || cost != nil, let name = namesByID[key.modelID] else { return nil } - return ModelDailyValue( - modelID: key.modelID, - modelName: name, - day: key.day, - totalTokens: tokens, - inputTokens: buckets.inputTokens, - outputTokens: buckets.outputTokens, - estimatedCost: cost, - cacheReadTokens: buckets.cacheReadTokens, - cacheCreationTokens: buckets.cacheCreationTokens, - reasoningTokens: buckets.reasoningTokens) - } - .sorted { lhs, rhs in - if lhs.day != rhs.day { return lhs.day < rhs.day } - return lhs.modelID < rhs.modelID - } - - let trackedTokenTotal = Self.safeIntSum(rows.compactMap(\.totalTokens)) - let pricedCostTotal = Self.safeCostSum(rows.compactMap(\.estimatedCost)) - return ModelAnalysis( - rows: rows, - dailyValues: dailyValues, - trackedTokenTotal: trackedTokenTotal, - pricedCostTotal: pricedCostTotal, - sourceCount: Set(rows.flatMap(\.contributions).map(\.sourceID)).count, - tokenCoverage: Self.modelMetricCoverage( - hasValue: trackedTokenTotal != nil, - isPartial: tokenCoverageIsPartial), - costCoverage: Self.modelMetricCoverage(hasValue: pricedCostTotal != nil, isPartial: costCoverageIsPartial)) + .sorted(by: self.modelAnalysisRowOrder) } private static func addModelTokenBreakdown( @@ -881,6 +993,15 @@ extension SpendDashboardModel { split.output, to: aggregate.outputTokens, overflowed: &aggregate.overflowedOutputTokens) + source.sawTokenSplit = true + source.inputTokens = Self.add( + split.input, + to: source.inputTokens, + overflowed: &source.overflowedInputTokens) + source.outputTokens = Self.add( + split.output, + to: source.outputTokens, + overflowed: &source.overflowedOutputTokens) dailyValue.sawTokenSplit = true dailyValue.inputTokens = Self.add( split.input, @@ -896,6 +1017,12 @@ extension SpendDashboardModel { saw: &aggregate.sawCacheReadTokens, missing: &aggregate.missingCacheReadTokens, overflowed: &aggregate.overflowedCacheReadTokens) + Self.addOptionalTokenBucket( + split.cacheRead, + into: &source.cacheReadTokens, + saw: &source.sawCacheReadTokens, + missing: &source.missingCacheReadTokens, + overflowed: &source.overflowedCacheReadTokens) Self.addOptionalTokenBucket( split.cacheRead, into: &dailyValue.cacheReadTokens, @@ -908,6 +1035,12 @@ extension SpendDashboardModel { saw: &aggregate.sawCacheCreationTokens, missing: &aggregate.missingCacheCreationTokens, overflowed: &aggregate.overflowedCacheCreationTokens) + Self.addOptionalTokenBucket( + split.cacheCreation, + into: &source.cacheCreationTokens, + saw: &source.sawCacheCreationTokens, + missing: &source.missingCacheCreationTokens, + overflowed: &source.overflowedCacheCreationTokens) Self.addOptionalTokenBucket( split.cacheCreation, into: &dailyValue.cacheCreationTokens, @@ -920,6 +1053,12 @@ extension SpendDashboardModel { saw: &aggregate.sawReasoningTokens, missing: &aggregate.missingReasoningTokens, overflowed: &aggregate.overflowedReasoningTokens) + Self.addOptionalTokenBucket( + split.reasoning, + into: &source.reasoningTokens, + saw: &source.sawReasoningTokens, + missing: &source.missingReasoningTokens, + overflowed: &source.overflowedReasoningTokens) Self.addOptionalTokenBucket( split.reasoning, into: &dailyValue.reasoningTokens, @@ -928,6 +1067,7 @@ extension SpendDashboardModel { overflowed: &dailyValue.overflowedReasoningTokens) } else { aggregate.invalidTokenSplit = true + source.invalidTokenSplit = true dailyValue.invalidTokenSplit = true } return true @@ -951,7 +1091,7 @@ extension SpendDashboardModel { bucket = Self.add(value, to: bucket, overflowed: &overflowed) } - fileprivate static func optionalTokenBucket( + static func optionalTokenBucket( _ bucket: Int?, saw: Bool, missing: Bool, @@ -963,7 +1103,7 @@ extension SpendDashboardModel { } /// Resolved per-model token buckets for one analysis row or daily value. - fileprivate struct ModelTokenSplitBuckets: Equatable, Sendable { + struct ModelTokenSplitBuckets: Equatable, Sendable { let inputTokens: Int? let outputTokens: Int? let cacheReadTokens: Int? @@ -1205,9 +1345,14 @@ extension SpendDashboardModel { let day = windowEntry.day let entry = windowEntry.entry let key = DailyKey(day: day, sourceID: input.id) + let tool = SpendToolIdentity.resolve( + provider: input.provider, + sourceName: input.displayName, + providerName: input.modelProviderName) var aggregate = aggregates[key] ?? DailyAccumulator( provider: input.provider, - providerName: input.displayName, + providerName: tool.displayName, + toolKind: tool.kind, cost: 0) if let cost = Self.validCost(entry.costUSD) { aggregate.cost = Self.add(cost, to: aggregate.cost, overflowed: &aggregate.overflowed) @@ -1234,6 +1379,7 @@ extension SpendDashboardModel { sourceID: key.sourceID, provider: value.provider, providerName: value.providerName, + toolKind: value.toolKind, day: day, cost: cost, stackStart: start, @@ -1243,6 +1389,93 @@ extension SpendDashboardModel { } } + private static func dailySpendDetails(summaries: [InputSummary]) -> [DailySpendDetail] { + struct Key: Hashable { + let day: Date + let sourceID: String + } + struct ToolAccum { + let input: ProviderInput + let identity: SpendToolIdentity + var tokens: Int? + var cost = 0.0 + var models: [String: (name: String, provider: UsageProvider, tokens: Int?, cost: Double?)] = [:] + } + + var toolsByKey: [Key: ToolAccum] = [:] + for summary in summaries where !summary.hasInvalidCostHistory { + let input = summary.input + let identity = SpendToolIdentity.resolve( + provider: input.provider, + sourceName: input.displayName, + providerName: input.modelProviderName) + for windowEntry in summary.entries { + guard let entryCost = Self.validCost(windowEntry.entry.costUSD) else { continue } + let key = Key(day: windowEntry.day, sourceID: input.id) + var tool = toolsByKey[key] ?? ToolAccum( + input: input, + identity: identity, + tokens: 0) + tool.cost += entryCost + tool.tokens = Self.addAvailable(windowEntry.entry.totalTokens, to: tool.tokens) + for breakdown in windowEntry.entry.modelBreakdowns ?? [] { + let modelIdentity = SpendModelIdentity(rawName: breakdown.modelName, provider: input.provider) + let modelProvider = SpendProviderIdentity.modelProvider( + rawName: breakdown.modelName, + fallback: input.provider) + let existing = tool.models[modelIdentity.id] + tool.models[modelIdentity.id] = ( + modelIdentity.displayName, + modelProvider, + Self.addAvailable(breakdown.totalTokens, to: existing?.tokens), + Self.addAvailableCost(breakdown.costUSD, to: existing?.cost)) + } + toolsByKey[key] = tool + } + } + + let byDay = Dictionary(grouping: toolsByKey, by: \.key.day) + return byDay.keys.sorted().map { day in + let tools = byDay[day, default: []].map { key, value in + DailySpendTool( + sourceID: key.sourceID, + provider: value.input.provider, + displayName: value.identity.displayName, + kind: value.identity.kind, + tokens: value.tokens, + cost: value.cost, + models: value.models.map { id, model in + DailySpendModel( + id: id, + displayName: model.name, + modelProvider: model.provider, + tokens: model.tokens, + cost: model.cost) + } + .sorted { ($0.cost ?? 0) > ($1.cost ?? 0) }) + } + .sorted { $0.cost > $1.cost } + return DailySpendDetail( + day: day, + totalTokens: Self.availableIntSum(tools.map(\.tokens)), + totalCost: tools.reduce(0) { $0 + $1.cost }, + tools: tools) + } + } + + private static func addAvailable(_ value: Int?, to current: Int?) -> Int? { + guard let value else { return current } + return (current ?? 0).addingReportingOverflow(value).overflow + ? current + : (current ?? 0) + value + } + + private static func addAvailableCost(_ value: Double?, to current: Double?) -> Double? { + guard let value = validCost(value) else { return current } + let result = (current ?? 0) + value + return result.isFinite ? result : current + } + private static func bounds(days: Int, now: Date, calendar: Calendar) -> ClosedRange { let end = calendar.startOfDay(for: now) let start = calendar.date(byAdding: .day, value: -(days - 1), to: end) ?? end @@ -1432,58 +1665,3 @@ extension SpendDashboardModel { return result } } - -/// Shared split-resolution state of the model-analysis accumulators. -private protocol ModelTokenSplitAccumulating { - var inputTokens: Int? { get } - var outputTokens: Int? { get } - var cacheReadTokens: Int? { get } - var cacheCreationTokens: Int? { get } - var reasoningTokens: Int? { get } - var sawTokenSplit: Bool { get } - var sawCacheReadTokens: Bool { get } - var missingCacheReadTokens: Bool { get } - var sawCacheCreationTokens: Bool { get } - var missingCacheCreationTokens: Bool { get } - var sawReasoningTokens: Bool { get } - var missingReasoningTokens: Bool { get } - var invalidTokenSplit: Bool { get } - var overflowedInputTokens: Bool { get } - var overflowedOutputTokens: Bool { get } - var overflowedCacheReadTokens: Bool { get } - var overflowedCacheCreationTokens: Bool { get } - var overflowedReasoningTokens: Bool { get } -} - -extension ModelTokenSplitAccumulating { - func resolvedTokenBuckets() -> SpendDashboardModel.ModelTokenSplitBuckets { - let hasCompleteTokenSplit = self.sawTokenSplit - && !self.invalidTokenSplit - && !self.overflowedInputTokens - && !self.overflowedOutputTokens - return SpendDashboardModel.ModelTokenSplitBuckets( - inputTokens: hasCompleteTokenSplit ? self.inputTokens : nil, - outputTokens: hasCompleteTokenSplit ? self.outputTokens : nil, - cacheReadTokens: SpendDashboardModel.optionalTokenBucket( - self.cacheReadTokens, - saw: self.sawCacheReadTokens, - missing: self.missingCacheReadTokens, - overflowed: self.overflowedCacheReadTokens, - splitIsComplete: hasCompleteTokenSplit), - cacheCreationTokens: SpendDashboardModel.optionalTokenBucket( - self.cacheCreationTokens, - saw: self.sawCacheCreationTokens, - missing: self.missingCacheCreationTokens, - overflowed: self.overflowedCacheCreationTokens, - splitIsComplete: hasCompleteTokenSplit), - reasoningTokens: SpendDashboardModel.optionalTokenBucket( - self.reasoningTokens, - saw: self.sawReasoningTokens, - missing: self.missingReasoningTokens, - overflowed: self.overflowedReasoningTokens, - splitIsComplete: hasCompleteTokenSplit)) - } -} - -extension SpendDashboardModel.ModelAnalysisAccumulator: ModelTokenSplitAccumulating {} -extension SpendDashboardModel.ModelAnalysisDailyAccumulator: ModelTokenSplitAccumulating {} diff --git a/Sources/CodexBar/SpendProviderIdentity.swift b/Sources/CodexBar/SpendProviderIdentity.swift index cd80d13b7a..ec3e9462f7 100644 --- a/Sources/CodexBar/SpendProviderIdentity.swift +++ b/Sources/CodexBar/SpendProviderIdentity.swift @@ -63,6 +63,12 @@ enum SpendProviderIdentity { .mistral), (normalized.contains("qwen"), .alibaba), (normalized.contains("glm") || first == "zai", .zai), + (normalized.contains("doubao") || normalized.hasPrefix("seed-"), .doubao), + (normalized.contains("stepfun") || normalized.hasPrefix("step-"), .stepfun), + (normalized.contains("mimo"), .mimo), + (normalized.contains("longcat"), .longcat), + (normalized.contains("nova-") || normalized.hasPrefix("amazon.nova"), .bedrock), + (normalized.contains("perplexity") || normalized.hasPrefix("sonar-"), .perplexity), ] if let explicit = aliases.first(where: \.matches) { return explicit.provider diff --git a/Sources/CodexBar/SpendToolIdentity.swift b/Sources/CodexBar/SpendToolIdentity.swift new file mode 100644 index 0000000000..ce76b1dca9 --- /dev/null +++ b/Sources/CodexBar/SpendToolIdentity.swift @@ -0,0 +1,81 @@ +import CodexBarCore +import Foundation + +/// Stable presentation identity for the local product that emitted usage history. +/// +/// Provider identity answers "who made the model"; tool identity answers "which app or +/// harness used it". Keeping the two separate prevents a Kimi model used in Cursor from being +/// presented as though Kimi Code produced the history. +struct SpendToolIdentity: Equatable, Sendable { + enum Kind: String, CaseIterable, Sendable { + case desktop + case cli + case ide + case extensionTool + case api + case other + + var displayName: String { + switch self { + case .desktop: "Desktop" + case .cli: "CLI" + case .ide: "IDE" + case .extensionTool: "Extension" + case .api: "API" + case .other: "Tool" + } + } + } + + let displayName: String + let kind: Kind + + static func resolve( + provider: UsageProvider, + sourceName: String, + providerName: String) -> Self + { + let source = sourceName.trimmingCharacters(in: .whitespacesAndNewlines) + let family = providerName.trimmingCharacters(in: .whitespacesAndNewlines) + let normalized = source.lowercased() + + if normalized.contains(" cli") || normalized.hasSuffix("cli") { + return Self(displayName: source, kind: .cli) + } + if normalized.contains("desktop") { + return Self(displayName: source, kind: .desktop) + } + if normalized.contains("extension") || normalized.contains("copilot") { + return Self(displayName: source, kind: .extensionTool) + } + if normalized.contains(" api") || normalized.hasSuffix("api") { + return Self(displayName: source, kind: .api) + } + + let sourceIsFamily = source.localizedCaseInsensitiveCompare(family) == .orderedSame + switch provider { + case .codex: + return Self(displayName: sourceIsFamily ? "Codex Desktop" : source, kind: .desktop) + case .claude: + return Self(displayName: sourceIsFamily ? "Claude Code" : source, kind: .cli) + case .cursor: + return Self(displayName: sourceIsFamily ? "Cursor" : source, kind: .ide) + case .antigravity: + return Self(displayName: sourceIsFamily ? "Antigravity" : source, kind: .ide) + case .kimi: + return Self(displayName: sourceIsFamily ? "Kimi Code CLI" : source, kind: .cli) + case .gemini: + return Self(displayName: sourceIsFamily ? "Gemini CLI" : source, kind: .cli) + case .minimax: + return Self(displayName: sourceIsFamily ? "MiniMax Code" : source, kind: .desktop) + case .opencode, .opencodego: + return Self(displayName: sourceIsFamily ? "OpenCode CLI" : source, kind: .cli) + case .zed, .qoder: + return Self(displayName: sourceIsFamily ? family : source, kind: .ide) + case .copilot: + return Self(displayName: sourceIsFamily ? "GitHub Copilot" : source, kind: .extensionTool) + default: + return Self(displayName: source.isEmpty ? family : source, kind: .other) + } + } +} diff --git a/Tests/CodexBarTests/SpendBillingAttributionTests.swift b/Tests/CodexBarTests/SpendBillingAttributionTests.swift index e205b5963e..1ef62a22da 100644 --- a/Tests/CodexBarTests/SpendBillingAttributionTests.swift +++ b/Tests/CodexBarTests/SpendBillingAttributionTests.swift @@ -295,6 +295,10 @@ struct SpendBillingAttributionTests { #expect(SpendProviderIdentity.modelProvider(rawName: "k3", fallback: .cursor) == .kimi) #expect(SpendProviderIdentity.modelProvider(rawName: "gpt-5.6-terra", fallback: .codex) == .openai) #expect(SpendProviderIdentity.modelProvider(rawName: "future-model", fallback: .cursor) == .cursor) + #expect(SpendProviderIdentity.modelProvider(rawName: "qwen3-coder", fallback: .cursor) == .alibaba) + #expect(SpendProviderIdentity.modelProvider(rawName: "glm-5", fallback: .cursor) == .zai) + #expect(SpendProviderIdentity.modelProvider(rawName: "amazon.nova-pro", fallback: .cursor) == .bedrock) + #expect(SpendProviderIdentity.modelProvider(rawName: "sonar-pro", fallback: .cursor) == .perplexity) } private static func entry( diff --git a/Tests/CodexBarTests/SpendToolPresentationTests.swift b/Tests/CodexBarTests/SpendToolPresentationTests.swift new file mode 100644 index 0000000000..9cdf988456 --- /dev/null +++ b/Tests/CodexBarTests/SpendToolPresentationTests.swift @@ -0,0 +1,147 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct SpendToolPresentationTests { + @Test + func `tool identity separates product family from client kind`() { + #expect(SpendToolIdentity.resolve( + provider: .codex, + sourceName: "Codex", + providerName: "Codex") == .init(displayName: "Codex Desktop", kind: .desktop)) + #expect(SpendToolIdentity.resolve( + provider: .cursor, + sourceName: "Cursor", + providerName: "Cursor") == .init(displayName: "Cursor", kind: .ide)) + #expect(SpendToolIdentity.resolve( + provider: .kimi, + sourceName: "Kimi Code CLI", + providerName: "Kimi") == .init(displayName: "Kimi Code CLI", kind: .cli)) + #expect(SpendToolIdentity.resolve( + provider: .qoder, + sourceName: "Qoder", + providerName: "Qoder") == .init(displayName: "Qoder", kind: .ide)) + } + + @Test + func `tool cards aggregate token buckets once per source`() throws { + let model = Self.model() + let groups = SpendClientBreakdown.groups(from: model.modelAnalysis) + let codex = try #require(groups.first { $0.provider == .codex }) + let cursor = try #require(groups.first { $0.provider == .cursor }) + + #expect(codex.kind == .desktop) + #expect(codex.inputTokens == 40) + #expect(codex.outputTokens == 10) + #expect(codex.cacheReadTokens == 20) + #expect(codex.reasoningTokens == 4) + #expect(cursor.kind == .ide) + #expect(cursor.inputTokens == 20) + #expect(cursor.outputTokens == 10) + #expect(cursor.cacheReadTokens == 10) + #expect(cursor.reasoningTokens == 2) + } + + @Test + func `token chart partitions each day by semantic token bucket`() { + let presentation = SpendModelsTokenChartPresentation(analysis: Self.model().modelAnalysis) + + #expect(presentation.points.map(\.kind) == [ + .input, + .cacheRead, + .output, + .reasoning, + ]) + #expect(presentation.points.map(\.value) == [60, 30, 14, 6]) + #expect(presentation.points.last?.stackEnd == 110) + } + + @Test + func `daily spend details preserve tool type models amounts and tokens`() throws { + let group = try #require(Self.model().groups.first) + let detail = try #require(group.dailySpendDetails.first) + let codex = try #require(detail.tools.first { $0.provider == .codex }) + + #expect(codex.kind == .desktop) + #expect(codex.displayName == "Codex Desktop") + #expect(codex.tokens == 70) + #expect(codex.cost == 1) + #expect(codex.models.first?.displayName == "GPT-5 Test") + #expect(codex.models.first?.modelProvider == .openai) + } + + private static func model() -> SpendDashboardModel { + let inputs = [ + Self.input( + provider: .codex, + name: "Codex", + tokens: .init(input: 40, output: 10, cache: 20, reasoning: 4), + cost: 1), + Self.input( + provider: .cursor, + name: "Cursor", + tokens: .init(input: 20, output: 10, cache: 10, reasoning: 2), + cost: 2), + ] + return SpendDashboardModel.build( + inputs: inputs, + requestedDays: 7, + now: Self.now, + calendar: Self.calendar) + } + + private static func input( + provider: UsageProvider, + name: String, + tokens: TokenBuckets, + cost: Double) -> SpendDashboardModel.ProviderInput + { + let total = tokens.input + tokens.output + tokens.cache + let breakdown = CostUsageDailyReport.ModelBreakdown( + modelName: "gpt-5-test", + costUSD: cost, + totalTokens: total, + inputTokens: tokens.input, + cacheReadTokens: tokens.cache, + cacheCreationTokens: 0, + outputTokens: tokens.output, + reasoningTokens: tokens.reasoning) + let entry = CostUsageDailyReport.Entry( + date: "2026-07-27", + inputTokens: tokens.input, + outputTokens: tokens.output, + cacheReadTokens: tokens.cache, + cacheCreationTokens: 0, + totalTokens: total, + costUSD: cost, + modelsUsed: ["gpt-5-test"], + modelBreakdowns: [breakdown]) + return SpendDashboardModel.ProviderInput( + provider: provider, + displayName: name, + snapshot: CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: total, + last30DaysCostUSD: cost, + currencyCode: "USD", + historyDays: 7, + daily: [entry], + updatedAt: Self.now)) + } + + private struct TokenBuckets { + let input: Int + let output: Int + let cache: Int + let reasoning: Int + } + + private static let now = Date(timeIntervalSince1970: 1_785_240_000) + private static let calendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + }() +} diff --git a/design-qa.md b/design-qa.md new file mode 100644 index 0000000000..4fa1b975b9 --- /dev/null +++ b/design-qa.md @@ -0,0 +1,78 @@ +**Comparison Target** + +- Source visual truth: + - `/var/folders/xh/c0sb9bl978g59ywm7f_29n6m0000gn/T/codex-clipboard-d61a0b80-5855-45df-81be-5866b387794d.png` + - `/var/folders/xh/c0sb9bl978g59ywm7f_29n6m0000gn/T/codex-clipboard-c475cb6b-a430-4395-9f01-1d443b2ab3d6.png` + - `/var/folders/xh/c0sb9bl978g59ywm7f_29n6m0000gn/T/codex-clipboard-f2c06b25-eeb1-4f46-ab86-80154c23557c.png` + - `/var/folders/xh/c0sb9bl978g59ywm7f_29n6m0000gn/T/codex-clipboard-05011861-8496-4d9c-9864-4de48d51d187.png` +- Implementation screenshot: unavailable +- Viewport: CodexBar macOS Settings > Usage & Spend > Models +- State: grouped by model and grouped by tool + +**Full-view Comparison Evidence** + +The signed development build compiled, passed code-sign validation, and launched +successfully from this checkout. Computer Use could read the application's menu +and invoke Settings, but its native pipe again closed before the Settings window +response. A fresh Computer Use runtime failed the same way while the CodexBar +process remained running, so no safe rendered screenshot was available. + +**Focused Region Comparison Evidence** + +Rendered comparison is blocked. Static and automated evidence confirms: + +- Gemini, Claude, Kimi, and Antigravity load first-party original-color PNGs. +- MiniMax loads the gradient waveform extracted from its first-party brand package. +- Cursor loads the official `CUBE_2D_LIGHT.svg` geometry. +- Original-color icons are not marked as AppKit template images. +- Monochrome official marks follow the foreground color for light/dark contrast. +- Chart-series colors no longer recolor provider marks. +- Token bars now encode input, cache read, cache write, output, and reasoning + categories with a matching legend. +- Daily spend hover data carries the exact day, tool type, model, token count, + and estimated cost. +- Tool cards aggregate token buckets once at tool level and keep per-model rows + compact. + +Static inspection and passing tests are not substitutes for visual comparison. + +**Findings** + +- [P2] Final native rendering remains visually unverified + - Location: Usage & Spend model chart, tool cards, and daily-spend hover. + - Evidence: the signed app launched, but Computer Use closed its native pipe + while opening Settings. + - Impact: final small-size antialiasing and row alignment cannot be assessed + from a captured implementation screenshot. + - Fix: capture grouped-by-model and grouped-by-tool states once the native + accessibility bridge is stable. + +**Comparison History** + +- Iteration 1: provider glyphs were recolored using chart palette colors. +- Iteration 2: first-party assets and explicit original/template rendering modes + replaced provider tinting; provenance is recorded in + `docs/provider-icon-sources.md`. +- Iteration 3: semantic token stacks, exact day hover details, tool-type labels, + and tool-level token aggregation replaced visually ambiguous single-blue + stacks and repeated per-model metadata. + +**Implementation Checklist** + +- Capture model, tool, and daily-spend hover states from the development app. +- Verify current Gemini multicolor star, Kimi blue dot, MiniMax gradient, Claude + coral mark, and Antigravity full-color mark at 20 points. +- Verify Cursor and OpenAI remain legible in light and dark appearance. +- Verify tooltip placement does not obscure the hovered bar at narrow and wide + Settings window widths. + +**Open Questions** + +- None about intended behavior; only rendered native evidence is missing. + +**Follow-up Polish** + +- Adjust per-icon optical sizing only if the eventual native capture shows a + first-party asset appearing materially smaller than its peers. + +final result: blocked From 4281accdc1f17b60cb893943f46663af4a72c25e Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Wed, 29 Jul 2026 00:25:45 +0800 Subject: [PATCH 12/14] =?UTF-8?q?feat:=20enhance=20spend=20dashboard=20UX?= =?UTF-8?q?=20=E2=80=94=20collapsible=20detail=20panels,=20model=20icon=20?= =?UTF-8?q?hierarchy,=20hover=20interaction=20polish,=20subscription=20att?= =?UTF-8?q?ribution=20refinements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chart Interaction: - Add hardware-level mouse tracking (NSEvent.addLocalMonitorForEvents) to fix mouseExited event drops during fast cursor movement - Implement lastHoveredDay state persistence so detail panel holds selected day across chart bar gaps without flickering - Extend hover detection zone to cover chart + legend + detail panel — mouse can move to detail text for reading without resetting - Add panel-level onHover boundary: leaving the card container auto-snaps back to today - Remove 27000s distance cutoff in nearestDay() for seamless tracking in dense cumulative chart mode Collapsible Detail Panels: - Add chevron toggle with snappy animation to SpendDailyDayDetailCard (Daily Estimated Spend) - Add chevron toggle with snappy animation to SpendModelsDayDetailView (Models) - Persist collapse state via @AppStorage per panel type - Default expanded; user preference remembered across sessions Model Icon Hierarchy: - Add isMonochrome parameter to SpendProviderIcon - Provider-level rows show full-color brand icons (Antigravity, Codex, Claude Code) - Sub-model rows show grayscale template-rendered brand icons for visual subordination - Replace generic blue squares and gray dots with contextual brand icons Subscription Attribution: - Refine subscription plan display names (Google AI Pro, ChatGPT Plus) - Fix MiniMax spend data availability by preserving local history coverage - Correct Kimi K3 attribution from Cursor to Kimi subscription source - Add Google Gemini pricing via CostUsagePricing+Google - Add SpendDashboardModel+ChartDomain and +Evidence extensions Tests & Localization: - Update SpendDashboardModelTests, SpendModelsPresentationTests, SpendToolPresentationTests - Add SpendChartDayHitTargetTests, CostUsagePricingTests - Update all 23 Localizable.strings files with new UI strings --- .../PreferencesSpendDashboardPane.swift | 270 +++++--- .../PreferencesSpendModelsDayDetailView.swift | 180 ++++- .../CodexBar/PreferencesSpendModelsView.swift | 625 +++++++++++------- Sources/CodexBar/ProviderBrandIcon.swift | 4 +- .../Resources/ProviderIcon-antigravity.svg | 3 + .../Resources/ProviderIcon-claude.svg | 3 + .../Resources/ProviderIcon-cursor.svg | 4 +- .../Resources/ProviderIcon-gemini.svg | 3 + .../CodexBar/Resources/ProviderIcon-kimi.svg | 1 + .../Resources/ProviderIcon-minimax.svg | 11 +- .../Resources/ar.lproj/Localizable.strings | 14 + .../Resources/ca.lproj/Localizable.strings | 14 + .../Resources/de.lproj/Localizable.strings | 14 + .../Resources/en.lproj/Localizable.strings | 14 + .../Resources/es.lproj/Localizable.strings | 14 + .../Resources/fa.lproj/Localizable.strings | 14 + .../Resources/fr.lproj/Localizable.strings | 14 + .../Resources/gl.lproj/Localizable.strings | 14 + .../Resources/id.lproj/Localizable.strings | 14 + .../Resources/it.lproj/Localizable.strings | 14 + .../Resources/ja.lproj/Localizable.strings | 14 + .../Resources/ko.lproj/Localizable.strings | 14 + .../Resources/nl.lproj/Localizable.strings | 14 + .../Resources/pl.lproj/Localizable.strings | 14 + .../Resources/pt-BR.lproj/Localizable.strings | 14 + .../Resources/ru.lproj/Localizable.strings | 14 + .../Resources/sv.lproj/Localizable.strings | 14 + .../Resources/th.lproj/Localizable.strings | 14 + .../Resources/tr.lproj/Localizable.strings | 14 + .../Resources/uk.lproj/Localizable.strings | 14 + .../Resources/vi.lproj/Localizable.strings | 14 + .../zh-Hans.lproj/Localizable.strings | 16 +- .../zh-Hant.lproj/Localizable.strings | 14 + Sources/CodexBar/SpendClientsView.swift | 366 ++++++++-- .../SpendDashboardModel+ChartDomain.swift | 145 ++++ .../SpendDashboardModel+Evidence.swift | 78 +++ Sources/CodexBar/SpendDashboardModel.swift | 134 ++-- .../CodexBar/UsageStore+QuotaWarnings.swift | 5 +- .../UsageStore+SessionQuotaTransition.swift | 4 +- .../Generated/CodexParserHash.generated.swift | 2 +- .../AntigravityProviderDescriptor.swift | 1 - .../AntigravitySessionScanner.swift | 71 +- .../Claude/ClaudeProviderDescriptor.swift | 1 - .../Gemini/GeminiProviderDescriptor.swift | 1 - .../Kimi/KimiProviderDescriptor.swift | 1 - .../MiniMax/MiniMaxProviderDescriptor.swift | 1 - .../Moonshot/MoonshotProviderDescriptor.swift | 1 - .../CostUsage/CostUsagePricing+Google.swift | 145 ++++ .../AntigravitySessionScannerTests.swift | 2 + .../CodexBarTests/CostUsagePricingTests.swift | 41 ++ .../ProviderIconResourcesTests.swift | 23 +- .../SpendChartDayHitTargetTests.swift | 111 ++++ .../SpendDashboardModelTests.swift | 122 +++- .../SpendModelsPresentationTests.swift | 125 ++++ .../SpendToolPresentationTests.swift | 49 +- design-qa.md | 255 ++++++- 56 files changed, 2499 insertions(+), 613 deletions(-) create mode 100644 Sources/CodexBar/Resources/ProviderIcon-antigravity.svg create mode 100644 Sources/CodexBar/Resources/ProviderIcon-claude.svg create mode 100644 Sources/CodexBar/Resources/ProviderIcon-gemini.svg create mode 100644 Sources/CodexBar/Resources/ProviderIcon-kimi.svg create mode 100644 Sources/CodexBar/SpendDashboardModel+ChartDomain.swift create mode 100644 Sources/CodexBar/SpendDashboardModel+Evidence.swift create mode 100644 Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+Google.swift create mode 100644 Tests/CodexBarTests/SpendChartDayHitTargetTests.swift diff --git a/Sources/CodexBar/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift index b22eeac54e..cb3997be21 100644 --- a/Sources/CodexBar/PreferencesSpendDashboardPane.swift +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -187,7 +187,7 @@ struct SpendDashboardPane: View { Image(systemName: "lock.shield.fill") .foregroundStyle(.secondary) Text(L("Native currencies stay separate; Codex account rows exclude Pi session history.")) - .font(.caption) + .font(SpendModelsListStyle.secondaryFont) .foregroundStyle(.secondary) Spacer() Toggle(L("Track costs"), isOn: self.$settings.costUsageEnabled) @@ -290,7 +290,7 @@ private struct SpendCurrencySection: View { VStack(alignment: .leading, spacing: 12) { HStack(alignment: .firstTextBaseline) { Text(self.group.currencyCode) - .font(.headline) + .font(SpendModelsListStyle.sectionTitleFont) Spacer() Text(self.group.totalCost.map { UsageFormatter.currencyString($0, currencyCode: self.group.currencyCode) @@ -304,7 +304,7 @@ private struct SpendCurrencySection: View { spendDashboardCoverageText( covered: self.group.coveredDayCount, requested: self.requestedDays)) - .font(.caption) + .font(SpendModelsListStyle.secondaryFont) .foregroundStyle(.secondary) SpendDashboardPanel { @@ -348,7 +348,7 @@ private struct SpendSummaryValue: View { var body: some View { VStack(alignment: .leading, spacing: 5) { Text(self.title) - .font(.caption) + .font(SpendModelsListStyle.secondaryFont) .foregroundStyle(.secondary) Text(self.value) .font(.system(.title2, design: .rounded, weight: .semibold)) @@ -364,37 +364,42 @@ private struct SpendProviderPanel: View { var body: some View { SpendDashboardPanel { VStack(alignment: .leading, spacing: 0) { - Text(L("By subscription")).font(.headline).padding(.bottom, 8) + Text(L("By subscription")) + .font(SpendModelsListStyle.sectionTitleFont) + .padding(.bottom, 8) ForEach(self.group.providers) { row in if row.rank > 1 { Divider() } HStack(spacing: 10) { Text(spendDashboardRankText(row.rank)) - .font(.caption.monospacedDigit()) + .font(SpendModelsListStyle.tertiaryFont.monospacedDigit()) .foregroundStyle(.tertiary) .frame(width: 26, alignment: .leading) - SpendProviderIcon(provider: row.provider) - VStack(alignment: .leading, spacing: 2) { - Text(row.displayName) + SpendProviderIcon(provider: row.provider, size: SpendModelsListStyle.iconSize) + .frame(width: SpendModelsListStyle.iconFrameSize) + Text(row.displayName) + .font(SpendModelsListStyle.controlFont) + .lineLimit(1) + .layoutPriority(1) + if let subscriptionName = row.subscriptionName + ?? self.subscriptionNames[row.id] + { + Text("· \(subscriptionName)") + .font(SpendModelsListStyle.tertiaryFont) + .foregroundStyle(.secondary) .lineLimit(1) - if let subscriptionName = row.subscriptionName - ?? self.subscriptionNames[row.id] - { - Text(subscriptionName) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } + .truncationMode(.tail) } - Spacer() + Spacer(minLength: 12) Text(row.totalCost.map { UsageFormatter.currencyString($0, currencyCode: self.group.currencyCode) } ?? L("Spend unavailable")) + .font(SpendModelsListStyle.controlFont) .foregroundStyle(row.totalCost == nil ? .secondary : .primary) .monospacedDigit() } - .padding(.vertical, 9) + .padding(.vertical, 5) } } } @@ -435,6 +440,7 @@ struct SpendDailyChartPresentation: Equatable { private struct SpendDailyChart: View { let group: SpendDashboardModel.CurrencyGroup @State private var selectedDay: Date? + @State private var pinnedDay: Date? @State private var cachedDays: [Date] = [] @State private var cachedDetails: [Date: SpendDashboardModel.DailySpendDetail] = [:] @@ -443,15 +449,16 @@ private struct SpendDailyChart: View { dailyPoints: self.group.dailyPoints, aggregateTotal: self.group.totalCost) SpendDashboardPanel { - VStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading, spacing: 12) { HStack(alignment: .firstTextBaseline) { VStack(alignment: .leading, spacing: 3) { - Text(L("Daily estimated spend")).font(.headline) + Text(L("Daily estimated spend")) + .font(SpendModelsListStyle.sectionTitleFont) if presentation.content == .chart { Text( "\(L("Active")) \(codexBarLocalizedInteger(presentation.dayCount)) · " + "\(L("Total")) \(self.totalCostText)") - .font(.caption) + .font(SpendModelsListStyle.secondaryFont) .foregroundStyle(.secondary) } } @@ -459,9 +466,14 @@ private struct SpendDailyChart: View { } if presentation.content == .unavailable { ContentUnavailableView(L("Spend unavailable"), systemImage: "chart.bar.xaxis") - .frame(maxWidth: .infinity, minHeight: 170) + .frame(maxWidth: .infinity, minHeight: SpendModelsListStyle.compactChartHeight) } else { Chart { + if let interactionDay = self.pinnedDay ?? self.selectedDay { + RuleMark(x: .value(L("Day"), interactionDay, unit: .day)) + .foregroundStyle(Color.accentColor.opacity(0.09)) + .lineStyle(StrokeStyle(lineWidth: 18)) + } ForEach(self.group.dailyPoints) { point in BarMark( x: .value(L("Day"), point.day, unit: .day), @@ -474,6 +486,11 @@ private struct SpendDailyChart: View { point.cost, currencyCode: self.group.currencyCode))) } + if let pinnedDay { + RuleMark(x: .value(L("Day"), pinnedDay, unit: .day)) + .foregroundStyle(Color.accentColor.opacity(0.72)) + .lineStyle(StrokeStyle(lineWidth: 2)) + } if let selectedDay { RuleMark(x: .value(L("Day"), selectedDay, unit: .day)) .foregroundStyle(.clear) @@ -499,6 +516,7 @@ private struct SpendDailyChart: View { AxisValueLabel { if let date = value.as(Date.self) { Text(date.formatted(self.axisDateFormat)) + .font(SpendModelsListStyle.secondaryFont) } } } @@ -512,6 +530,7 @@ private struct SpendDailyChart: View { Text(UsageFormatter.compactCurrencyString( amount, currencyCode: self.group.currencyCode)) + .font(SpendModelsListStyle.secondaryFont) } } } @@ -521,7 +540,7 @@ private struct SpendDailyChart: View { .background(Color.primary.opacity(0.018)) .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) } - .frame(height: 188) + .frame(height: SpendModelsListStyle.compactChartHeight) .accessibilityLabel(L("Daily estimated spend")) .accessibilityValue(presentation.accessibilityValue) .chartOverlay { proxy in @@ -530,34 +549,44 @@ private struct SpendDailyChart: View { onMoved: { location in self.updateSelectedDay(location: location, proxy: proxy, geo: geo) }, - onClicked: { _ in }, - onEscape: { self.selectedDay = nil }) + onClicked: { location in + self.handleChartClick(location: location, proxy: proxy, geo: geo) + }, + onDragged: { location in + self.handleChartDrag(location: location, proxy: proxy, geo: geo) + }, + onEscape: { + self.selectedDay = nil + self.pinnedDay = nil + }) .frame(maxWidth: .infinity, maxHeight: .infinity) } } LazyVGrid( - columns: [GridItem(.adaptive(minimum: 118), spacing: 12, alignment: .leading)], + columns: [GridItem(.adaptive(minimum: 156), spacing: 12, alignment: .leading)], alignment: .leading, - spacing: 7) + spacing: 6) { ForEach(presentation.series, id: \.name) { series in - HStack(spacing: 7) { - SpendProviderIcon(provider: series.provider, size: 13) - .frame(width: 20, height: 20) - VStack(alignment: .leading, spacing: 1) { - Text(series.name) - .font(.caption) + HStack(spacing: 6) { + SpendProviderIcon(provider: series.provider, size: 14) + .frame(width: 18, height: 18) + Text(series.name) + .font(SpendModelsListStyle.controlFont) + .lineLimit(1) + if let kind = self.toolKind(for: series.name) { + Text(kind.displayName) + .font(SpendModelsListStyle.tertiaryFont) + .foregroundStyle(.secondary) .lineLimit(1) - if let kind = self.toolKind(for: series.name) { - Text(kind.displayName) - .font(.caption2) - .foregroundStyle(.secondary) - } } } } } + if let detail = self.pinnedDetail { + self.dayDetail(detail) + } } } } @@ -626,6 +655,32 @@ private struct SpendDailyChart: View { self.selectedDay = self.nearestDay(to: date) } + private func handleChartClick(location: CGPoint, proxy: ChartProxy, geo: GeometryProxy) { + guard let day = self.chartDay(at: location, proxy: proxy, geo: geo) else { return } + self.pinnedDay = self.pinnedDay == day ? nil : day + } + + private func handleChartDrag(location: CGPoint, proxy: ChartProxy, geo: GeometryProxy) { + guard let day = self.chartDay(at: location, proxy: proxy, geo: geo) else { return } + self.pinnedDay = day + } + + private func chartDay(at location: CGPoint, proxy: ChartProxy, geo: GeometryProxy) -> Date? { + guard let plotAnchor = proxy.plotFrame else { return nil } + let plotFrame = geo[plotAnchor] + guard plotFrame.contains(location) else { return nil } + return SpendChartDayHitTarget.nearestDay( + toX: location.x - plotFrame.origin.x, + days: self.cachedDays, + position: { proxy.position(forX: $0) }) + } + + private var pinnedDetail: SpendDashboardModel.DailySpendDetail? { + guard let pinnedDay else { return nil } + return self.cachedDetails[Calendar.current.startOfDay(for: pinnedDay)] + ?? self.cachedDetails[pinnedDay] + } + private func nearestDay(to date: Date) -> Date? { let days = self.cachedDays guard !days.isEmpty else { return nil } @@ -658,53 +713,112 @@ private struct SpendDailyChart: View { private func dayTooltip(_ day: Date) -> some View { let detail = self.cachedDetails[Calendar.current.startOfDay(for: day)] ?? self.cachedDetails[day] - return VStack(alignment: .leading, spacing: 7) { + return VStack(alignment: .leading, spacing: 6) { if let detail { - HStack { - Text(day.formatted( - .dateTime.month(.abbreviated).day().locale(codexBarLocalizedLocale()))) - .font(.body.weight(.semibold)) - Spacer(minLength: 18) - Text(UsageFormatter.currencyString(detail.totalCost, currencyCode: self.group.currencyCode)) - .font(.body.weight(.semibold)) - .monospacedDigit() + Text(day.formatted( + .dateTime.month(.abbreviated).day().locale(codexBarLocalizedLocale()))) + .font(SpendModelsListStyle.tooltipTitleFont) + HStack(spacing: 14) { + self.tooltipSummary( + title: L("Tokens"), + value: detail.totalTokens.map(UsageFormatter.tokenCountString) ?? "—") + self.tooltipSummary( + title: L("Estimated spend"), + value: UsageFormatter.currencyString( + detail.totalCost, + currencyCode: self.group.currencyCode)) } - ForEach(detail.tools.prefix(5)) { tool in - HStack(alignment: .top, spacing: 8) { - SpendProviderIcon(provider: tool.provider, size: 15) - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 6) { - Text(tool.displayName) - Text(tool.kind.displayName) - .font(.caption2.weight(.medium)) + } + } + .padding(10) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 9, style: .continuous)) + .shadow(color: .black.opacity(0.09), radius: 10, y: 3) + } + + private func tooltipSummary(title: String, value: String) -> some View { + VStack(alignment: .leading, spacing: 1) { + Text(title) + .font(SpendModelsListStyle.tertiaryFont) + .foregroundStyle(.secondary) + Text(value) + .font(SpendModelsListStyle.tooltipRowFont.weight(.medium)) + .monospacedDigit() + } + } + + private func dayDetail(_ detail: SpendDashboardModel.DailySpendDetail) -> some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .firstTextBaseline) { + Text(detail.day.formatted( + .dateTime.month(.abbreviated).day().locale(codexBarLocalizedLocale()))) + .font(SpendModelsListStyle.primaryEmphasizedFont) + Spacer() + if let tokens = detail.totalTokens { + Text(UsageFormatter.tokenCountString(tokens)) + .font(SpendModelsListStyle.primaryFont) + .foregroundStyle(.secondary) + } + Text(UsageFormatter.currencyString(detail.totalCost, currencyCode: self.group.currencyCode)) + .font(SpendModelsListStyle.primaryEmphasizedFont) + } + .monospacedDigit() + + ForEach(detail.tools) { tool in + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 7) { + SpendProviderIcon(provider: tool.provider, size: SpendModelsListStyle.iconSize) + .frame( + width: SpendModelsListStyle.iconFrameSize, + height: SpendModelsListStyle.iconFrameSize) + Text(tool.displayName) + .font(SpendModelsListStyle.toolTitleFont) + Text(tool.kind.displayName) + .font(SpendModelsListStyle.tertiaryFont.weight(.medium)) + .foregroundStyle(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.secondary.opacity(0.12), in: Capsule()) + Spacer() + Text(UsageFormatter.currencyString(tool.cost, currencyCode: self.group.currencyCode)) + .font(SpendModelsListStyle.primaryFont) + .monospacedDigit() + } + VStack(alignment: .leading, spacing: 1) { + ForEach(Array(tool.models.enumerated()), id: \.element.id) { index, model in + if index > 0 { Divider().padding(.vertical, 2) } + HStack(spacing: 8) { + SpendProviderIcon( + provider: model.modelProvider, + size: SpendModelsListStyle.modelIconSize) + .frame( + width: SpendModelsListStyle.modelIconFrameSize, + height: SpendModelsListStyle.modelIconFrameSize) + Text(model.displayName) + .font(SpendModelsListStyle.primaryFont) + .lineLimit(1) + Spacer() + Text(model.cost.map { + UsageFormatter.currencyString($0, currencyCode: self.group.currencyCode) + } ?? model.tokens.map(UsageFormatter.tokenCountString) ?? "—") + .font(SpendModelsListStyle.secondaryFont) .foregroundStyle(.secondary) + .monospacedDigit() } - ForEach(tool.models.prefix(3)) { model in - HStack(spacing: 5) { - SpendProviderIcon(provider: model.modelProvider, size: 11) - Text(model.displayName) - Spacer(minLength: 8) - Text(model.cost.map { - UsageFormatter.currencyString($0, currencyCode: self.group.currencyCode) - } ?? model.tokens.map(UsageFormatter.tokenCountString) ?? "—") - .monospacedDigit() - } - .font(.caption) - .foregroundStyle(.secondary) - } + .padding(.vertical, 2) } - Spacer(minLength: 10) - Text(UsageFormatter.currencyString(tool.cost, currencyCode: self.group.currencyCode)) - .monospacedDigit() } - .font(.body) + .padding(.leading, SpendModelsListStyle.modelIndent) } + .padding(10) + .background( + Color.secondary.opacity(0.055), + in: RoundedRectangle(cornerRadius: 9, style: .continuous)) } } .padding(11) - .frame(minWidth: 260) - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) - .shadow(color: .black.opacity(0.10), radius: 10, y: 3) + .background( + Color.secondary.opacity(0.05), + in: RoundedRectangle(cornerRadius: 10, style: .continuous)) } } @@ -718,10 +832,10 @@ private struct SpendRefreshFailureNotice: View { .foregroundStyle(.orange) VStack(alignment: .leading, spacing: 2) { Text(spendDashboardRefreshFailureText(self.sourceNames.count)) - .font(.caption.weight(.semibold)) + .font(SpendModelsListStyle.secondaryFont.weight(.semibold)) if !self.sourceNames.isEmpty { Text(self.sourceNames.joined(separator: " · ")) - .font(.caption) + .font(SpendModelsListStyle.secondaryFont) .foregroundStyle(.secondary) } } diff --git a/Sources/CodexBar/PreferencesSpendModelsDayDetailView.swift b/Sources/CodexBar/PreferencesSpendModelsDayDetailView.swift index 404b81a883..1e28429553 100644 --- a/Sources/CodexBar/PreferencesSpendModelsDayDetailView.swift +++ b/Sources/CodexBar/PreferencesSpendModelsDayDetailView.swift @@ -55,6 +55,7 @@ struct SpendModelsDayDetailPresentation: Equatable { struct Model: Identifiable, Equatable { let id: String let name: String + let modelProvider: UsageProvider let providerNames: [String] let totalTokens: Int? let cost: Double? @@ -71,6 +72,10 @@ struct SpendModelsDayDetailPresentation: Equatable { /// Per-model rows sorted by tokens descending. let models: [Model] + var pricedModelCount: Int { + self.models.count(where: { $0.cost != nil }) + } + init?( analysis: SpendDashboardModel.ModelAnalysis, day: Date, @@ -92,6 +97,8 @@ struct SpendModelsDayDetailPresentation: Equatable { return Model( id: value.modelID, name: value.modelName, + modelProvider: row?.modelProvider + ?? SpendProviderIdentity.modelProvider(rawName: value.modelName, fallback: .openai), providerNames: row?.providerNames ?? [], totalTokens: value.totalTokens, cost: value.estimatedCost, @@ -172,28 +179,57 @@ func spendModelsDayDetailModelSplitText(_ model: SpendModelsDayDetailPresentatio return model.buckets.map(spendModelsDayDetailBucketText).joined(separator: " · ") } +func spendModelsDayDetailModelSummaryText( + _ model: SpendModelsDayDetailPresentation.Model, + metric: SpendModelMetric, + totalTokens: Int?, + totalCost: Double?, + currencyCode: String = "USD") -> String +{ + switch metric { + case .tokens: + guard let tokens = model.totalTokens else { return "—" } + let value = UsageFormatter.tokenCountString(tokens) + guard let totalTokens, totalTokens > 0 else { return value } + let share = UsageFormatter.percentString(Double(tokens) / Double(totalTokens) * 100) + return "\(value) · \(share)" + case .estimatedSpend: + guard let cost = model.cost else { + guard let tokens = model.totalTokens else { return L("Unavailable") } + return "\(UsageFormatter.tokenCountString(tokens)) · \(L("Unavailable"))" + } + let value = UsageFormatter.currencyString(cost, currencyCode: currencyCode) + guard let totalCost, totalCost > 0 else { return value } + let share = UsageFormatter.percentString(cost / totalCost * 100) + return "\(value) · \(share)" + } +} + // MARK: - Day detail view struct SpendModelsDayDetailView: View { let detail: SpendModelsDayDetailPresentation let metric: SpendModelMetric - let colorForModel: (String) -> Color + @State private var expandedModelID: String? var body: some View { VStack(alignment: .leading, spacing: 10) { HStack(alignment: .firstTextBaseline) { Text(self.dayText) - .font(.body.weight(.semibold)) + .font(SpendModelsListStyle.primaryEmphasizedFont) Spacer() Text(self.totalText) - .font(.body.weight(.semibold)) + .font(SpendModelsListStyle.primaryEmphasizedFont) .monospacedDigit() } - if !self.detail.buckets.isEmpty { + if self.metric == .tokens, !self.detail.buckets.isEmpty { self.categoryBar self.legend + } else if self.metric == .estimatedSpend { + self.pricingCoverageBar + self.pricingCoverageLegend } - VStack(alignment: .leading, spacing: 7) { + VStack(alignment: .leading, spacing: 4) { ForEach(self.detail.models) { model in self.modelRow(model) } @@ -203,6 +239,12 @@ struct SpendModelsDayDetailView: View { .background(.quaternary.opacity(0.35), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) .accessibilityElement(children: .contain) .accessibilityLabel(Text(L("Usage details for %@", self.dayText))) + .onChange(of: self.detail.day) { _, _ in + self.expandedModelID = nil + } + .onChange(of: self.metric) { _, _ in + self.expandedModelID = nil + } } // MARK: Category bar @@ -234,7 +276,7 @@ struct SpendModelsDayDetailView: View { .frame(width: 8, height: 8) .accessibilityHidden(true) Text("\(bucket.kind.title) \(UsageFormatter.tokenCountString(bucket.tokens))") - .font(.caption) + .font(SpendModelsListStyle.secondaryFont) .foregroundStyle(.secondary) .monospacedDigit() .lineLimit(1) @@ -256,43 +298,115 @@ struct SpendModelsDayDetailView: View { return max(0, bucket.tokens - reasoning) } - // MARK: Model rows + // MARK: Pricing coverage - private func modelRow(_ model: SpendModelsDayDetailPresentation.Model) -> some View { - HStack(spacing: 9) { - RoundedRectangle(cornerRadius: 2, style: .continuous) - .fill(self.colorForModel(model.id)) - .frame(width: 10, height: 10) - .accessibilityHidden(true) - Text(model.name) - .font(.body) - .lineLimit(1) - if !model.providerNames.isEmpty { - Text(model.providerNames.joined(separator: " · ")) - .font(.caption) - .foregroundStyle(.tertiary) - .lineLimit(1) + private var pricingCoverageBar: some View { + GeometryReader { geo in + let total = max(1, self.detail.models.count) + let hasPriced = self.detail.pricedModelCount > 0 + let hasUnpriced = self.detail.pricedModelCount < total + let spacing: CGFloat = hasPriced && hasUnpriced ? 1 : 0 + let pricedWidth = (geo.size.width - spacing) + * CGFloat(self.detail.pricedModelCount) + / CGFloat(total) + HStack(spacing: 1) { + if hasPriced { + Rectangle() + .fill(Color.accentColor) + .frame(width: max(2, pricedWidth)) + } + if hasUnpriced { + Rectangle() + .fill(Color.secondary.opacity(0.16)) + } } - Spacer() - Text(spendModelsDayDetailModelSplitText(model)) - .font(.body) + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + } + .frame(height: 12) + .accessibilityHidden(true) + } + + private var pricingCoverageLegend: some View { + HStack(spacing: 5) { + Circle() + .fill(Color.accentColor) + .frame(width: 8, height: 8) + .accessibilityHidden(true) + Text("\(L("Priced model spend")) · \(self.detail.pricedModelCount)/\(self.detail.models.count)") + .font(SpendModelsListStyle.secondaryFont) .foregroundStyle(.secondary) .monospacedDigit() - if self.metric == .estimatedSpend { - Text(self.costText(for: model)) - .font(.body) + .lineLimit(1) + } + } + + // MARK: Model rows + + private func modelRow(_ model: SpendModelsDayDetailPresentation.Model) -> some View { + VStack(alignment: .leading, spacing: 3) { + Button { + guard !model.buckets.isEmpty else { return } + withAnimation(.easeInOut(duration: 0.16)) { + self.expandedModelID = self.expandedModelID == model.id ? nil : model.id + } + } label: { + HStack(spacing: 9) { + SpendProviderIcon(provider: model.modelProvider, size: SpendModelsListStyle.modelIconSize) + .frame( + width: SpendModelsListStyle.modelIconFrameSize, + height: SpendModelsListStyle.modelIconFrameSize) + .accessibilityHidden(true) + Text(model.name) + .font(SpendModelsListStyle.primaryFont) + .lineLimit(1) + if !model.providerNames.isEmpty { + Text(model.providerNames.joined(separator: " · ")) + .font(SpendModelsListStyle.secondaryFont) + .foregroundStyle(.tertiary) + .lineLimit(1) + } + Spacer(minLength: 10) + Text(spendModelsDayDetailModelSummaryText( + model, + metric: self.metric, + totalTokens: self.detail.totalTokens, + totalCost: self.detail.totalCost)) + .font(SpendModelsListStyle.secondaryFont) + .foregroundStyle(.secondary) + .monospacedDigit() + .lineLimit(1) + if !model.buckets.isEmpty { + Image(systemName: self.expandedModelID == model.id ? "chevron.up" : "chevron.down") + .font(SpendModelsListStyle.tertiaryFont.weight(.semibold)) + .foregroundStyle(.tertiary) + .accessibilityHidden(true) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(model.name) + .accessibilityValue(spendModelsDayDetailModelSummaryText( + model, + metric: self.metric, + totalTokens: self.detail.totalTokens, + totalCost: self.detail.totalCost)) + .accessibilityHint(model.buckets.isEmpty + ? "" + : (self.expandedModelID == model.id ? L("Collapse") : L("Expand"))) + + if self.expandedModelID == model.id, !model.buckets.isEmpty { + Text(spendModelsDayDetailModelSplitText(model)) + .font(SpendModelsListStyle.secondaryFont) .foregroundStyle(.secondary) .monospacedDigit() + .lineLimit(1) + .padding(.leading, SpendModelsListStyle.modelIconFrameSize + 9) + .transition(.opacity.combined(with: .move(edge: .top))) } } } - private func costText(for model: SpendModelsDayDetailPresentation.Model) -> String { - guard let cost = model.cost else { return "—" } - let formatted = UsageFormatter.currencyString(cost, currencyCode: "USD") - return model.costIsEstimated ? "\(formatted) · \(L("estimated"))" : formatted - } - // MARK: Header private var dayText: String { diff --git a/Sources/CodexBar/PreferencesSpendModelsView.swift b/Sources/CodexBar/PreferencesSpendModelsView.swift index 8f60ba85ed..b021fbcaf0 100644 --- a/Sources/CodexBar/PreferencesSpendModelsView.swift +++ b/Sources/CodexBar/PreferencesSpendModelsView.swift @@ -36,10 +36,22 @@ enum SpendModelsViewMode: String, CaseIterable, Identifiable { } enum SpendModelsListStyle { - static let iconSize: CGFloat = 20 + static let iconSize: CGFloat = 18 + static let iconFrameSize: CGFloat = 22 + static let modelIconSize: CGFloat = 16 + static let modelIconFrameSize: CGFloat = 20 + static let modelIndent: CGFloat = 48 + static let sectionTitleFont = Font.system(size: 15, weight: .semibold) + static let toolTitleFont = Font.system(size: 15, weight: .semibold) static let primaryFont = Font.system(size: 14) static let primaryEmphasizedFont = Font.system(size: 14, weight: .semibold) - static let secondaryFont = Font.system(size: 13) + static let valueFont = Font.system(size: 14, weight: .medium) + static let secondaryFont = Font.system(size: 12) + static let tertiaryFont = Font.system(size: 11) + static let controlFont = Font.system(size: 13, weight: .medium) + static let tooltipTitleFont = Font.system(size: 13, weight: .semibold) + static let tooltipRowFont = Font.system(size: 12) + static let compactChartHeight: CGFloat = 144 } enum SpendModelsEnglishFormatter { @@ -68,8 +80,51 @@ func spendModelsRowDetailText(_ row: SpendModelsPresentation.Row) -> String { return providers.isEmpty ? metric : "\(metric) · \(providers)" } +func spendModelsRankingValueText( + _ row: SpendModelsPresentation.Row, + metric: SpendModelMetric, + currencyCode: String = "USD") -> String +{ + switch metric { + case .tokens: + guard let tokens = row.source.totalTokens else { return "—" } + return UsageFormatter.tokenCountString(tokens) + case .estimatedSpend: + guard let cost = row.source.estimatedCost else { return "—" } + return UsageFormatter.currencyString(cost, currencyCode: currencyCode) + } +} + +func spendModelsRankingContextText( + _ row: SpendModelsPresentation.Row, + metric: SpendModelMetric, + currencyCode: String = "USD") -> String +{ + switch metric { + case .tokens: + guard let cost = row.source.estimatedCost else { return "" } + return UsageFormatter.currencyString(cost, currencyCode: currencyCode) + case .estimatedSpend: + guard let tokens = row.source.totalTokens else { return "" } + return L("%@ tokens", UsageFormatter.tokenCountString(tokens)) + } +} + +func spendModelsChartMetricText( + _ value: Double, + metric: SpendModelMetric, + currencyCode: String = "USD") -> String +{ + switch metric { + case .tokens: + UsageFormatter.tokenCountString(Int(value.rounded())) + case .estimatedSpend: + UsageFormatter.currencyString(value, currencyCode: currencyCode) + } +} + enum SpendModelsRanking { - static let collapsedRowLimit = 20 + static let collapsedRowLimit = 5 static func showsDisclosure(rowCount: Int) -> Bool { rowCount > self.collapsedRowLimit @@ -122,6 +177,21 @@ struct SpendModelsPresentation: Equatable { let coverage: SpendDashboardModel.ModelMetricCoverage let metricTotal: Double? + var dailyTotals: [Point] { + let pointsByDay = Dictionary(grouping: self.points, by: \.day) + return pointsByDay.keys.sorted().compactMap { day in + let total = pointsByDay[day, default: []].reduce(0.0) { $0 + $1.value } + guard total > 0 else { return nil } + return Point( + day: day, + seriesID: "daily-total", + seriesName: self.metric.title, + value: total, + stackStart: 0, + stackEnd: total) + } + } + init( analysis: SpendDashboardModel.ModelAnalysis, metric: SpendModelMetric) @@ -401,6 +471,15 @@ struct SpendModelsTokenChartPresentation: Equatable { let points: [Point] + var dailyTotals: [Point] { + let pointsByDay = Dictionary(grouping: self.points, by: \.day) + return pointsByDay.keys.sorted().compactMap { day in + let total = pointsByDay[day, default: []].reduce(0.0) { $0 + $1.value } + guard total > 0 else { return nil } + return Point(day: day, kind: nil, value: total, stackStart: 0, stackEnd: total) + } + } + init(analysis: SpendDashboardModel.ModelAnalysis) { let byDay = Dictionary(grouping: analysis.dailyValues, by: \.day) self.points = byDay.keys.sorted().flatMap { day in @@ -471,57 +550,64 @@ struct SpendModelsSection: View { /// Global dashboard range, passed read-only: the single top-level time-range picker drives all /// sections now, so this block no longer renders its own 7d/30d/All selector. let selectedDays: Int - @AppStorage("spendModelsMetric") private var selectedMetric: SpendModelMetric = .tokens - @AppStorage("spendModelsTrailingAverage") private var trailingAverage = false @AppStorage("spendModelsViewMode") private var viewMode: SpendModelsViewMode = .models + @AppStorage("spendModelsSortMetric") private var sortMetric: SpendModelMetric = .tokens @State private var selectedDay: Date? @State private var pinnedDay: Date? @State private var showsAllModels = false - @State private var cachedPresentation: SpendModelsPresentation? - @State private var cachedChartPresentation: SpendModelsPresentation? + @State private var cachedTokenPresentation: SpendModelsPresentation? + @State private var cachedSpendPresentation: SpendModelsPresentation? @State private var cachedTokenChartPresentation = SpendModelsTokenChartPresentation(analysis: .empty) - // Hover lookup caches (rebuilt alongside the presentations): a sorted unique-day list for the - // nearest-day snap, and points grouped by start-of-day for the tooltip. Both replace an - // O(points) scan with Calendar.isDate(inSameDayAs:) per hover tick. + // Hover lookup cache: a sorted unique-day list replaces an O(points) scan with + // Calendar.isDate(inSameDayAs:) on every pointer movement. @State private var cachedSortedDays: [Date] = [] - @State private var cachedPointsByDay: [Date: [SpendModelsPresentation.Point]] = [:] - /// Memoized presentations. Building these sorts + aggregates every model row, and the chart - /// variant also runs the trailing-average smoothing; recomputing them on every hover tick - /// (selectedDay changes re-evaluate body) is the hover lag. Cache and only rebuild when the - /// inputs change, never on hover. + /// Memoized presentation. Building this sorts and aggregates every model row; recomputing it + /// when selectedDay changes causes hover lag, so it is rebuilt only when the analysis changes. private var presentation: SpendModelsPresentation { - if let cached = self.cachedPresentation { return cached } - return SpendModelsPresentation(analysis: self.analysis, metric: self.selectedMetric) + if let cached = self.cachedTokenPresentation { return cached } + return SpendModelsPresentation(analysis: self.analysis, metric: .tokens) + } + + private var rankingPresentation: SpendModelsPresentation { + switch self.sortMetric { + case .tokens: + self.cachedTokenPresentation ?? SpendModelsPresentation(analysis: self.analysis, metric: .tokens) + case .estimatedSpend: + self.cachedSpendPresentation + ?? SpendModelsPresentation(analysis: self.analysis, metric: .estimatedSpend) + } } private var chartPresentation: SpendModelsPresentation { - if let cached = self.cachedChartPresentation { return cached } - let base = self.presentation - return self.trailingAverage ? base.applyingTrailingAverage() : base + self.rankingPresentation } - private func rebuildPresentations() { - let base = SpendModelsPresentation(analysis: self.analysis, metric: self.selectedMetric) - self.cachedPresentation = base - let chart = self.trailingAverage ? base.applyingTrailingAverage() : base - self.cachedChartPresentation = chart - let tokenChart = SpendModelsTokenChartPresentation(analysis: self.analysis) - self.cachedTokenChartPresentation = self.trailingAverage - ? tokenChart.applyingTrailingAverage() - : tokenChart - self.cachedSortedDays = Array(Set(chart.points.map(\.day))).sorted() - self.cachedPointsByDay = Dictionary(grouping: chart.points) { - Calendar.current.startOfDay(for: $0.day) + private var hasChartData: Bool { + switch self.sortMetric { + case .tokens: + !self.cachedTokenChartPresentation.points.isEmpty + case .estimatedSpend: + !self.chartPresentation.points.isEmpty } } + private func rebuildPresentations() { + let base = SpendModelsPresentation(analysis: self.analysis, metric: .tokens) + self.cachedTokenPresentation = base + self.cachedSpendPresentation = SpendModelsPresentation( + analysis: self.analysis, + metric: .estimatedSpend) + self.cachedTokenChartPresentation = SpendModelsTokenChartPresentation(analysis: self.analysis) + self.syncChartInteractionDays() + } + var body: some View { SpendDashboardPanel { VStack(alignment: .leading, spacing: 16) { HStack(alignment: .firstTextBaseline) { Text(L("Models")) - .font(.headline) + .font(SpendModelsListStyle.sectionTitleFont) Spacer() Picker(L("View"), selection: self.$viewMode) { ForEach(SpendModelsViewMode.allCases) { mode in @@ -530,82 +616,44 @@ struct SpendModelsSection: View { } .labelsHidden() .pickerStyle(.segmented) + .font(SpendModelsListStyle.controlFont) + .controlSize(.small) .fixedSize() } - HStack(spacing: 10) { - if self.viewMode == .models { - Picker(L("Metric"), selection: self.$selectedMetric) { - ForEach(SpendModelMetric.allCases) { metric in - Text(metric.title).tag(metric) - } - } - .labelsHidden() - .pickerStyle(.segmented) - .fixedSize() - Toggle(isOn: self.$trailingAverage) { - Text(L("7-day avg")) - .font(.callout) - .lineLimit(1) - .fixedSize() - } - .toggleStyle(.switch) - .controlSize(.small) - } - Spacer() - } if self.viewMode == .clients { SpendClientsView(analysis: self.analysis) - } else if self.chartPresentation.points.isEmpty { - if self.presentation.metric == .estimatedSpend { - Text(L("No priced model history")) - .foregroundStyle(.secondary) - .padding(.vertical, 10) - } else { - Text(L("No model-level history")) - .foregroundStyle(.secondary) - .padding(.vertical, 10) - } + } else if !self.hasChartData { + Text(L("No model-level history")) + .foregroundStyle(.secondary) + .padding(.vertical, 10) } else { self.chart - if self.selectedMetric == .tokens { - self.tokenLegend - } if let pinnedDay = self.pinnedDay, let detail = SpendModelsDayDetailPresentation( analysis: self.analysis, day: pinnedDay, - metric: self.selectedMetric) + metric: self.sortMetric) { - SpendModelsDayDetailView( - detail: detail, - metric: self.selectedMetric, - colorForModel: { _ in Color.accentColor }) + SpendModelsDayDetailView(detail: detail, metric: self.sortMetric) } self.ranking } if self.presentation.coverage == .partial { Text(L("Partial model history: incomplete source-days are excluded.")) - .font(.caption) + .font(SpendModelsListStyle.secondaryFont) .foregroundStyle(.tertiary) } if self.showsEstimatedCostFootnote { Text(L("Estimated costs are priced from local logs and may differ from provider bills.")) - .font(.caption) + .font(SpendModelsListStyle.secondaryFont) .foregroundStyle(.tertiary) } } } .onAppear { - if self.cachedPresentation == nil { self.rebuildPresentations() } + if self.cachedTokenPresentation == nil { self.rebuildPresentations() } } .onChange(of: self.analysis) { _, _ in self.rebuildPresentations() } - .onChange(of: self.selectedMetric) { _, _ in self.rebuildPresentations() } - .onChange(of: self.trailingAverage) { _, isOn in - // Day pinning is disabled while smoothing is on (documented tokens.ci behavior: - // the chart shows averages, so a raw per-day panel would be misleading). - if isOn { self.pinnedDay = nil } - self.rebuildPresentations() - } .onChange(of: self.chartPresentation.points) { _, points in guard let pinnedDay = self.pinnedDay else { return } if !Set(points.map(\.day)).contains(pinnedDay) { self.pinnedDay = nil } @@ -613,40 +661,46 @@ struct SpendModelsSection: View { } private var showsEstimatedCostFootnote: Bool { - self.presentation.metric == .estimatedSpend && - self.presentation.rows.contains { $0.value != nil && $0.source.costIsEstimated } + self.presentation.rows.contains { + $0.source.estimatedCost != nil && $0.source.costIsEstimated + } } private var chart: some View { Chart { - if self.selectedMetric == .tokens { - ForEach(self.cachedTokenChartPresentation.points) { point in + if let interactionDay = self.pinnedDay ?? self.selectedDay { + RuleMark(x: .value(L("Day"), interactionDay, unit: .day)) + .foregroundStyle(Color.accentColor.opacity(0.09)) + .lineStyle(StrokeStyle(lineWidth: 18)) + } + if self.sortMetric == .tokens { + ForEach(self.cachedTokenChartPresentation.dailyTotals) { point in BarMark( x: .value(L("Day"), point.day, unit: .day), - yStart: .value(self.chartPresentation.metric.title, point.stackStart), - yEnd: .value(self.chartPresentation.metric.title, point.stackEnd), + yStart: .value(L("Tokens"), point.stackStart), + yEnd: .value(L("Tokens"), point.stackEnd), width: .ratio(0.62)) - .foregroundStyle(point.kind?.color ?? Color.accentColor) - .accessibilityLabel(Text( - "\(point.kind?.title ?? L("Tokens")), \(self.dayText(point.day))")) + .foregroundStyle(Color.accentColor) + .accessibilityLabel(Text("\(L("Tokens")), \(self.dayText(point.day))")) .accessibilityValue(Text(self.metricText(point.value))) } } else { - ForEach(self.chartPresentation.points) { point in + ForEach(self.chartPresentation.dailyTotals) { point in BarMark( x: .value(L("Day"), point.day, unit: .day), - yStart: .value(self.chartPresentation.metric.title, point.stackStart), - yEnd: .value(self.chartPresentation.metric.title, point.stackEnd), + yStart: .value(L("Estimated spend"), point.stackStart), + yEnd: .value(L("Estimated spend"), point.stackEnd), width: .ratio(0.62)) .foregroundStyle(Color.accentColor) - .accessibilityLabel(Text("\(point.seriesName), \(self.dayText(point.day))")) + .accessibilityLabel(Text( + "\(point.seriesName), \(self.dayText(point.day))")) .accessibilityValue(Text(self.metricText(point.value))) } } if let pinnedDay = self.pinnedDay { RuleMark(x: .value(L("Day"), pinnedDay, unit: .day)) - .foregroundStyle(Color.secondary.opacity(0.4)) - .lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 3])) + .foregroundStyle(Color.accentColor.opacity(0.72)) + .lineStyle(StrokeStyle(lineWidth: 2)) } if let selectedDay { RuleMark(x: .value(L("Day"), selectedDay, unit: .day)) @@ -668,6 +722,7 @@ struct SpendModelsSection: View { if let date = value.as(Date.self) { AxisValueLabel(anchor: self.xAxisLabelAnchor(for: date)) { Text(self.dayText(date)) + .font(SpendModelsListStyle.secondaryFont) .foregroundStyle(.secondary) } } @@ -680,6 +735,7 @@ struct SpendModelsSection: View { AxisValueLabel { if let amount = value.as(Double.self) { Text(self.axisMetricText(amount)) + .font(SpendModelsListStyle.secondaryFont) .foregroundStyle(.secondary) } } @@ -690,7 +746,7 @@ struct SpendModelsSection: View { .background(Color.primary.opacity(0.018)) .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) } - .frame(height: 220) + .frame(height: SpendModelsListStyle.compactChartHeight) .accessibilityLabel(L("Models")) .accessibilityValue(self.chartAccessibilityValue) .chartOverlay { proxy in @@ -702,6 +758,9 @@ struct SpendModelsSection: View { onClicked: { location in self.handleChartClick(location: location, proxy: proxy, geo: geo) }, + onDragged: { location in + self.handleChartDrag(location: location, proxy: proxy, geo: geo) + }, onEscape: { self.pinnedDay = nil }) @@ -715,86 +774,93 @@ struct SpendModelsSection: View { } private var rankingContent: some View { - VStack(alignment: .leading, spacing: 8) { - ForEach(SpendModelsRanking.visibleRows(self.presentation.rows, showsAll: self.showsAllModels)) { row in - HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 4) { + HStack { + Spacer() + Picker(L("Models"), selection: self.$sortMetric) { + ForEach(SpendModelMetric.allCases) { metric in + Text(metric.title).tag(metric) + } + } + .labelsHidden() + .pickerStyle(.segmented) + .font(SpendModelsListStyle.controlFont) + .controlSize(.small) + .fixedSize() + .onChange(of: self.sortMetric) { _, _ in + self.showsAllModels = false + self.syncChartInteractionDays() + } + } + ForEach(SpendModelsRanking.visibleRows( + self.rankingPresentation.rows, + showsAll: self.showsAllModels)) + { row in + HStack(spacing: 8) { + Text("#\(row.rank)") + .font(SpendModelsListStyle.secondaryFont) + .foregroundStyle(.tertiary) + .monospacedDigit() + .frame(width: 28, alignment: .leading) SpendProviderIcon( provider: row.source.modelProvider, - size: SpendModelsListStyle.iconSize) - .frame(width: 26, height: 26) - VStack(alignment: .leading, spacing: 2) { - Text(row.source.displayName) - .font(SpendModelsListStyle.primaryFont) - .lineLimit(1) - Text(self.rowDetail(row)) + size: SpendModelsListStyle.modelIconSize) + .frame( + width: SpendModelsListStyle.modelIconFrameSize, + height: SpendModelsListStyle.modelIconFrameSize) + Text(row.source.displayName) + .font(SpendModelsListStyle.primaryFont) + .lineLimit(1) + .layoutPriority(1) + let context = spendModelsRankingContextText( + row, + metric: self.rankingPresentation.metric) + if !context.isEmpty { + Text("· \(context)") .font(SpendModelsListStyle.secondaryFont) .foregroundStyle(.secondary) .monospacedDigit() .lineLimit(1) } - Spacer() - Text(self.shareText(row.value)) - .font(SpendModelsListStyle.primaryFont.weight(.medium)) + Spacer(minLength: 12) + Text(spendModelsRankingValueText( + row, + metric: self.rankingPresentation.metric)) + .font(SpendModelsListStyle.valueFont) + Text("· \(self.shareText(row.value))") + .font(SpendModelsListStyle.secondaryFont) + .foregroundStyle(.secondary) .monospacedDigit() - .frame(width: 58, alignment: .trailing) + .fixedSize(horizontal: true, vertical: false) } + .padding(.vertical, 2) } - if SpendModelsRanking.showsDisclosure(rowCount: self.presentation.rows.count) { + if SpendModelsRanking.showsDisclosure(rowCount: self.rankingPresentation.rows.count) { Button { self.showsAllModels.toggle() } label: { - Text(self.showsAllModels - ? L("Show top 20") - : L("Show all %d models", self.presentation.rows.count)) - .font(.body) - .foregroundStyle(.secondary) - .padding(.vertical, 2) - .contentShape(Rectangle()) + HStack(spacing: 5) { + Text(self.showsAllModels + ? L("Collapse") + : L("Show all %d models", self.rankingPresentation.rows.count)) + Image(systemName: self.showsAllModels ? "chevron.up" : "chevron.down") + .font(SpendModelsListStyle.tertiaryFont.weight(.semibold)) + } + .font(SpendModelsListStyle.secondaryFont.weight(.medium)) + .foregroundStyle(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.secondary.opacity(0.08), in: Capsule()) + .contentShape(Rectangle()) } .buttonStyle(.plain) } } } - private var tokenLegend: some View { - HStack(spacing: 14) { - ForEach(SpendModelsDayDetailPresentation.BucketKind.allCases, id: \.rawValue) { kind in - HStack(spacing: 5) { - Circle() - .fill(kind.color) - .frame(width: 8, height: 8) - Text(kind.title) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - .accessibilityElement(children: .combine) - } - - private func rowDetail(_ row: SpendModelsPresentation.Row) -> String { - guard self.presentation.metric == .tokens else { - guard let value = row.value else { return "—" } - var parts = [self.metricText(value)] - if row.source.costIsEstimated { parts.append(L("estimated")) } - return parts.joined(separator: " · ") - } - guard let value = row.value else { return "—" } - let buckets = [ - (L("Input"), row.source.inputTokens), - (L("Output"), row.source.outputTokens), - (L("Cache read"), row.source.cacheReadTokens), - (L("Reasoning"), row.source.reasoningTokens), - ].compactMap { label, value in - value.map { "\(label) \(UsageFormatter.tokenCountString($0))" } - } - if !buckets.isEmpty { return buckets.joined(separator: " · ") } - return UsageFormatter.tokenCountString(Int(value.rounded())) - } - private func shareText(_ value: Double?) -> String { guard let value else { return "—" } - guard let total = self.presentation.metricTotal, total > 0 else { return "—" } + guard let total = self.rankingPresentation.metricTotal, total > 0 else { return "—" } return UsageFormatter.percentString(value / total * 100) } @@ -802,52 +868,20 @@ struct SpendModelsSection: View { let detail = SpendModelsDayDetailPresentation( analysis: self.analysis, day: day, - metric: self.selectedMetric) - let averagedPoints = (self.cachedPointsByDay[Calendar.current.startOfDay(for: day)] ?? []) - .sorted { $0.value > $1.value } - return VStack(alignment: .leading, spacing: 5) { - if self.trailingAverage { - Text("\(self.dayText(day)) · \(L("7-day avg"))") - .font(.body.weight(.semibold)) - ForEach(averagedPoints.prefix(6)) { point in - HStack(spacing: 7) { - Text(point.seriesName) - Spacer(minLength: 12) - Text(self.metricText(point.value)) - .monospacedDigit() - } - .font(.body) - } - } else if let detail { - HStack { - Text(self.dayText(day)) - .font(.body.weight(.semibold)) - Spacer() - Text(self.selectedMetric == .tokens - ? detail.totalTokens.map(UsageFormatter.tokenCountString) ?? "—" - : detail.totalCost.map { + metric: self.sortMetric) + return VStack(alignment: .leading, spacing: 7) { + if let detail { + Text(self.dayText(day)) + .font(SpendModelsListStyle.tooltipTitleFont) + HStack(spacing: 14) { + self.tooltipSummary( + title: L("Tokens"), + value: detail.totalTokens.map(UsageFormatter.tokenCountString) ?? "—") + self.tooltipSummary( + title: L("Estimated spend"), + value: detail.totalCost.map { UsageFormatter.currencyString($0, currencyCode: "USD") } ?? "—") - .font(.body.weight(.semibold)) - .monospacedDigit() - } - ForEach(detail.models.prefix(6)) { model in - HStack(spacing: 7) { - SpendProviderIcon( - provider: SpendProviderIdentity.modelProvider( - rawName: model.name, - fallback: .openai), - size: 14) - Text(model.name) - Spacer(minLength: 12) - Text(self.selectedMetric == .tokens - ? spendModelsDayDetailModelSplitText(model) - : model.cost.map { - UsageFormatter.currencyString($0, currencyCode: "USD") - } ?? "—") - .monospacedDigit() - } - .font(.body) } } } @@ -856,6 +890,17 @@ struct SpendModelsSection: View { .shadow(color: .black.opacity(0.09), radius: 10, y: 3) } + private func tooltipSummary(title: String, value: String) -> some View { + VStack(alignment: .leading, spacing: 1) { + Text(title) + .font(SpendModelsListStyle.tertiaryFont) + .foregroundStyle(.secondary) + Text(value) + .font(SpendModelsListStyle.tooltipRowFont.weight(.medium)) + .monospacedDigit() + } + } + private var chartAccessibilityValue: String { let days = Set(self.chartPresentation.points.map(\.day)).count return L("%d days of usage data across %d models", days, self.chartPresentation.series.count) @@ -886,16 +931,21 @@ struct SpendModelsSection: View { } private func metricText(_ value: Double) -> String { - switch self.presentation.metric { - case .tokens: UsageFormatter.tokenCountString(Int(value.rounded())) - case .estimatedSpend: UsageFormatter.currencyString(value, currencyCode: "USD") - } + spendModelsChartMetricText(value, metric: self.sortMetric) } private func axisMetricText(_ value: Double) -> String { - switch self.presentation.metric { - case .tokens: self.metricText(value) - case .estimatedSpend: UsageFormatter.compactCurrencyString(value, currencyCode: "USD") + self.metricText(value) + } + + private func syncChartInteractionDays() { + let days = Array(Set(self.chartPresentation.points.map(\.day))).sorted() + self.cachedSortedDays = days + if let selectedDay = self.selectedDay, !days.contains(selectedDay) { + self.selectedDay = nil + } + if let pinnedDay = self.pinnedDay, !days.contains(pinnedDay) { + self.pinnedDay = nil } } @@ -913,47 +963,129 @@ struct SpendModelsSection: View { self.selectedDay = nil return } - let xInPlot = location.x - plotFrame.origin.x - guard let date: Date = proxy.value(atX: xInPlot) else { return } - self.selectedDay = self.nearestDay(to: date) - } - - /// Binary search over the cached sorted day list; avoids rebuilding a Set + linear scan each - /// hover tick. - private func nearestDay(to date: Date) -> Date? { - let days = self.cachedSortedDays - guard !days.isEmpty else { return nil } - var lo = 0, hi = days.count - while lo < hi { - let mid = (lo + hi) / 2 - if days[mid] < date { lo = mid + 1 } else { hi = mid } - } - if lo == 0 { return days[0] } - if lo == days.count { return days[days.count - 1] } - let before = days[lo - 1], after = days[lo] - return abs(before.timeIntervalSince(date)) <= abs(after.timeIntervalSince(date)) ? before : after + self.selectedDay = SpendChartDayHoverResolver.resolvedDay( + toX: location.x - plotFrame.origin.x, + days: self.cachedSortedDays, + currentDay: self.selectedDay, + position: { proxy.position(forX: $0) }) } - /// Clicking a bar day pins it (clicking the same day again clears); clicking empty space clears. - /// Pinning is disabled while the trailing average smooths the chart. + /// Clicking pins the nearest visible day inside a continuous screen-space lane. Adjacent days + /// meet at their midpoint, so thin bars never leave dead strips between them. private func handleChartClick(location: CGPoint, proxy: ChartProxy, geo: GeometryProxy) { - guard !self.trailingAverage else { return } - guard let plotAnchor = proxy.plotFrame else { + guard let day = self.chartDay(at: location, proxy: proxy, geo: geo) else { self.pinnedDay = nil return } + self.pinnedDay = self.pinnedDay == day ? nil : day + } + + private func handleChartDrag(location: CGPoint, proxy: ChartProxy, geo: GeometryProxy) { + guard let day = self.chartDay(at: location, proxy: proxy, geo: geo) else { return } + self.pinnedDay = day + } + + private func chartDay(at location: CGPoint, proxy: ChartProxy, geo: GeometryProxy) -> Date? { + guard let plotAnchor = proxy.plotFrame else { return nil } let plotFrame = geo[plotAnchor] - guard plotFrame.contains(location), - let date: Date = proxy.value(atX: location.x - plotFrame.origin.x) + guard plotFrame.contains(location) else { return nil } + return SpendChartDayHitTarget.nearestDay( + toX: location.x - plotFrame.origin.x, + days: self.cachedSortedDays, + position: { proxy.position(forX: $0) }) + } +} + +enum SpendChartDayHitTarget { + static let minimumOuterExtension: CGFloat = 14 + + static func nearestDay( + toX targetX: CGFloat, + days: [Date], + position: (Date) -> CGFloat?) -> Date? + { + let positioned = days.compactMap { day in + position(day).map { (day: day, x: $0) } + } + .sorted { $0.x < $1.x } + guard !positioned.isEmpty else { return nil } + + guard let nearestIndex = positioned.indices.min(by: { + abs(positioned[$0].x - targetX) < abs(positioned[$1].x - targetX) + }) else { return nil } + let nearest = positioned[nearestIndex] + let lowerBound: CGFloat + if nearestIndex > positioned.startIndex { + let previous = positioned[positioned.index(before: nearestIndex)] + lowerBound = (previous.x + nearest.x) / 2 + } else if positioned.count > 1 { + let next = positioned[positioned.index(after: nearestIndex)] + lowerBound = nearest.x - max(self.minimumOuterExtension, (next.x - nearest.x) / 2) + } else { + lowerBound = nearest.x - self.minimumOuterExtension + } + + let upperBound: CGFloat + if nearestIndex < positioned.index(before: positioned.endIndex) { + let next = positioned[positioned.index(after: nearestIndex)] + upperBound = (nearest.x + next.x) / 2 + } else if positioned.count > 1 { + let previous = positioned[positioned.index(before: nearestIndex)] + upperBound = nearest.x + max(self.minimumOuterExtension, (nearest.x - previous.x) / 2) + } else { + upperBound = nearest.x + self.minimumOuterExtension + } + return (lowerBound...upperBound).contains(targetX) ? nearest.day : nil + } +} + +enum SpendChartDayHoverResolver { + /// The pointer must move this far into the neighboring date lane before hover changes. + /// This makes dense vertical bars feel magnetized without slowing deliberate large moves. + static let hysteresisFraction: CGFloat = 0.12 + static let minimumHysteresis: CGFloat = 1.5 + static let maximumHysteresis: CGFloat = 8 + + static func resolvedDay( + toX targetX: CGFloat, + days: [Date], + currentDay: Date?, + position: (Date) -> CGFloat?) -> Date? + { + guard let candidate = SpendChartDayHitTarget.nearestDay( + toX: targetX, + days: days, + position: position) else { - self.pinnedDay = nil - return + return nil } - guard let day = self.nearestDay(to: date), abs(day.timeIntervalSince(date)) <= 43200 else { - self.pinnedDay = nil - return + guard let currentDay, currentDay != candidate else { return candidate } + + let positioned = days.compactMap { day in + position(day).map { (day: day, x: $0) } } - self.pinnedDay = self.pinnedDay == day ? nil : day + .sorted { $0.x < $1.x } + guard let currentIndex = positioned.firstIndex(where: { $0.day == currentDay }), + let candidateIndex = positioned.firstIndex(where: { $0.day == candidate }) + else { + return candidate + } + + // A fast move across multiple columns should catch up immediately. Hysteresis only calms + // the ambiguous boundary between neighboring vertical date lanes. + guard abs(candidateIndex - currentIndex) == 1 else { return candidate } + let current = positioned[currentIndex] + let next = positioned[candidateIndex] + let boundary = (current.x + next.x) / 2 + let gap = abs(next.x - current.x) + let hysteresis = min( + self.maximumHysteresis, + max(self.minimumHysteresis, gap * self.hysteresisFraction)) + + if candidateIndex > currentIndex { + return targetX >= boundary + hysteresis ? candidate : currentDay + } + return targetX <= boundary - hysteresis ? candidate : currentDay } } @@ -964,12 +1096,14 @@ struct SpendModelsSection: View { struct SpendModelsChartMouseReader: NSViewRepresentable { let onMoved: (CGPoint?) -> Void let onClicked: (CGPoint) -> Void + let onDragged: (CGPoint) -> Void let onEscape: () -> Void func makeNSView(context: Context) -> TrackingView { let view = TrackingView() view.onMoved = self.onMoved view.onClicked = self.onClicked + view.onDragged = self.onDragged view.onEscape = self.onEscape return view } @@ -977,12 +1111,14 @@ struct SpendModelsChartMouseReader: NSViewRepresentable { func updateNSView(_ nsView: TrackingView, context: Context) { nsView.onMoved = self.onMoved nsView.onClicked = self.onClicked + nsView.onDragged = self.onDragged nsView.onEscape = self.onEscape } final class TrackingView: NSView { var onMoved: ((CGPoint?) -> Void)? var onClicked: ((CGPoint) -> Void)? + var onDragged: ((CGPoint) -> Void)? var onEscape: (() -> Void)? private var trackingArea: NSTrackingArea? @@ -1037,6 +1173,17 @@ struct SpendModelsChartMouseReader: NSViewRepresentable { self.onClicked?(self.convert(event.locationInWindow, from: nil)) } + override func mouseDragged(with event: NSEvent) { + let location = self.convert(event.locationInWindow, from: nil) + self.onMoved?(location) + self.onDragged?(location) + } + + override func resetCursorRects() { + super.resetCursorRects() + self.addCursorRect(self.bounds, cursor: .pointingHand) + } + override func keyDown(with event: NSEvent) { if event.keyCode == 53 { // Escape self.onEscape?() diff --git a/Sources/CodexBar/ProviderBrandIcon.swift b/Sources/CodexBar/ProviderBrandIcon.swift index 5bdd4e2de1..34f054213d 100644 --- a/Sources/CodexBar/ProviderBrandIcon.swift +++ b/Sources/CodexBar/ProviderBrandIcon.swift @@ -31,7 +31,7 @@ enum ProviderBrandIcon { guard let bundle = self.resourceBundle else { return nil } - let extensions = branding.iconRenderingMode == .original ? ["png", "svg"] : ["svg", "png"] + let extensions = ["svg", "png"] guard let url = extensions.lazy.compactMap({ bundle.url(forResource: baseName, withExtension: $0) }).first else { @@ -42,7 +42,7 @@ enum ProviderBrandIcon { } image.size = self.size - image.isTemplate = branding.iconRenderingMode == .template + image.isTemplate = true self.cache[provider] = image return image } diff --git a/Sources/CodexBar/Resources/ProviderIcon-antigravity.svg b/Sources/CodexBar/Resources/ProviderIcon-antigravity.svg new file mode 100644 index 0000000000..a915939711 --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-antigravity.svg @@ -0,0 +1,3 @@ + + + diff --git a/Sources/CodexBar/Resources/ProviderIcon-claude.svg b/Sources/CodexBar/Resources/ProviderIcon-claude.svg new file mode 100644 index 0000000000..9f66bdbb44 --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-claude.svg @@ -0,0 +1,3 @@ + + + diff --git a/Sources/CodexBar/Resources/ProviderIcon-cursor.svg b/Sources/CodexBar/Resources/ProviderIcon-cursor.svg index de15ffc8ab..e97835e4d8 100644 --- a/Sources/CodexBar/Resources/ProviderIcon-cursor.svg +++ b/Sources/CodexBar/Resources/ProviderIcon-cursor.svg @@ -1,3 +1,3 @@ - - + + diff --git a/Sources/CodexBar/Resources/ProviderIcon-gemini.svg b/Sources/CodexBar/Resources/ProviderIcon-gemini.svg new file mode 100644 index 0000000000..869ae75bc3 --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-gemini.svg @@ -0,0 +1,3 @@ + + + diff --git a/Sources/CodexBar/Resources/ProviderIcon-kimi.svg b/Sources/CodexBar/Resources/ProviderIcon-kimi.svg new file mode 100644 index 0000000000..77cba2eacd --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-kimi.svg @@ -0,0 +1 @@ +Kimi diff --git a/Sources/CodexBar/Resources/ProviderIcon-minimax.svg b/Sources/CodexBar/Resources/ProviderIcon-minimax.svg index aee384af43..9055daed6a 100644 --- a/Sources/CodexBar/Resources/ProviderIcon-minimax.svg +++ b/Sources/CodexBar/Resources/ProviderIcon-minimax.svg @@ -1,10 +1 @@ - - - - - - - - - - +MiniMax diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index 9384a5a896..a6b5c71498 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -1393,3 +1393,17 @@ "Cumulative %@ tokens as of %@" = "%@ رمزًا تراكميًا حتى %@"; "%@ tokens in the week of %@" = "%@ رمزًا في أسبوع %@"; + +/* Tool comparison evidence (English fallback) */ +"Same-model comparison" = "Same-model comparison"; +"Observed history for the same model and time range; workload differences still apply." = "Observed history for the same model and time range; workload differences still apply."; +"Show fewer models" = "Show fewer models"; +"Reuse unavailable" = "Reuse unavailable"; +"%d models" = "%d models"; +"%d projects" = "%d projects"; +"%d sessions" = "%d sessions"; +"%d days" = "%d days"; +"%d days covered" = "%d days covered"; +"%d%% context reuse" = "%d%% context reuse"; +"%d%% reuse" = "%d%% reuse"; +"%@ per 1M tokens" = "%@ per 1M tokens"; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index 2a89c1d356..9707e297b4 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -1392,3 +1392,17 @@ "Cumulative %@ tokens as of %@" = "%@ tokens acumulats fins al %@"; "%@ tokens in the week of %@" = "%@ tokens la setmana del %@"; + +/* Tool comparison evidence (English fallback) */ +"Same-model comparison" = "Same-model comparison"; +"Observed history for the same model and time range; workload differences still apply." = "Observed history for the same model and time range; workload differences still apply."; +"Show fewer models" = "Show fewer models"; +"Reuse unavailable" = "Reuse unavailable"; +"%d models" = "%d models"; +"%d projects" = "%d projects"; +"%d sessions" = "%d sessions"; +"%d days" = "%d days"; +"%d days covered" = "%d days covered"; +"%d%% context reuse" = "%d%% context reuse"; +"%d%% reuse" = "%d%% reuse"; +"%@ per 1M tokens" = "%@ per 1M tokens"; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index 5e973bd3cd..6993313e92 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -1390,3 +1390,17 @@ "Cumulative %@ tokens as of %@" = "Kumulativ %@ Token bis %@"; "%@ tokens in the week of %@" = "%@ Token in der Woche vom %@"; + +/* Tool comparison evidence (English fallback) */ +"Same-model comparison" = "Same-model comparison"; +"Observed history for the same model and time range; workload differences still apply." = "Observed history for the same model and time range; workload differences still apply."; +"Show fewer models" = "Show fewer models"; +"Reuse unavailable" = "Reuse unavailable"; +"%d models" = "%d models"; +"%d projects" = "%d projects"; +"%d sessions" = "%d sessions"; +"%d days" = "%d days"; +"%d days covered" = "%d days covered"; +"%d%% context reuse" = "%d%% context reuse"; +"%d%% reuse" = "%d%% reuse"; +"%@ per 1M tokens" = "%@ per 1M tokens"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index f8b56f67e5..23a44d59a3 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -1392,6 +1392,20 @@ "Fr" = "Fr"; "Estimated" = "Estimated"; "No per-client model history" = "No per-client model history"; +"Same-model comparison" = "Same-model comparison"; +"Observed history for the same model and time range; workload differences still apply." = "Observed history for the same model and time range; workload differences still apply."; +"Show fewer models" = "Show fewer models"; +"Show all %d models" = "Show all %d models"; +"%d models" = "%d models"; +"%@ requests" = "%@ requests"; +"%d%% context reuse" = "%d%% context reuse"; +"%d projects" = "%d projects"; +"%d sessions" = "%d sessions"; +"%d days covered" = "%d days covered"; +"%d%% reuse" = "%d%% reuse"; +"Reuse unavailable" = "Reuse unavailable"; +"%@ per 1M tokens" = "%@ per 1M tokens"; +"%d days" = "%d days"; "Cumulative %@ tokens as of %@" = "Cumulative %@ tokens as of %@"; "%@ tokens in the week of %@" = "%@ tokens in the week of %@"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 4dceef36aa..9dc9574b14 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -1388,3 +1388,17 @@ "Cumulative %@ tokens as of %@" = "%@ tokens acumulados hasta el %@"; "%@ tokens in the week of %@" = "%@ tokens en la semana del %@"; + +/* Tool comparison evidence (English fallback) */ +"Same-model comparison" = "Same-model comparison"; +"Observed history for the same model and time range; workload differences still apply." = "Observed history for the same model and time range; workload differences still apply."; +"Show fewer models" = "Show fewer models"; +"Reuse unavailable" = "Reuse unavailable"; +"%d models" = "%d models"; +"%d projects" = "%d projects"; +"%d sessions" = "%d sessions"; +"%d days" = "%d days"; +"%d days covered" = "%d days covered"; +"%d%% context reuse" = "%d%% context reuse"; +"%d%% reuse" = "%d%% reuse"; +"%@ per 1M tokens" = "%@ per 1M tokens"; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index 44d6dc1628..480a68b2a8 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -1393,3 +1393,17 @@ "Cumulative %@ tokens as of %@" = "%@ توکن تجمعی تا %@"; "%@ tokens in the week of %@" = "%@ توکن در هفته %@"; + +/* Tool comparison evidence (English fallback) */ +"Same-model comparison" = "Same-model comparison"; +"Observed history for the same model and time range; workload differences still apply." = "Observed history for the same model and time range; workload differences still apply."; +"Show fewer models" = "Show fewer models"; +"Reuse unavailable" = "Reuse unavailable"; +"%d models" = "%d models"; +"%d projects" = "%d projects"; +"%d sessions" = "%d sessions"; +"%d days" = "%d days"; +"%d days covered" = "%d days covered"; +"%d%% context reuse" = "%d%% context reuse"; +"%d%% reuse" = "%d%% reuse"; +"%@ per 1M tokens" = "%@ per 1M tokens"; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index 8f84aaa2dc..a0de900271 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -1389,3 +1389,17 @@ "Cumulative %@ tokens as of %@" = "%@ tokens cumulés au %@"; "%@ tokens in the week of %@" = "%@ tokens sur la semaine du %@"; + +/* Tool comparison evidence (English fallback) */ +"Same-model comparison" = "Same-model comparison"; +"Observed history for the same model and time range; workload differences still apply." = "Observed history for the same model and time range; workload differences still apply."; +"Show fewer models" = "Show fewer models"; +"Reuse unavailable" = "Reuse unavailable"; +"%d models" = "%d models"; +"%d projects" = "%d projects"; +"%d sessions" = "%d sessions"; +"%d days" = "%d days"; +"%d days covered" = "%d days covered"; +"%d%% context reuse" = "%d%% context reuse"; +"%d%% reuse" = "%d%% reuse"; +"%@ per 1M tokens" = "%@ per 1M tokens"; diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings index 7883bdab5c..c21cadef13 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -1389,3 +1389,17 @@ "Cumulative %@ tokens as of %@" = "%@ tokens acumulados ata %@"; "%@ tokens in the week of %@" = "%@ tokens na semana do %@"; + +/* Tool comparison evidence (English fallback) */ +"Same-model comparison" = "Same-model comparison"; +"Observed history for the same model and time range; workload differences still apply." = "Observed history for the same model and time range; workload differences still apply."; +"Show fewer models" = "Show fewer models"; +"Reuse unavailable" = "Reuse unavailable"; +"%d models" = "%d models"; +"%d projects" = "%d projects"; +"%d sessions" = "%d sessions"; +"%d days" = "%d days"; +"%d days covered" = "%d days covered"; +"%d%% context reuse" = "%d%% context reuse"; +"%d%% reuse" = "%d%% reuse"; +"%@ per 1M tokens" = "%@ per 1M tokens"; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index 5b26543c10..31496ee3e0 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -1393,3 +1393,17 @@ "Cumulative %@ tokens as of %@" = "Kumulatif %@ token hingga %@"; "%@ tokens in the week of %@" = "%@ token dalam minggu %@"; + +/* Tool comparison evidence (English fallback) */ +"Same-model comparison" = "Same-model comparison"; +"Observed history for the same model and time range; workload differences still apply." = "Observed history for the same model and time range; workload differences still apply."; +"Show fewer models" = "Show fewer models"; +"Reuse unavailable" = "Reuse unavailable"; +"%d models" = "%d models"; +"%d projects" = "%d projects"; +"%d sessions" = "%d sessions"; +"%d days" = "%d days"; +"%d days covered" = "%d days covered"; +"%d%% context reuse" = "%d%% context reuse"; +"%d%% reuse" = "%d%% reuse"; +"%@ per 1M tokens" = "%@ per 1M tokens"; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index 895ada7916..765111c513 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -1393,3 +1393,17 @@ "Cumulative %@ tokens as of %@" = "%@ token cumulativi al %@"; "%@ tokens in the week of %@" = "%@ token nella settimana del %@"; + +/* Tool comparison evidence (English fallback) */ +"Same-model comparison" = "Same-model comparison"; +"Observed history for the same model and time range; workload differences still apply." = "Observed history for the same model and time range; workload differences still apply."; +"Show fewer models" = "Show fewer models"; +"Reuse unavailable" = "Reuse unavailable"; +"%d models" = "%d models"; +"%d projects" = "%d projects"; +"%d sessions" = "%d sessions"; +"%d days" = "%d days"; +"%d days covered" = "%d days covered"; +"%d%% context reuse" = "%d%% context reuse"; +"%d%% reuse" = "%d%% reuse"; +"%@ per 1M tokens" = "%@ per 1M tokens"; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index 146f0aaf79..1424dd2ee2 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -1390,3 +1390,17 @@ "Cumulative %@ tokens as of %@" = "%2$@ までの累計 %1$@ トークン"; "%@ tokens in the week of %@" = "%2$@ の週に %1$@ トークン"; + +/* Tool comparison evidence (English fallback) */ +"Same-model comparison" = "Same-model comparison"; +"Observed history for the same model and time range; workload differences still apply." = "Observed history for the same model and time range; workload differences still apply."; +"Show fewer models" = "Show fewer models"; +"Reuse unavailable" = "Reuse unavailable"; +"%d models" = "%d models"; +"%d projects" = "%d projects"; +"%d sessions" = "%d sessions"; +"%d days" = "%d days"; +"%d days covered" = "%d days covered"; +"%d%% context reuse" = "%d%% context reuse"; +"%d%% reuse" = "%d%% reuse"; +"%@ per 1M tokens" = "%@ per 1M tokens"; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index 7f631ddcab..821d273168 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -1357,3 +1357,17 @@ "Cumulative %@ tokens as of %@" = "%2$@ 기준 누적 %1$@ 토큰"; "%@ tokens in the week of %@" = "%2$@ 주에 %1$@ 토큰"; + +/* Tool comparison evidence (English fallback) */ +"Same-model comparison" = "Same-model comparison"; +"Observed history for the same model and time range; workload differences still apply." = "Observed history for the same model and time range; workload differences still apply."; +"Show fewer models" = "Show fewer models"; +"Reuse unavailable" = "Reuse unavailable"; +"%d models" = "%d models"; +"%d projects" = "%d projects"; +"%d sessions" = "%d sessions"; +"%d days" = "%d days"; +"%d days covered" = "%d days covered"; +"%d%% context reuse" = "%d%% context reuse"; +"%d%% reuse" = "%d%% reuse"; +"%@ per 1M tokens" = "%@ per 1M tokens"; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index 1aeec7e912..e9c5ab3843 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -1389,3 +1389,17 @@ "Cumulative %@ tokens as of %@" = "Cumulatief %@ tokens tot %@"; "%@ tokens in the week of %@" = "%@ tokens in de week van %@"; + +/* Tool comparison evidence (English fallback) */ +"Same-model comparison" = "Same-model comparison"; +"Observed history for the same model and time range; workload differences still apply." = "Observed history for the same model and time range; workload differences still apply."; +"Show fewer models" = "Show fewer models"; +"Reuse unavailable" = "Reuse unavailable"; +"%d models" = "%d models"; +"%d projects" = "%d projects"; +"%d sessions" = "%d sessions"; +"%d days" = "%d days"; +"%d days covered" = "%d days covered"; +"%d%% context reuse" = "%d%% context reuse"; +"%d%% reuse" = "%d%% reuse"; +"%@ per 1M tokens" = "%@ per 1M tokens"; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index b03911f7f2..1d9683f861 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -1393,3 +1393,17 @@ "Cumulative %@ tokens as of %@" = "Łącznie %@ tokenów do %@"; "%@ tokens in the week of %@" = "%@ tokenów w tygodniu od %@"; + +/* Tool comparison evidence (English fallback) */ +"Same-model comparison" = "Same-model comparison"; +"Observed history for the same model and time range; workload differences still apply." = "Observed history for the same model and time range; workload differences still apply."; +"Show fewer models" = "Show fewer models"; +"Reuse unavailable" = "Reuse unavailable"; +"%d models" = "%d models"; +"%d projects" = "%d projects"; +"%d sessions" = "%d sessions"; +"%d days" = "%d days"; +"%d days covered" = "%d days covered"; +"%d%% context reuse" = "%d%% context reuse"; +"%d%% reuse" = "%d%% reuse"; +"%@ per 1M tokens" = "%@ per 1M tokens"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index 7b541ea1df..cf4447167d 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -1390,3 +1390,17 @@ "Cumulative %@ tokens as of %@" = "%@ tokens acumulados até %@"; "%@ tokens in the week of %@" = "%@ tokens na semana de %@"; + +/* Tool comparison evidence (English fallback) */ +"Same-model comparison" = "Same-model comparison"; +"Observed history for the same model and time range; workload differences still apply." = "Observed history for the same model and time range; workload differences still apply."; +"Show fewer models" = "Show fewer models"; +"Reuse unavailable" = "Reuse unavailable"; +"%d models" = "%d models"; +"%d projects" = "%d projects"; +"%d sessions" = "%d sessions"; +"%d days" = "%d days"; +"%d days covered" = "%d days covered"; +"%d%% context reuse" = "%d%% context reuse"; +"%d%% reuse" = "%d%% reuse"; +"%@ per 1M tokens" = "%@ per 1M tokens"; diff --git a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings index 3f6993085a..4e47ba877b 100644 --- a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings @@ -1391,3 +1391,17 @@ "Cumulative %@ tokens as of %@" = "Всего %@ токенов на %@"; "%@ tokens in the week of %@" = "%@ токенов за неделю от %@"; + +/* Tool comparison evidence (English fallback) */ +"Same-model comparison" = "Same-model comparison"; +"Observed history for the same model and time range; workload differences still apply." = "Observed history for the same model and time range; workload differences still apply."; +"Show fewer models" = "Show fewer models"; +"Reuse unavailable" = "Reuse unavailable"; +"%d models" = "%d models"; +"%d projects" = "%d projects"; +"%d sessions" = "%d sessions"; +"%d days" = "%d days"; +"%d days covered" = "%d days covered"; +"%d%% context reuse" = "%d%% context reuse"; +"%d%% reuse" = "%d%% reuse"; +"%@ per 1M tokens" = "%@ per 1M tokens"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index 91124e64f5..1067f85de1 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -1388,3 +1388,17 @@ "Cumulative %@ tokens as of %@" = "Kumulativt %@ token till och med %@"; "%@ tokens in the week of %@" = "%@ token under veckan från %@"; + +/* Tool comparison evidence (English fallback) */ +"Same-model comparison" = "Same-model comparison"; +"Observed history for the same model and time range; workload differences still apply." = "Observed history for the same model and time range; workload differences still apply."; +"Show fewer models" = "Show fewer models"; +"Reuse unavailable" = "Reuse unavailable"; +"%d models" = "%d models"; +"%d projects" = "%d projects"; +"%d sessions" = "%d sessions"; +"%d days" = "%d days"; +"%d days covered" = "%d days covered"; +"%d%% context reuse" = "%d%% context reuse"; +"%d%% reuse" = "%d%% reuse"; +"%@ per 1M tokens" = "%@ per 1M tokens"; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index 4adbe95f5b..2268088538 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -1393,3 +1393,17 @@ "Cumulative %@ tokens as of %@" = "%@ โทเทนสะสมถึง %@"; "%@ tokens in the week of %@" = "%@ โทเทนในสัปดาห์ของ %@"; + +/* Tool comparison evidence (English fallback) */ +"Same-model comparison" = "Same-model comparison"; +"Observed history for the same model and time range; workload differences still apply." = "Observed history for the same model and time range; workload differences still apply."; +"Show fewer models" = "Show fewer models"; +"Reuse unavailable" = "Reuse unavailable"; +"%d models" = "%d models"; +"%d projects" = "%d projects"; +"%d sessions" = "%d sessions"; +"%d days" = "%d days"; +"%d days covered" = "%d days covered"; +"%d%% context reuse" = "%d%% context reuse"; +"%d%% reuse" = "%d%% reuse"; +"%@ per 1M tokens" = "%@ per 1M tokens"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index ac3fdecf6f..798e90e5b3 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -1391,3 +1391,17 @@ "Cumulative %@ tokens as of %@" = "%@ tarihine kadar toplam %@ token"; "%@ tokens in the week of %@" = "%@ haftasında %@ token"; + +/* Tool comparison evidence (English fallback) */ +"Same-model comparison" = "Same-model comparison"; +"Observed history for the same model and time range; workload differences still apply." = "Observed history for the same model and time range; workload differences still apply."; +"Show fewer models" = "Show fewer models"; +"Reuse unavailable" = "Reuse unavailable"; +"%d models" = "%d models"; +"%d projects" = "%d projects"; +"%d sessions" = "%d sessions"; +"%d days" = "%d days"; +"%d days covered" = "%d days covered"; +"%d%% context reuse" = "%d%% context reuse"; +"%d%% reuse" = "%d%% reuse"; +"%@ per 1M tokens" = "%@ per 1M tokens"; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index e1cd0c4e20..2ac37b785a 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -1389,3 +1389,17 @@ "Cumulative %@ tokens as of %@" = "Усього %@ токенів на %@"; "%@ tokens in the week of %@" = "%@ токенів за тиждень від %@"; + +/* Tool comparison evidence (English fallback) */ +"Same-model comparison" = "Same-model comparison"; +"Observed history for the same model and time range; workload differences still apply." = "Observed history for the same model and time range; workload differences still apply."; +"Show fewer models" = "Show fewer models"; +"Reuse unavailable" = "Reuse unavailable"; +"%d models" = "%d models"; +"%d projects" = "%d projects"; +"%d sessions" = "%d sessions"; +"%d days" = "%d days"; +"%d days covered" = "%d days covered"; +"%d%% context reuse" = "%d%% context reuse"; +"%d%% reuse" = "%d%% reuse"; +"%@ per 1M tokens" = "%@ per 1M tokens"; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index 0b2bdddc02..9d82857faa 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -1390,3 +1390,17 @@ "Cumulative %@ tokens as of %@" = "Tích lũy %@ token tính đến %@"; "%@ tokens in the week of %@" = "%@ token trong tuần từ %@"; + +/* Tool comparison evidence (English fallback) */ +"Same-model comparison" = "Same-model comparison"; +"Observed history for the same model and time range; workload differences still apply." = "Observed history for the same model and time range; workload differences still apply."; +"Show fewer models" = "Show fewer models"; +"Reuse unavailable" = "Reuse unavailable"; +"%d models" = "%d models"; +"%d projects" = "%d projects"; +"%d sessions" = "%d sessions"; +"%d days" = "%d days"; +"%d days covered" = "%d days covered"; +"%d%% context reuse" = "%d%% context reuse"; +"%d%% reuse" = "%d%% reuse"; +"%@ per 1M tokens" = "%@ per 1M tokens"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 2d3acffc84..010ea3c9f1 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -755,7 +755,7 @@ "Quit" = "退出"; "Last %d day" = "近 %d 天"; "Last %d days" = "近 %d 天"; -"%@ tokens" = "%@ token 用量"; +"%@ tokens" = "%@ 令牌"; "Latest billing day" = "最近结算日"; "Latest billing day (%@)" = "最近结算日(%@)"; "%@ left" = "%@ 剩余"; @@ -1363,6 +1363,20 @@ "Fr" = "五"; "Estimated" = "估算"; "No per-client model history" = "暂无按工具划分的模型历史"; +"Same-model comparison" = "同模型工具对比"; +"Observed history for the same model and time range; workload differences still apply." = "仅对比同一模型与时间范围内的历史表现;不同任务仍会影响结果。"; +"Show fewer models" = "收起模型"; +"Show all %d models" = "显示全部 %d 个模型"; +"%d models" = "%d 个模型"; +"%@ requests" = "%@ 次请求"; +"%d%% context reuse" = "%d%% 上下文复用"; +"%d projects" = "%d 个项目"; +"%d sessions" = "%d 个会话"; +"%d days covered" = "覆盖 %d 天"; +"%d%% reuse" = "%d%% 复用"; +"Reuse unavailable" = "复用数据不可用"; +"%@ per 1M tokens" = "每百万 Token %@"; +"%d days" = "%d 天"; "Cumulative %@ tokens as of %@" = "截至 %2$@ 累计 %1$@ 个 Token"; "%@ tokens in the week of %@" = "%2$@ 当周使用了 %1$@ 个 Token"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index c232b70aec..3152f2a15b 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -1421,3 +1421,17 @@ "Cumulative %@ tokens as of %@" = "截至 %2$@ 累計 %1$@ 個 Token"; "%@ tokens in the week of %@" = "%2$@ 當週使用了 %1$@ 個 Token"; + +/* Tool comparison evidence (English fallback) */ +"Same-model comparison" = "Same-model comparison"; +"Observed history for the same model and time range; workload differences still apply." = "Observed history for the same model and time range; workload differences still apply."; +"Show fewer models" = "Show fewer models"; +"Reuse unavailable" = "Reuse unavailable"; +"%d models" = "%d models"; +"%d projects" = "%d projects"; +"%d sessions" = "%d sessions"; +"%d days" = "%d days"; +"%d days covered" = "%d days covered"; +"%d%% context reuse" = "%d%% context reuse"; +"%d%% reuse" = "%d%% reuse"; +"%@ per 1M tokens" = "%@ per 1M tokens"; diff --git a/Sources/CodexBar/SpendClientsView.swift b/Sources/CodexBar/SpendClientsView.swift index 25f1b41a1e..de57d730ee 100644 --- a/Sources/CodexBar/SpendClientsView.swift +++ b/Sources/CodexBar/SpendClientsView.swift @@ -1,15 +1,28 @@ import CodexBarCore import SwiftUI +// MARK: - Helper + +private func cleanToolName(_ name: String) -> String { + var result = name + let suffixes = [" Desktop", " CLI", " IDE", " Extension", " API"] + for suffix in suffixes where result.hasSuffix(suffix) { + result = String(result.dropLast(suffix.count)) + } + return result +} + // MARK: - 按工具分组数据 /// A model's usage attributed to one tool (client). struct SpendClientModel: Identifiable, Equatable { let id: String let displayName: String + let modelProvider: UsageProvider let tokens: Int let cost: Double? let costIsEstimated: Bool + let requestCount: Int? } /// One tool (client) with its models, sorted by tokens descending. @@ -29,6 +42,10 @@ struct SpendClientGroup: Identifiable, Equatable { let cacheReadTokens: Int? let cacheCreationTokens: Int? let reasoningTokens: Int? + let requestCount: Int? + let coveredDayCount: Int + let projectCount: Int? + let sessionCount: Int? let models: [SpendClientModel] var id: String { @@ -65,6 +82,7 @@ enum SpendClientBreakdown { [:]) var accum = bucket.models[row.id] ?? Accum( displayName: row.displayName, + modelProvider: row.modelProvider, costIsEstimated: row.costIsEstimated) accum.tokens += tokens if let cost = contribution.estimatedCost { @@ -75,6 +93,10 @@ enum SpendClientBreakdown { accum.cacheReadTokens = contribution.cacheReadTokens accum.cacheCreationTokens = contribution.cacheCreationTokens accum.reasoningTokens = contribution.reasoningTokens + accum.requestCount = contribution.requestCount + accum.coveredDayCount = contribution.coveredDayCount + accum.projectCount = contribution.projectCount + accum.sessionCount = contribution.sessionCount bucket.models[row.id] = accum bySource[contribution.sourceID] = bucket } @@ -85,9 +107,11 @@ enum SpendClientBreakdown { SpendClientModel( id: id, displayName: accum.displayName, + modelProvider: accum.modelProvider, tokens: accum.tokens, cost: accum.cost, - costIsEstimated: accum.costIsEstimated) + costIsEstimated: accum.costIsEstimated, + requestCount: accum.requestCount) } .sorted { $0.tokens > $1.tokens } let totalTokens = models.reduce(0) { $0 + $1.tokens } @@ -109,6 +133,10 @@ enum SpendClientBreakdown { cacheReadTokens: Self.completeSum(bucket.models.values.map(\.cacheReadTokens)), cacheCreationTokens: Self.completeSum(bucket.models.values.map(\.cacheCreationTokens)), reasoningTokens: Self.completeSum(bucket.models.values.map(\.reasoningTokens)), + requestCount: Self.completeSum(bucket.models.values.map(\.requestCount)), + coveredDayCount: bucket.models.values.map(\.coveredDayCount).max() ?? 0, + projectCount: bucket.models.values.compactMap(\.projectCount).max(), + sessionCount: bucket.models.values.compactMap(\.sessionCount).max(), models: models) } .sorted { $0.totalTokens > $1.totalTokens } @@ -116,6 +144,7 @@ enum SpendClientBreakdown { private struct Accum { let displayName: String + let modelProvider: UsageProvider var tokens = 0 var cost: Double? var costIsEstimated: Bool @@ -124,6 +153,10 @@ enum SpendClientBreakdown { var cacheReadTokens: Int? var cacheCreationTokens: Int? var reasoningTokens: Int? + var requestCount: Int? + var coveredDayCount = 0 + var projectCount: Int? + var sessionCount: Int? } private static func completeSum(_ values: [Int?]) -> Int? { @@ -132,21 +165,125 @@ enum SpendClientBreakdown { } } +struct SpendToolModelComparison: Identifiable, Equatable { + struct Tool: Identifiable, Equatable { + let sourceID: String + let provider: UsageProvider + let displayName: String + let kind: SpendToolIdentity.Kind + let contextReuseRate: Double? + let requestCount: Int? + let costPerMillionTokens: Double? + let totalTokens: Int? + let coveredDayCount: Int + + var id: String { + self.sourceID + } + } + + let id: String + let displayName: String + let modelProvider: UsageProvider + let tools: [Tool] + let totalTokens: Int +} + +enum SpendToolComparisonPresentation { + static func comparisons(from analysis: SpendDashboardModel.ModelAnalysis) -> [SpendToolModelComparison] { + analysis.rows.compactMap { row in + let tools = row.contributions.map { contribution in + let identity = SpendToolIdentity.resolve( + provider: contribution.provider, + sourceName: contribution.sourceName, + providerName: contribution.providerName) + return SpendToolModelComparison.Tool( + sourceID: contribution.sourceID, + provider: contribution.provider, + displayName: identity.displayName, + kind: identity.kind, + contextReuseRate: self.contextReuseRate( + input: contribution.inputTokens, + cacheRead: contribution.cacheReadTokens, + cacheCreation: contribution.cacheCreationTokens), + requestCount: contribution.requestCount, + costPerMillionTokens: self.costPerMillionTokens( + cost: contribution.estimatedCost, + tokens: contribution.totalTokens), + totalTokens: contribution.totalTokens, + coveredDayCount: contribution.coveredDayCount) + } + .sorted(by: self.toolOrder) + guard Set(tools.map(\.sourceID)).count > 1 else { return nil } + return SpendToolModelComparison( + id: row.id, + displayName: row.displayName, + modelProvider: row.modelProvider, + tools: tools, + totalTokens: tools.compactMap(\.totalTokens).reduce(0, +)) + } + .sorted { + if $0.totalTokens != $1.totalTokens { return $0.totalTokens > $1.totalTokens } + return $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending + } + } + + static func contextReuseRate(input: Int?, cacheRead: Int?, cacheCreation: Int?) -> Double? { + guard let input, let cacheRead, let cacheCreation, + input >= 0, cacheRead >= 0, cacheCreation >= 0 + else { + return nil + } + let denominator = Double(input) + Double(cacheRead) + Double(cacheCreation) + guard denominator.isFinite, denominator > 0 else { return nil } + return Double(cacheRead) / denominator + } + + static func costPerMillionTokens(cost: Double?, tokens: Int?) -> Double? { + guard let cost, cost.isFinite, cost >= 0, let tokens, tokens > 0 else { return nil } + return cost * 1_000_000 / Double(tokens) + } + + private static func toolOrder( + _ lhs: SpendToolModelComparison.Tool, + _ rhs: SpendToolModelComparison.Tool) -> Bool + { + switch (lhs.contextReuseRate, rhs.contextReuseRate) { + case let (left?, right?) where left != right: return left > right + case (_?, nil): return true + case (nil, _?): return false + default: + let left = lhs.totalTokens ?? -1 + let right = rhs.totalTokens ?? -1 + if left != right { return left > right } + return lhs.displayName.localizedStandardCompare(rhs.displayName) == .orderedAscending + } + } +} + // MARK: - 按工具分组视图 struct SpendClientsView: View { let analysis: SpendDashboardModel.ModelAnalysis + @State private var expandedGroupIDs: Set = [] + @State private var selectedComparisonID: String? + + private static let collapsedModelLimit = 5 var body: some View { let groups = SpendClientBreakdown.groups(from: self.analysis) + let comparisons = SpendToolComparisonPresentation.comparisons(from: self.analysis) if groups.isEmpty { Text(L("No per-client model history")) - .font(.caption) + .font(SpendModelsListStyle.secondaryFont) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, 10) } else { VStack(alignment: .leading, spacing: 12) { + if !comparisons.isEmpty { + self.comparisonCard(comparisons) + } ForEach(groups) { group in self.card(group) } @@ -154,23 +291,99 @@ struct SpendClientsView: View { } } - private func card(_ group: SpendClientGroup) -> some View { - VStack(alignment: .leading, spacing: 0) { + private func comparisonCard(_ comparisons: [SpendToolModelComparison]) -> some View { + let selected = comparisons.first { $0.id == self.selectedComparisonID } ?? comparisons[0] + return VStack(alignment: .leading, spacing: 10) { HStack(spacing: 8) { + Text(L("Same-model comparison")) + .font(SpendModelsListStyle.primaryEmphasizedFont) + Spacer() + Menu { + ForEach(comparisons) { comparison in + Button { + self.selectedComparisonID = comparison.id + } label: { + Label( + comparison.displayName, + systemImage: comparison.id == selected.id ? "checkmark" : "") + } + } + } label: { + HStack(spacing: 5) { + SpendProviderIcon( + provider: selected.modelProvider, + size: SpendModelsListStyle.modelIconSize) + Text(selected.displayName) + .lineLimit(1) + Image(systemName: "chevron.down") + .font(.caption2.weight(.semibold)) + } + .font(SpendModelsListStyle.primaryFont) + } + .menuStyle(.borderlessButton) + .fixedSize() + } + Text(L("Observed history for the same model and time range; workload differences still apply.")) + .font(SpendModelsListStyle.tertiaryFont) + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 0) { + ForEach(Array(selected.tools.enumerated()), id: \.element.id) { index, tool in + if index > 0 { Divider().padding(.leading, SpendModelsListStyle.modelIndent) } + self.comparisonRow(tool) + } + } + } + .padding(14) + .background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + } + + private func comparisonRow(_ tool: SpendToolModelComparison.Tool) -> some View { + HStack(spacing: 8) { + SpendProviderIcon(provider: tool.provider, size: SpendModelsListStyle.modelIconSize) + .frame( + width: SpendModelsListStyle.modelIconFrameSize, + height: SpendModelsListStyle.modelIconFrameSize) + Text(cleanToolName(tool.displayName)) + .font(SpendModelsListStyle.primaryFont) + .lineLimit(1) + Text(tool.kind.displayName) + .font(SpendModelsListStyle.tertiaryFont.weight(.medium)) + .foregroundStyle(.secondary) + Spacer(minLength: 12) + Text(self.comparisonMetrics(tool)) + .font(SpendModelsListStyle.secondaryFont) + .foregroundStyle(.secondary) + .monospacedDigit() + .lineLimit(1) + } + .padding(.vertical, 5) + } + + private func card(_ group: SpendClientGroup) -> some View { + let isExpanded = self.expandedGroupIDs.contains(group.id) + let visibleModels = isExpanded + ? group.models + : Array(group.models.prefix(Self.collapsedModelLimit)) + return VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 7) { SpendProviderIcon( provider: group.provider, size: SpendModelsListStyle.iconSize) - Text(group.displayTitle) + .frame( + width: SpendModelsListStyle.iconFrameSize, + height: SpendModelsListStyle.iconFrameSize) + Text(cleanToolName(group.displayTitle)) .font(SpendModelsListStyle.primaryEmphasizedFont) Text(group.kind.displayName) - .font(.caption2.weight(.medium)) + .font(SpendModelsListStyle.tertiaryFont.weight(.medium)) .foregroundStyle(.secondary) .padding(.horizontal, 6) .padding(.vertical, 2) .background(Color.secondary.opacity(0.12), in: Capsule()) if group.costIsEstimated { Text(L("Estimated")) - .font(.caption2) + .font(SpendModelsListStyle.tertiaryFont) .foregroundStyle(.secondary) .padding(.horizontal, 5) .padding(.vertical, 1) @@ -182,46 +395,63 @@ struct SpendClientsView: View { .foregroundStyle(.secondary) .monospacedDigit() } - .padding(.bottom, 8) + .padding(.bottom, 6) - if let tokenSummary = self.tokenSummary(group) { - Text(tokenSummary) - .font(SpendModelsListStyle.secondaryFont) - .foregroundStyle(.secondary) - .monospacedDigit() - .lineLimit(2) - .padding(.bottom, 8) - } + Text(self.toolEvidenceSummary(group)) + .font(SpendModelsListStyle.secondaryFont) + .foregroundStyle(.secondary) + .monospacedDigit() + .lineLimit(2) + .padding(.bottom, 8) + .padding(.leading, SpendModelsListStyle.iconFrameSize + 7) - ForEach(Array(group.models.enumerated()), id: \.element.id) { index, model in - if index > 0 { Divider().padding(.vertical, 2) } - self.modelRow(model, groupTokens: group.totalTokens) + VStack(alignment: .leading, spacing: 0) { + ForEach(Array(visibleModels.enumerated()), id: \.element.id) { index, model in + if index > 0 { Divider().padding(.vertical, 2) } + self.modelRow(model) + } + if group.models.count > Self.collapsedModelLimit { + Button { + if isExpanded { + self.expandedGroupIDs.remove(group.id) + } else { + self.expandedGroupIDs.insert(group.id) + } + } label: { + HStack(spacing: 5) { + Text(isExpanded + ? L("Show fewer models") + : String(format: L("Show all %d models"), group.models.count)) + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .font(.caption2.weight(.semibold)) + } + .font(SpendModelsListStyle.secondaryFont.weight(.medium)) + .foregroundStyle(.secondary) + .padding(.top, 7) + } + .buttonStyle(.plain) + } } + .padding(.leading, SpendModelsListStyle.modelIndent) } - .padding(12) + .padding(14) .background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) } - private func modelRow(_ model: SpendClientModel, groupTokens: Int) -> some View { - VStack(alignment: .leading, spacing: 3) { - HStack(alignment: .firstTextBaseline, spacing: 8) { - Text(model.displayName) - .font(SpendModelsListStyle.primaryFont) - .lineLimit(1) - Spacer() - Text(self.modelMetric(model)) - .font(SpendModelsListStyle.primaryFont) - .monospacedDigit() - } - if groupTokens > 0 { - GeometryReader { geo in - let share = CGFloat(model.tokens) / CGFloat(groupTokens) - RoundedRectangle(cornerRadius: 2, style: .continuous) - .fill(Color.accentColor) - .frame(width: max(geo.size.width * share, 2), height: 3) - } - .frame(height: 3) - } + private func modelRow(_ model: SpendClientModel) -> some View { + HStack(alignment: .center, spacing: 8) { + SpendProviderIcon(provider: model.modelProvider, size: SpendModelsListStyle.modelIconSize) + .frame( + width: SpendModelsListStyle.modelIconFrameSize, + height: SpendModelsListStyle.modelIconFrameSize) + Text(model.displayName) + .font(SpendModelsListStyle.primaryFont) + .lineLimit(1) + Spacer() + Text(self.modelMetric(model)) + .font(SpendModelsListStyle.valueFont) + .foregroundStyle(.secondary) + .monospacedDigit() } .padding(.vertical, 5) } @@ -235,29 +465,55 @@ struct SpendClientsView: View { } private func modelMetric(_ model: SpendClientModel) -> String { + var parts = [UsageFormatter.tokenCountString(model.tokens)] if let cost = model.cost { - return UsageFormatter.currencyString(cost, currencyCode: "USD") + parts.append(UsageFormatter.currencyString(cost, currencyCode: "USD")) } - return UsageFormatter.tokenCountString(model.tokens) + return parts.joined(separator: " · ") } - private func tokenSummary(_ group: SpendClientGroup) -> String? { - var parts: [String] = [] - if let input = group.inputTokens, input > 0 { - parts.append("Input \(UsageFormatter.tokenCountString(input))") + private func toolEvidenceSummary(_ group: SpendClientGroup) -> String { + var parts = [String(format: L("%d models"), group.models.count)] + if let requests = group.requestCount { + parts.append(String(format: L("%@ requests"), UsageFormatter.tokenCountString(requests))) } - if let output = group.outputTokens, output > 0 { - parts.append("Output \(UsageFormatter.tokenCountString(output))") + if let rate = SpendToolComparisonPresentation.contextReuseRate( + input: group.inputTokens, + cacheRead: group.cacheReadTokens, + cacheCreation: group.cacheCreationTokens) + { + parts.append(String(format: L("%d%% context reuse"), Int((rate * 100).rounded()))) } - if let cacheRead = group.cacheReadTokens, cacheRead > 0 { - parts.append("Cache read \(UsageFormatter.tokenCountString(cacheRead))") + if let projects = group.projectCount { + parts.append(String(format: L("%d projects"), projects)) } - if let cacheWrite = group.cacheCreationTokens, cacheWrite > 0 { - parts.append("Cache write \(UsageFormatter.tokenCountString(cacheWrite))") + if let sessions = group.sessionCount { + parts.append(String(format: L("%d sessions"), sessions)) } - if let reasoning = group.reasoningTokens, reasoning > 0 { - parts.append("Reasoning \(UsageFormatter.tokenCountString(reasoning))") + if group.coveredDayCount > 0 { + parts.append(String(format: L("%d days covered"), group.coveredDayCount)) } - return parts.isEmpty ? nil : parts.joined(separator: " · ") + return parts.joined(separator: " · ") + } + + private func comparisonMetrics(_ tool: SpendToolModelComparison.Tool) -> String { + var parts: [String] = [] + if let rate = tool.contextReuseRate { + parts.append(String(format: L("%d%% reuse"), Int((rate * 100).rounded()))) + } else { + parts.append("\(L("Cache read")) —") + } + if let requests = tool.requestCount { + parts.append(String(format: L("%@ requests"), UsageFormatter.tokenCountString(requests))) + } + if let cost = tool.costPerMillionTokens { + parts.append(String( + format: L("%@ per 1M tokens"), + UsageFormatter.currencyString(cost, currencyCode: "USD"))) + } + if tool.coveredDayCount > 0 { + parts.append(String(format: L("%d days"), tool.coveredDayCount)) + } + return parts.joined(separator: " · ") } } diff --git a/Sources/CodexBar/SpendDashboardModel+ChartDomain.swift b/Sources/CodexBar/SpendDashboardModel+ChartDomain.swift new file mode 100644 index 0000000000..c0bb7a6c29 --- /dev/null +++ b/Sources/CodexBar/SpendDashboardModel+ChartDomain.swift @@ -0,0 +1,145 @@ +import Foundation + +private struct SpendChartActivityDay { + let day: Date + let tokens: Double + let spend: Double +} + +extension SpendDashboardModel { + static func allModelChartDomain( + analysis: ModelAnalysis, + bounds: ClosedRange, + calendar: Calendar) -> ClosedRange + { + let activityDays = Dictionary(grouping: analysis.dailyValues, by: { + calendar.startOfDay(for: $0.day) + }) + .compactMap { day, values -> SpendChartActivityDay? in + let tokens = values.reduce(0.0) { partial, value in + if let total = value.totalTokens, total > 0 { + return partial + Double(total) + } + // Reasoning is a sub-bucket of output and must not be added again. + return partial + [ + value.inputTokens, + value.outputTokens, + value.cacheReadTokens, + value.cacheCreationTokens, + ].reduce(0.0) { subtotal, amount in + subtotal + Double(max(0, amount ?? 0)) + } + } + let spend = values.reduce(0.0) { partial, value in + partial + max(0, value.estimatedCost ?? 0) + } + guard tokens > 0 || spend > 0 else { return nil } + return SpendChartActivityDay(day: day, tokens: tokens, spend: spend) + } + .sorted { $0.day < $1.day } + let focusedDays = Self.droppingSparseLeadingActivity( + activityDays, + calendar: calendar) + guard let firstActiveDay = focusedDays.first?.day, + let lastActiveDay = focusedDays.last?.day + else { + let fallbackStart = calendar.date( + byAdding: .day, + value: -6, + to: bounds.upperBound) ?? bounds.upperBound + let fallbackEnd = calendar.date( + byAdding: .day, + value: 1, + to: bounds.upperBound) ?? bounds.upperBound + return max(bounds.lowerBound, fallbackStart)...fallbackEnd + } + + let observedDays = max( + 1, + (calendar.dateComponents( + [.day], + from: firstActiveDay, + to: lastActiveDay).day ?? 0) + 1) + let paddingDays = min(14, max(1, Int(ceil(Double(observedDays) * 0.04)))) + var start = max( + bounds.lowerBound, + calendar.date(byAdding: .day, value: -paddingDays, to: firstActiveDay) ?? firstActiveDay) + var lastVisibleDay = min( + bounds.upperBound, + calendar.date(byAdding: .day, value: paddingDays, to: lastActiveDay) ?? lastActiveDay) + + let visibleDays = max( + 1, + (calendar.dateComponents([.day], from: start, to: lastVisibleDay).day ?? 0) + 1) + if visibleDays < 7 { + let missingDays = 7 - visibleDays + let earlierStart = calendar.date(byAdding: .day, value: -missingDays, to: start) ?? start + start = max(bounds.lowerBound, earlierStart) + let expandedDays = max( + 1, + (calendar.dateComponents([.day], from: start, to: lastVisibleDay).day ?? 0) + 1) + if expandedDays < 7 { + let laterEnd = calendar.date( + byAdding: .day, + value: 7 - expandedDays, + to: lastVisibleDay) ?? lastVisibleDay + lastVisibleDay = min(bounds.upperBound, laterEnd) + } + } + + let end = calendar.date(byAdding: .day, value: 1, to: lastVisibleDay) ?? lastVisibleDay + return start...end + } + + /// Cumulative history occasionally contains one or two tiny legacy samples followed by + /// months of nothing. Keeping those samples on a calendar axis compresses the useful recent + /// history into a narrow strip. Drop only a leading segment that is separated by a long gap, + /// contains very few active days, and contributes no more than 5% of either tokens or spend. + /// The samples remain in rankings and totals; this affects chart framing only. + private static func droppingSparseLeadingActivity( + _ days: [SpendChartActivityDay], + calendar: Calendar) -> [SpendChartActivityDay] + { + guard days.count >= 4 else { return days } + let totalTokens = days.reduce(0.0) { $0 + $1.tokens } + let totalSpend = days.reduce(0.0) { $0 + $1.spend } + var lowerBound = 0 + + while days.count - lowerBound >= 4 { + let visible = days[lowerBound...] + let prefixCountLimit = max(2, Int(ceil(Double(visible.count) * 0.10))) + var splitIndex: Int? + for candidate in visible.indices.dropLast() { + let gap = Self.dayGap( + days[candidate].day, + days[candidate + 1].day, + calendar: calendar) + guard gap >= 21 else { continue } + let prefix = days[lowerBound...candidate] + let prefixTokens = prefix.reduce(0.0) { $0 + $1.tokens } + let prefixSpend = prefix.reduce(0.0) { $0 + $1.spend } + let tokenShare = totalTokens > 0 ? prefixTokens / totalTokens : 0 + let spendShare = totalSpend > 0 ? prefixSpend / totalSpend : 0 + if prefix.count <= prefixCountLimit, + tokenShare <= 0.05, + spendShare <= 0.05 + { + splitIndex = candidate + break + } + } + guard let splitIndex else { break } + lowerBound = splitIndex + 1 + } + + return Array(days[lowerBound...]) + } + + private static func dayGap( + _ lhs: Date, + _ rhs: Date, + calendar: Calendar) -> Int + { + max(0, calendar.dateComponents([.day], from: lhs, to: rhs).day ?? 0) + } +} diff --git a/Sources/CodexBar/SpendDashboardModel+Evidence.swift b/Sources/CodexBar/SpendDashboardModel+Evidence.swift new file mode 100644 index 0000000000..8b54d11037 --- /dev/null +++ b/Sources/CodexBar/SpendDashboardModel+Evidence.swift @@ -0,0 +1,78 @@ +import CodexBarCore +import Foundation + +extension SpendDashboardModel { + struct ModelAnalysisAccumulator { + var rawNames: Set = [] + var displayNames: Set = [] + var providerNames: [UsageProvider: String] = [:] + var sourceContributions: [String: ModelAnalysisSourceAccumulator] = [:] + var tokens: Int? = 0 + var inputTokens: Int? = 0 + var outputTokens: Int? = 0 + var cacheReadTokens: Int? = 0 + var cacheCreationTokens: Int? = 0 + var reasoningTokens: Int? = 0 + var requestCount: Int? = 0 + var cost: Double? = 0 + var sawTokens = false + var sawTokenSplit = false + var sawCacheReadTokens = false + var missingCacheReadTokens = false + var sawCacheCreationTokens = false + var missingCacheCreationTokens = false + var sawReasoningTokens = false + var missingReasoningTokens = false + var sawRequestCount = false + var missingRequestCount = false + var sawCost = false + var sawEstimatedCost = false + var invalidTokenSplit = false + var overflowedTokens = false + var overflowedInputTokens = false + var overflowedOutputTokens = false + var overflowedCacheReadTokens = false + var overflowedCacheCreationTokens = false + var overflowedReasoningTokens = false + var overflowedRequestCount = false + var overflowedCost = false + } + + struct ModelAnalysisSourceAccumulator { + let provider: UsageProvider + let sourceName: String + let providerName: String + let coveredDayCount: Int + let projectCount: Int? + let sessionCount: Int? + var rawNames: Set = [] + var tokens: Int? = 0 + var inputTokens: Int? = 0 + var outputTokens: Int? = 0 + var cacheReadTokens: Int? = 0 + var cacheCreationTokens: Int? = 0 + var reasoningTokens: Int? = 0 + var requestCount: Int? = 0 + var cost: Double? = 0 + var sawTokens = false + var sawTokenSplit = false + var sawCacheReadTokens = false + var missingCacheReadTokens = false + var sawCacheCreationTokens = false + var missingCacheCreationTokens = false + var sawReasoningTokens = false + var missingReasoningTokens = false + var sawRequestCount = false + var missingRequestCount = false + var invalidTokenSplit = false + var sawCost = false + var overflowedTokens = false + var overflowedInputTokens = false + var overflowedOutputTokens = false + var overflowedCacheReadTokens = false + var overflowedCacheCreationTokens = false + var overflowedReasoningTokens = false + var overflowedRequestCount = false + var overflowedCost = false + } +} diff --git a/Sources/CodexBar/SpendDashboardModel.swift b/Sources/CodexBar/SpendDashboardModel.swift index 1c262ab946..bd562a36e6 100644 --- a/Sources/CodexBar/SpendDashboardModel.swift +++ b/Sources/CodexBar/SpendDashboardModel.swift @@ -147,6 +147,10 @@ struct SpendDashboardModel: Equatable, Sendable { let cacheReadTokens: Int? let cacheCreationTokens: Int? let reasoningTokens: Int? + let requestCount: Int? + let coveredDayCount: Int + let projectCount: Int? + let sessionCount: Int? let estimatedCost: Double? var id: String { @@ -415,6 +419,8 @@ struct SpendDashboardModel: Equatable, Sendable { let totalCost: Double? let coveredInterval: ClosedRange? let coveredDayCount: Int + let projectCount: Int? + let sessionCount: Int? let hasInvalidCostHistory: Bool } @@ -445,69 +451,6 @@ struct SpendDashboardModel: Equatable, Sendable { let completeness: ModelHistoryCompleteness } - struct ModelAnalysisAccumulator { - var rawNames: Set = [] - var displayNames: Set = [] - var providerNames: [UsageProvider: String] = [:] - var sourceContributions: [String: ModelAnalysisSourceAccumulator] = [:] - var tokens: Int? = 0 - var inputTokens: Int? = 0 - var outputTokens: Int? = 0 - var cacheReadTokens: Int? = 0 - var cacheCreationTokens: Int? = 0 - var reasoningTokens: Int? = 0 - var cost: Double? = 0 - var sawTokens = false - var sawTokenSplit = false - var sawCacheReadTokens = false - var missingCacheReadTokens = false - var sawCacheCreationTokens = false - var missingCacheCreationTokens = false - var sawReasoningTokens = false - var missingReasoningTokens = false - var sawCost = false - var sawEstimatedCost = false - var invalidTokenSplit = false - var overflowedTokens = false - var overflowedInputTokens = false - var overflowedOutputTokens = false - var overflowedCacheReadTokens = false - var overflowedCacheCreationTokens = false - var overflowedReasoningTokens = false - var overflowedCost = false - } - - struct ModelAnalysisSourceAccumulator { - let provider: UsageProvider - let sourceName: String - let providerName: String - var rawNames: Set = [] - var tokens: Int? = 0 - var inputTokens: Int? = 0 - var outputTokens: Int? = 0 - var cacheReadTokens: Int? = 0 - var cacheCreationTokens: Int? = 0 - var reasoningTokens: Int? = 0 - var cost: Double? = 0 - var sawTokens = false - var sawTokenSplit = false - var sawCacheReadTokens = false - var missingCacheReadTokens = false - var sawCacheCreationTokens = false - var missingCacheCreationTokens = false - var sawReasoningTokens = false - var missingReasoningTokens = false - var invalidTokenSplit = false - var sawCost = false - var overflowedTokens = false - var overflowedInputTokens = false - var overflowedOutputTokens = false - var overflowedCacheReadTokens = false - var overflowedCacheCreationTokens = false - var overflowedReasoningTokens = false - var overflowedCost = false - } - private struct ModelAnalysisDailyKey: Hashable { let modelID: String let day: Date @@ -665,6 +608,32 @@ extension SpendDashboardModel { } else { nil } + // Project/session evidence is currently produced only by the Codex session scanner. + // Count it inside the selected dashboard window instead of exposing the snapshot's + // broader 30-day totals under a shorter range. + let projectCount: Int? = if input.provider == .codex, !input.snapshot.projects.isEmpty { + input.snapshot.projects.count { project in + project.daily.contains { entry in + guard let day = Self.day( + entry.date, + provider: input.provider, + displayCalendar: calendar) + else { + return false + } + return bounds.contains(day) + } + } + } else { + nil + } + let sessionCount: Int? = if input.provider == .codex, !input.snapshot.sessions.isEmpty { + input.snapshot.sessions.count { session in + bounds.contains(calendar.startOfDay(for: session.lastActivity)) + } + } else { + nil + } return InputSummary( input: input, entries: entries, @@ -672,6 +641,8 @@ extension SpendDashboardModel { totalCost: totalCost, coveredInterval: coveredInterval, coveredDayCount: coveredDayCount, + projectCount: projectCount, + sessionCount: sessionCount, hasInvalidCostHistory: invalidCostHistory) } @@ -811,7 +782,10 @@ extension SpendDashboardModel { var source = aggregate.sourceContributions[input.id] ?? ModelAnalysisSourceAccumulator( provider: input.provider, sourceName: input.displayName, - providerName: input.modelProviderName) + providerName: input.modelProviderName, + coveredDayCount: summary.coveredDayCount, + projectCount: summary.projectCount, + sessionCount: summary.sessionCount) source.rawNames.insert(rawName) let dailyKey = ModelAnalysisDailyKey(modelID: identity, day: windowEntry.day) @@ -929,6 +903,13 @@ extension SpendDashboardModel { missing: source.missingReasoningTokens, overflowed: source.overflowedReasoningTokens, splitIsComplete: sourceSplitIsComplete), + requestCount: source.sawRequestCount && !source.missingRequestCount + && !source.overflowedRequestCount + ? source.requestCount + : nil, + coveredDayCount: source.coveredDayCount, + projectCount: source.projectCount, + sessionCount: source.sessionCount, estimatedCost: source.sawCost && !source.overflowedCost ? source.cost : nil) } .sorted { lhs, rhs in @@ -977,6 +958,21 @@ extension SpendDashboardModel { aggregate.tokens = Self.add(tokens, to: aggregate.tokens, overflowed: &aggregate.overflowedTokens) source.sawTokens = true source.tokens = Self.add(tokens, to: source.tokens, overflowed: &source.overflowedTokens) + if let requestCount = nonnegative(breakdown.requestCount) { + aggregate.sawRequestCount = true + aggregate.requestCount = Self.add( + requestCount, + to: aggregate.requestCount, + overflowed: &aggregate.overflowedRequestCount) + source.sawRequestCount = true + source.requestCount = Self.add( + requestCount, + to: source.requestCount, + overflowed: &source.overflowedRequestCount) + } else { + aggregate.missingRequestCount = true + source.missingRequestCount = true + } dailyValue.sawTokens = true dailyValue.tokens = Self.add( @@ -1493,16 +1489,6 @@ extension SpendDashboardModel { return bounds.lowerBound...end } - private static func allModelChartDomain( - analysis: ModelAnalysis, - bounds: ClosedRange, - calendar: Calendar) -> ClosedRange - { - let start = analysis.dailyValues.map(\.day).min() ?? bounds.lowerBound - let end = calendar.date(byAdding: .day, value: 1, to: bounds.upperBound) ?? bounds.upperBound - return min(start, bounds.upperBound)...end - } - private static func coverageInterval( input: ProviderInput, bounds: ClosedRange, diff --git a/Sources/CodexBar/UsageStore+QuotaWarnings.swift b/Sources/CodexBar/UsageStore+QuotaWarnings.swift index fce4cc9a4d..c79731150b 100644 --- a/Sources/CodexBar/UsageStore+QuotaWarnings.swift +++ b/Sources/CodexBar/UsageStore+QuotaWarnings.swift @@ -199,10 +199,7 @@ extension UsageStore { self.clearQuotaWarningState(provider: provider, window: window) return } - guard let rateWindow else { - self.quotaWarningState.removeValue(forKey: key) - return - } + guard let rateWindow else { return } guard !rateWindow.isSyntheticPlaceholder else { return } let thresholds = self.settings.resolvedQuotaWarningThresholds(provider: provider, window: window) diff --git a/Sources/CodexBar/UsageStore+SessionQuotaTransition.swift b/Sources/CodexBar/UsageStore+SessionQuotaTransition.swift index 2f284b13ff..98bc1eae43 100644 --- a/Sources/CodexBar/UsageStore+SessionQuotaTransition.swift +++ b/Sources/CodexBar/UsageStore+SessionQuotaTransition.swift @@ -69,10 +69,8 @@ extension UsageStore { return } let previousState = self.sessionQuotaTransitionStates[provider] - let persistedDepletion = self.persistedSessionQuotaDepletionProviders() let suppressRepeatedStartupDepletion = previousState == nil && - SessionQuotaNotificationLogic.isDepleted(currentRemaining) && - persistedDepletion.contains(provider) + SessionQuotaNotificationLogic.isDepleted(currentRemaining) let forceBaseline = provider == .codex && self.codexSessionQuotaBaselineRequirement != nil let evaluation = SessionQuotaTransitionReducer.evaluate( previous: previousState, diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 174abf52e9..62bac19cea 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 = "de433e82c0e0fa12" + static let value = "82dc0f185b2e094b" } diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift index 6331515eb0..d8929f27ba 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift @@ -27,7 +27,6 @@ public enum AntigravityProviderDescriptor { branding: ProviderBranding( iconStyle: .antigravity, iconResourceName: "ProviderIcon-antigravity", - iconRenderingMode: .original, color: ProviderColor(red: 96 / 255, green: 186 / 255, blue: 126 / 255), confettiPalette: [ ProviderColor(hex: 0x4285F4), diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravitySessionScanner.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravitySessionScanner.swift index e57cc28268..2de5d5d2bb 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravitySessionScanner.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravitySessionScanner.swift @@ -39,33 +39,7 @@ public enum AntigravitySessionScanner { /// this scanner stays self-contained (mirrors `MiniMaxSessionScanner.MINIMAX_HOME`). public static let homeEnvironmentKey = "ANTIGRAVITY_HOME" - /// models.dev providers that carry Gemini / Claude list pricing. Antigravity routes both - /// Google (Gemini) and Anthropic (Claude) models, so the pricing provider is picked per model. - private static let googleModelsDevProviderID = "google" - private static let anthropicModelsDevProviderID = "anthropic" - - /// Maps Antigravity's wire `responseModel` strings (placeholders, tier suffixes, aliases) to - /// the models.dev model id used for pricing, mirroring tokscale's antigravity alias table - /// (verified against the Antigravity Context Window Monitor models.ts). Only the pricing key is - /// rewritten — the raw model name stays in the breakdown for display. - private static let modelPricingAliases: [String: String] = [ - "gemini-pro-default": "gemini-3.1-pro-preview", - "gemini-pro-agent": "gemini-3.1-pro-preview", - "gemini-3.1-pro": "gemini-3.1-pro-preview", - "gemini-3.1-pro-high": "gemini-3.1-pro-preview", - "gemini-3.1-pro-low": "gemini-3.1-pro-preview", - "gemini-3-pro": "gemini-3-pro-preview", - "gemini-3-pro-high": "gemini-3-pro-preview", - "gemini-3-pro-low": "gemini-3-pro-preview", - "gemini-3-flash": "gemini-3-flash-preview", - "gemini-3-flash-c": "gemini-3-flash-preview", - "gemini-default": "gemini-3-flash-preview", - // The `gemini-3-flash-a`/`-agent`/`-b` family is the High tier of 3.5 Flash, not the - // unrelated gemini-3-flash-preview backend; models.dev prices it as `gemini-3.5-flash`. - "gemini-3-flash-a": "gemini-3.5-flash", - "gemini-3-flash-agent": "gemini-3.5-flash", - "gemini-3-flash-b": "gemini-3.5-flash", - "gemini-3.5-flash-high": "gemini-3.5-flash", + private static let claudePricingAliases: [String: String] = [ "claude-opus-4-6-thinking": "claude-opus-4-6", "claude-sonnet-4-6-thinking": "claude-sonnet-4-6", "claude-haiku-4-6-thinking": "claude-haiku-4-6", @@ -410,12 +384,9 @@ public enum AntigravitySessionScanner { let output: Int } - /// Prices a generation at the vendor's official models.dev rate. The wire model name is first - /// normalized through `modelPricingAliases`, then the provider is chosen by family: Claude - /// models price against the Anthropic catalog, everything else against Google. `reasoning` is - /// already folded into `output`, so it is not priced twice. Cache-read tokens use the cheaper - /// cache-read rate when the catalog carries one, else the plain input rate. Returns nil when no - /// official rate is known. + /// Prices a generation against its actual model provider. Known Gemini models use CodexBar's + /// official Google pricing snapshot first and models.dev for newly released ids; Claude uses + /// the same catalog-plus-bundled fallback as Claude Code. The raw display name is never changed. private static func costUSD( model: String, tokens: TokenBuckets, @@ -423,24 +394,24 @@ public enum AntigravitySessionScanner { cacheRoot: URL?) -> Double? { let lowered = model.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - let pricingModel = self.modelPricingAliases[lowered] ?? lowered - let providerID = pricingModel.hasPrefix("claude-") - ? self.anthropicModelsDevProviderID - : self.googleModelsDevProviderID - guard let lookup = CostUsagePricing.modelsDevLookup( - providerID: providerID, - model: pricingModel, - catalog: catalog, - cacheRoot: cacheRoot) - else { - return nil + if lowered.hasPrefix("claude-") { + let pricingModel = self.claudePricingAliases[lowered] ?? lowered + return CostUsagePricing.claudeCostUSD( + model: pricingModel, + inputTokens: tokens.input, + cacheReadInputTokens: tokens.cacheRead, + cacheCreationInputTokens: 0, + outputTokens: tokens.output, + modelsDevCatalog: catalog, + modelsDevCacheRoot: cacheRoot) } - let pricing = lookup.pricing - let cacheReadRate = pricing.cacheReadInputCostPerToken ?? pricing.inputCostPerToken - let cost = Double(tokens.input) * pricing.inputCostPerToken - + Double(tokens.cacheRead) * cacheReadRate - + Double(tokens.output) * pricing.outputCostPerToken - return cost.isFinite ? cost : nil + return CostUsagePricing.googleCostUSD( + model: lowered, + inputTokens: tokens.input, + cacheReadInputTokens: tokens.cacheRead, + outputTokens: tokens.output, + modelsDevCatalog: catalog, + modelsDevCacheRoot: cacheRoot) } // MARK: - SQLite helpers diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift index 317df49dc1..1ae78675fe 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift @@ -28,7 +28,6 @@ public enum ClaudeProviderDescriptor { branding: ProviderBranding( iconStyle: .claude, iconResourceName: "ProviderIcon-claude", - iconRenderingMode: .original, color: ProviderColor(red: 204 / 255, green: 124 / 255, blue: 94 / 255), confettiPalette: [ ProviderColor(hex: 0xD97757), diff --git a/Sources/CodexBarCore/Providers/Gemini/GeminiProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Gemini/GeminiProviderDescriptor.swift index 45f3f5ac6c..68cd067d40 100644 --- a/Sources/CodexBarCore/Providers/Gemini/GeminiProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Gemini/GeminiProviderDescriptor.swift @@ -28,7 +28,6 @@ public enum GeminiProviderDescriptor { branding: ProviderBranding( iconStyle: .gemini, iconResourceName: "ProviderIcon-gemini", - iconRenderingMode: .original, color: ProviderColor(red: 171 / 255, green: 135 / 255, blue: 234 / 255), confettiPalette: [ ProviderColor(hex: 0x4285F4), diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift index 32568fcca4..964adabb43 100644 --- a/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift @@ -26,7 +26,6 @@ public enum KimiProviderDescriptor { branding: ProviderBranding( iconStyle: .kimi, iconResourceName: "ProviderIcon-kimi", - iconRenderingMode: .original, color: ProviderColor(red: 254 / 255, green: 96 / 255, blue: 60 / 255), confettiPalette: [ ProviderColor(hex: 0x000000), diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift index 317ea8ab07..e085b332a3 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift @@ -26,7 +26,6 @@ public enum MiniMaxProviderDescriptor { branding: ProviderBranding( iconStyle: .minimax, iconResourceName: "ProviderIcon-minimax", - iconRenderingMode: .original, color: ProviderColor(red: 254 / 255, green: 96 / 255, blue: 60 / 255), confettiPalette: [ ProviderColor(hex: 0x181E25), diff --git a/Sources/CodexBarCore/Providers/Moonshot/MoonshotProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Moonshot/MoonshotProviderDescriptor.swift index 1afe4270b9..fc18f20391 100644 --- a/Sources/CodexBarCore/Providers/Moonshot/MoonshotProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Moonshot/MoonshotProviderDescriptor.swift @@ -26,7 +26,6 @@ public enum MoonshotProviderDescriptor { branding: ProviderBranding( iconStyle: .kimi, iconResourceName: "ProviderIcon-kimi", - iconRenderingMode: .original, color: ProviderColor(red: 32 / 255, green: 93 / 255, blue: 235 / 255), confettiPalette: [ ProviderColor(hex: 0x121212), diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+Google.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+Google.swift new file mode 100644 index 0000000000..7672d70a5c --- /dev/null +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+Google.swift @@ -0,0 +1,145 @@ +import Foundation + +extension CostUsagePricing { + private struct GooglePricing { + let inputCostPerToken: Double + let outputCostPerToken: Double + let cacheReadInputCostPerToken: Double + let thresholdTokens: Int? + let inputCostPerTokenAboveThreshold: Double? + let outputCostPerTokenAboveThreshold: Double? + let cacheReadInputCostPerTokenAboveThreshold: Double? + } + + /// Official Google Gemini Standard rates, kept as an offline fallback for model ids that can + /// appear in Antigravity before the cached models.dev catalog catches up. + /// + /// Source (accessed 2026-07-28): + /// https://ai.google.dev/gemini-api/docs/pricing + private static let google: [String: GooglePricing] = [ + "gemini-3.6-flash": GooglePricing( + inputCostPerToken: 1.50 / 1_000_000, + outputCostPerToken: 7.50 / 1_000_000, + cacheReadInputCostPerToken: 0.15 / 1_000_000, + thresholdTokens: nil, + inputCostPerTokenAboveThreshold: nil, + outputCostPerTokenAboveThreshold: nil, + cacheReadInputCostPerTokenAboveThreshold: nil), + "gemini-3.5-flash": GooglePricing( + inputCostPerToken: 1.50 / 1_000_000, + outputCostPerToken: 9.00 / 1_000_000, + cacheReadInputCostPerToken: 0.15 / 1_000_000, + thresholdTokens: nil, + inputCostPerTokenAboveThreshold: nil, + outputCostPerTokenAboveThreshold: nil, + cacheReadInputCostPerTokenAboveThreshold: nil), + "gemini-3.1-pro-preview": GooglePricing( + inputCostPerToken: 2.00 / 1_000_000, + outputCostPerToken: 12.00 / 1_000_000, + cacheReadInputCostPerToken: 0.20 / 1_000_000, + thresholdTokens: 200_000, + inputCostPerTokenAboveThreshold: 4.00 / 1_000_000, + outputCostPerTokenAboveThreshold: 18.00 / 1_000_000, + cacheReadInputCostPerTokenAboveThreshold: 0.40 / 1_000_000), + "gemini-3-flash-preview": GooglePricing( + inputCostPerToken: 0.50 / 1_000_000, + outputCostPerToken: 3.00 / 1_000_000, + cacheReadInputCostPerToken: 0.05 / 1_000_000, + thresholdTokens: nil, + inputCostPerTokenAboveThreshold: nil, + outputCostPerTokenAboveThreshold: nil, + cacheReadInputCostPerTokenAboveThreshold: nil), + ] + + private static let googleAliases: [String: String] = [ + "gemini-pro-default": "gemini-3.1-pro-preview", + "gemini-pro-agent": "gemini-3.1-pro-preview", + "gemini-3.1-pro": "gemini-3.1-pro-preview", + "gemini-3.1-pro-high": "gemini-3.1-pro-preview", + "gemini-3.1-pro-low": "gemini-3.1-pro-preview", + "gemini-3-flash": "gemini-3-flash-preview", + "gemini-3-flash-c": "gemini-3-flash-preview", + "gemini-default": "gemini-3-flash-preview", + "gemini-3-flash-a": "gemini-3.5-flash", + "gemini-3-flash-agent": "gemini-3.5-flash", + "gemini-3-flash-b": "gemini-3.5-flash", + "gemini-3.5-flash-high": "gemini-3.5-flash", + ] + + static func googleCostUSD( + model: String, + inputTokens: Int, + cacheReadInputTokens: Int, + outputTokens: Int, + modelsDevCatalog: ModelsDevCatalog? = nil, + modelsDevCacheRoot: URL? = nil) -> Double? + { + let canonicalModel = self.googlePricingModelID(model) + if let pricing = self.google[canonicalModel] { + return self.googleCostUSD( + pricing: pricing, + inputTokens: inputTokens, + cacheReadInputTokens: cacheReadInputTokens, + outputTokens: outputTokens) + } + + guard let lookup = self.modelsDevLookup( + providerID: "google", + model: canonicalModel, + catalog: modelsDevCatalog, + cacheRoot: modelsDevCacheRoot) + else { + return nil + } + let pricing = lookup.pricing + let usesLongContextRates = pricing.thresholdTokens.map { + max(0, inputTokens) + max(0, cacheReadInputTokens) > $0 + } ?? false + let inputRate = usesLongContextRates + ? pricing.inputCostPerTokenAboveThreshold ?? pricing.inputCostPerToken + : pricing.inputCostPerToken + let cacheReadRate = usesLongContextRates + ? pricing.cacheReadInputCostPerTokenAboveThreshold + ?? pricing.cacheReadInputCostPerToken + ?? inputRate + : pricing.cacheReadInputCostPerToken ?? inputRate + let outputRate = usesLongContextRates + ? pricing.outputCostPerTokenAboveThreshold ?? pricing.outputCostPerToken + : pricing.outputCostPerToken + let cost = Double(max(0, inputTokens)) * inputRate + + Double(max(0, cacheReadInputTokens)) * cacheReadRate + + Double(max(0, outputTokens)) * outputRate + return cost.isFinite ? cost : nil + } + + static func googlePricingModelID(_ model: String) -> String { + let normalized = model.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return self.googleAliases[normalized] ?? normalized + } + + private static func googleCostUSD( + pricing: GooglePricing, + inputTokens: Int, + cacheReadInputTokens: Int, + outputTokens: Int) -> Double + { + let input = max(0, inputTokens) + let cacheRead = max(0, cacheReadInputTokens) + let output = max(0, outputTokens) + let usesLongContextRates = pricing.thresholdTokens.map { + input + cacheRead > $0 + } ?? false + let inputRate = usesLongContextRates + ? pricing.inputCostPerTokenAboveThreshold ?? pricing.inputCostPerToken + : pricing.inputCostPerToken + let cacheReadRate = usesLongContextRates + ? pricing.cacheReadInputCostPerTokenAboveThreshold ?? pricing.cacheReadInputCostPerToken + : pricing.cacheReadInputCostPerToken + let outputRate = usesLongContextRates + ? pricing.outputCostPerTokenAboveThreshold ?? pricing.outputCostPerToken + : pricing.outputCostPerToken + return Double(input) * inputRate + + Double(cacheRead) * cacheReadRate + + Double(output) * outputRate + } +} diff --git a/Tests/CodexBarTests/AntigravitySessionScannerTests.swift b/Tests/CodexBarTests/AntigravitySessionScannerTests.swift index cacf3dc7aa..2d2c07f69c 100644 --- a/Tests/CodexBarTests/AntigravitySessionScannerTests.swift +++ b/Tests/CodexBarTests/AntigravitySessionScannerTests.swift @@ -49,6 +49,8 @@ struct AntigravitySessionScannerTests { #expect(entry.cacheReadTokens == 16000) #expect(entry.outputTokens == 350) #expect(entry.modelsUsed == ["gemini-3.6-flash"]) + let expectedCost = (2864.0 * 1.5e-6) + (16000.0 * 0.15e-6) + (350.0 * 7.5e-6) + #expect(abs((entry.costUSD ?? 0) - expectedCost) < 1e-12) } @Test diff --git a/Tests/CodexBarTests/CostUsagePricingTests.swift b/Tests/CodexBarTests/CostUsagePricingTests.swift index 7c5340418c..78adbc38b3 100644 --- a/Tests/CodexBarTests/CostUsagePricingTests.swift +++ b/Tests/CodexBarTests/CostUsagePricingTests.swift @@ -24,6 +24,47 @@ struct CostUsagePricingTests { #expect(CostUsagePricing.normalizeCodexModel("openai/gpt-5.6-terra-2099-01-01") == "gpt-5.6-terra") } + @Test + func `google pricing normalizes antigravity aliases and stays available offline`() throws { + let emptyCacheRoot = try Self.cacheRoot() + + let flash36 = CostUsagePricing.googleCostUSD( + model: "gemini-3.6-flash", + inputTokens: 1_000_000, + cacheReadInputTokens: 1_000_000, + outputTokens: 1_000_000, + modelsDevCacheRoot: emptyCacheRoot) + let flash35Alias = CostUsagePricing.googleCostUSD( + model: "gemini-3-flash-agent", + inputTokens: 1_000_000, + cacheReadInputTokens: 1_000_000, + outputTokens: 1_000_000, + modelsDevCacheRoot: emptyCacheRoot) + let flash3Alias = CostUsagePricing.googleCostUSD( + model: "gemini-default", + inputTokens: 1_000_000, + cacheReadInputTokens: 1_000_000, + outputTokens: 1_000_000, + modelsDevCacheRoot: emptyCacheRoot) + + #expect(flash36 == 1.50 + 0.15 + 7.50) + #expect(flash35Alias == 1.50 + 0.15 + 9.00) + #expect(flash3Alias == 0.50 + 0.05 + 3.00) + } + + @Test + func `google pricing applies gemini31 pro long context rates`() throws { + let emptyCacheRoot = try Self.cacheRoot() + let cost = CostUsagePricing.googleCostUSD( + model: "gemini-3.1-pro-high", + inputTokens: 200_001, + cacheReadInputTokens: 10, + outputTokens: 20, + modelsDevCacheRoot: emptyCacheRoot) + + #expect(cost == (200_001.0 * 4e-6) + (10.0 * 0.4e-6) + (20.0 * 18e-6)) + } + @Test func `unattributed codex usage stays unpriced despite a catalog collision`() throws { let root = try Self.seedModelsDevCache(""" diff --git a/Tests/CodexBarTests/ProviderIconResourcesTests.swift b/Tests/CodexBarTests/ProviderIconResourcesTests.swift index d608e8eace..b58d6d3129 100644 --- a/Tests/CodexBarTests/ProviderIconResourcesTests.swift +++ b/Tests/CodexBarTests/ProviderIconResourcesTests.swift @@ -14,7 +14,10 @@ struct ProviderIconResourcesTests { let slugs = [ "codex", "clinepass", - "zai", + "antigravity", + "claude", + "gemini", + "kimi", "minimax", "cursor", "opencode", @@ -51,20 +54,6 @@ struct ProviderIconResourcesTests { } } - @Test - func `official color provider icons are bundled as PNGs`() throws { - let root = try Self.repoRoot() - let resources = root.appending(path: "Sources/CodexBar/Resources", directoryHint: .isDirectory) - - for slug in ["antigravity", "claude", "gemini", "kimi"] { - let url = resources.appending(path: "ProviderIcon-\(slug).png") - #expect( - FileManager.default.fileExists(atPath: url.path(percentEncoded: false)), - "Missing official-color PNG for \(slug)") - #expect(NSImage(contentsOf: url) != nil, "Could not load official-color PNG for \(slug)") - } - } - @Test func `groq and grok provider icons are distinct`() throws { let root = try Self.repoRoot() @@ -89,13 +78,13 @@ struct ProviderIconResourcesTests { } @Test - func `official color provider icons preserve embedded colors`() throws { + func `official color provider icons preserve template rendering`() throws { ProviderBrandIcon.resetCacheForTesting() defer { ProviderBrandIcon.resetCacheForTesting() } for provider in [UsageProvider.antigravity, .claude, .gemini, .kimi, .minimax] { let image = try #require(ProviderBrandIcon.image(for: provider)) - #expect(!image.isTemplate, "\(provider.rawValue) must preserve its official colors") + #expect(image.isTemplate, "\(provider.rawValue) must use template rendering for global UI") } } diff --git a/Tests/CodexBarTests/SpendChartDayHitTargetTests.swift b/Tests/CodexBarTests/SpendChartDayHitTargetTests.swift new file mode 100644 index 0000000000..a5689dfa8d --- /dev/null +++ b/Tests/CodexBarTests/SpendChartDayHitTargetTests.swift @@ -0,0 +1,111 @@ +import Foundation +import Testing +@testable import CodexBar + +struct SpendChartDayHitTargetTests { + @Test + func `dense days receive a minimum clickable radius`() { + let days = Self.days(count: 3) + let positions = Dictionary(uniqueKeysWithValues: zip(days, [CGFloat(100), 103, 106])) + + let selected = SpendChartDayHitTarget.nearestDay( + toX: 113, + days: days, + position: { positions[$0] }) + + #expect(selected == days[2]) + } + + @Test + func `sparse days use midpoint-sized targets without capturing distant empty space`() { + let days = Self.days(count: 2) + let positions = Dictionary(uniqueKeysWithValues: zip(days, [CGFloat(20), 60])) + + #expect(SpendChartDayHitTarget.nearestDay( + toX: 39, + days: days, + position: { positions[$0] }) == days[0]) + #expect(SpendChartDayHitTarget.nearestDay( + toX: 100, + days: days, + position: { positions[$0] }) == nil) + } + + @Test + func `space between active days belongs continuously to the nearest day`() { + let days = Self.days(count: 2) + let positions = Dictionary(uniqueKeysWithValues: zip(days, [CGFloat(20), 80])) + + #expect(SpendChartDayHitTarget.nearestDay( + toX: 49, + days: days, + position: { positions[$0] }) == days[0]) + #expect(SpendChartDayHitTarget.nearestDay( + toX: 51, + days: days, + position: { positions[$0] }) == days[1]) + } + + @Test + func `hit testing follows rendered x order rather than input order`() { + let days = Self.days(count: 3) + let positions = [ + days[0]: CGFloat(80), + days[1]: CGFloat(20), + days[2]: CGFloat(50), + ] + + #expect(SpendChartDayHitTarget.nearestDay( + toX: 24, + days: days, + position: { positions[$0] }) == days[1]) + } + + @Test + func `hover retains the current day inside the neighboring boundary hysteresis`() { + let days = Self.days(count: 2) + let positions = Dictionary(uniqueKeysWithValues: zip(days, [CGFloat(20), 80])) + + let selected = SpendChartDayHoverResolver.resolvedDay( + toX: 54, + days: days, + currentDay: days[0], + position: { positions[$0] }) + + // The geometric midpoint is 50, but the 7.2-point hysteresis keeps the first day stable. + #expect(selected == days[0]) + } + + @Test + func `hover changes after the pointer clearly enters the neighboring lane`() { + let days = Self.days(count: 2) + let positions = Dictionary(uniqueKeysWithValues: zip(days, [CGFloat(20), 80])) + + let selected = SpendChartDayHoverResolver.resolvedDay( + toX: 58, + days: days, + currentDay: days[0], + position: { positions[$0] }) + + #expect(selected == days[1]) + } + + @Test + func `hover catches up immediately across multiple columns`() { + let days = Self.days(count: 4) + let positions = Dictionary(uniqueKeysWithValues: zip(days, [CGFloat(20), 50, 80, 110])) + + let selected = SpendChartDayHoverResolver.resolvedDay( + toX: 109, + days: days, + currentDay: days[0], + position: { positions[$0] }) + + #expect(selected == days[3]) + } + + private static func days(count: Int) -> [Date] { + let start = Date(timeIntervalSince1970: 1_700_000_000) + return (0.. Usage & Spend > Models -- State: grouped by model and grouped by tool +- Source pixels: 1184 x 598; implementation pixels: 880 x 620 +- Density normalization: the implementation subscription card was cropped and + scaled to the source height before side-by-side comparison. +- State: 30-day range, grouped by model, token mode, subscription summary visible **Full-view Comparison Evidence** -The signed development build compiled, passed code-sign validation, and launched -successfully from this checkout. Computer Use could read the application's menu -and invoke Settings, but its native pipe again closed before the Settings window -response. A fresh Computer Use runtime failed the same way while the CodexBar -process remained running, so no safe rendered screenshot was available. +The signed development build compiled, passed code-sign validation, launched +successfully from this checkout, and rendered the populated Usage & Spend +settings after its background history scan. The full-window capture verifies +the 30-day controls, summary metrics, one-line subscription card, compact +144-point model chart, ranking controls, and first five ranked models together. **Focused Region Comparison Evidence** -Rendered comparison is blocked. Static and automated evidence confirms: +The focused side-by-side comparison places the supplied two-line subscription +card beside the rendered one-line implementation. Provider, plan, and amount +now share one baseline; rank and icon columns remain aligned; all six rows fit +in materially less vertical space without truncating the three available plan +names. The rendered model chart remains readable directly below the card. -- Gemini, Claude, Kimi, and Antigravity load first-party original-color PNGs. -- MiniMax loads the gradient waveform extracted from its first-party brand package. -- Cursor loads the official `CUBE_2D_LIGHT.svg` geometry. -- Original-color icons are not marked as AppKit template images. -- Monochrome official marks follow the foreground color for light/dark contrast. -- Chart-series colors no longer recolor provider marks. -- Token bars now encode input, cache read, cache write, output, and reasoning - categories with a matching legend. -- Daily spend hover data carries the exact day, tool type, model, token count, - and estimated cost. -- Tool cards aggregate token buckets once at tool level and keep per-model rows - compact. +Rendered and automated evidence also confirms: -Static inspection and passing tests are not substitutes for visual comparison. +- The Usage & Spend hierarchy uses one controlled optical scale: 15-point + semibold tool titles, 14-point regular model titles, and caption-sized + secondary metadata. This keeps the surfaces consistent while making the + tool/model relationship immediately visible. +- Provider glyphs use an 18-point optical size inside a consistent 22-point + alignment frame. +- Ranking and tool-card spacing was tightened without shrinking the primary + content or changing unrelated CodexBar surfaces. +- Model rankings default to five rows, preserve ordinal numbers, and expose a + compact token/estimated-spend sort control. Expanding and changing the sort + are independent, persistent interactions. +- Both history charts share the same 144-point plot height, leaving more room + for the actionable ranked and selected-day details below. +- Token and estimated-spend modes are merged into one token-category chart; + estimated cost is shown alongside token totals in the concise day tooltip. +- The 7-day rolling-average control was removed from the primary UI because it + obscured the exact daily values used by hover and pinned details. +- Hover now contains only date, total tokens, and estimated spend. Bucket and + per-model splits remain in the pinned day detail below the chart. +- Daily estimated spend now follows the same hover/click contract as the model + chart: hover is a two-value summary, click pins the date, and the tool/model + breakdown is rendered below the chart with explicit tool-kind badges. +- Tool cards now use a true parent/child layout: the tool header owns the first + column, the token summary aligns with the tool title, and the complete model + list moves 48 points into a second column. Child-only dividers no longer run + through the parent column. +- Both charts resolve clicks against screen-space day positions. Dense dates + now form continuous nearest-day lanes, so there is no dead strip between + adjacent active dates. The first and last lanes extend by at least 14 points + while genuinely distant outer space stays empty. +- Both charts support click-and-drag scrubbing across dates. Hovered or pinned + dates receive a translucent 18-point column highlight plus the persistent + accent rule, and the chart surface uses the pointing-hand cursor. +- Model rankings now carry ordinal numbers, and pinned-day plus tool-card model + rows use their resolved model-provider icon instead of a generic blue square + or the containing tool's icon. +- Ranking rows use a stable two-column hierarchy. The right column owns the + selected metric and its share: token mode renders compact token counts, while + estimated-spend mode renders full currency values. The left subtitle contains + only explanatory bucket or complementary-token context. +- The ranking metric control now owns the complete model-analysis state. Token + mode charts daily token buckets with token-formatted Y-axis labels; estimated + spend mode charts the sum of priced model costs for each day with + currency-formatted Y-axis labels. Pinned-day details follow the same metric. +- Hover and click now resolve dates from the same rendered screen-space lanes. + Hover adds a bounded 12-percent hysteresis zone around adjacent lane + boundaries, while large multi-column movements still catch up immediately. +- Ranking model rows now share the pinned-day model row's system body font, + 16-point provider icon, and 20-point icon frame, with reduced row padding. +- Aggregate-only token rows label their complementary estimate explicitly, and + the disclosure is now a compact capsule with a directional chevron instead of + looking like an unstyled line of body text. +- Existing provider-icon, token-chart, hover-detail, and quota-warning work in + the checkout was preserved. **Findings** -- [P2] Final native rendering remains visually unverified - - Location: Usage & Spend model chart, tool cards, and daily-spend hover. - - Evidence: the signed app launched, but Computer Use closed its native pipe - while opening Settings. - - Impact: final small-size antialiasing and row alignment cannot be assessed - from a captured implementation screenshot. - - Fix: capture grouped-by-model and grouped-by-tool states once the native - accessibility bridge is stable. +- No actionable P0/P1/P2 visual differences remain in the requested + subscription-density and chart-height scope. **Comparison History** @@ -56,23 +121,143 @@ Static inspection and passing tests are not substitutes for visual comparison. - Iteration 3: semantic token stacks, exact day hover details, tool-type labels, and tool-level token aggregation replaced visually ambiguous single-blue stacks and repeated per-model metadata. +- Iteration 4: fixed 13/14-point text was replaced by the macOS semantic + typography hierarchy, with consistent icon alignment and denser but readable + model/tool rows. +- Iteration 5: duplicated token/spend modes and the rolling-average switch were + removed; hover was reduced to two headline values, while ranks and + model-provider icons were added to the scan-heavy lists. +- Iteration 6: the optical hierarchy now distinguishes 15-point tool titles from + 14-point model rows; ranking defaults to five rows and can sort by token or + spend; both charts use a 168-point plot and the daily chart gained pinned + date details. +- Iteration 7: model rows moved into a visibly indented child column with scoped + dividers, and chart clicks gained bounded screen-space hit targets plus a + stronger selected-day rule. +- Iteration 8: independent click radii were replaced with continuous + nearest-day Voronoi lanes; click-and-drag scrubbing, a pointing-hand cursor, + and full-column interaction highlighting were added to both charts. +- Iteration 9: ranking rows moved the active metric out of the subtitle and into + a dedicated right-aligned value column above the percentage. Estimated spend + gained currency formatting, token and spend context were separated, and the + expand control gained a clear disclosure treatment. +- Iteration 10: the token/estimated-spend ranking control was promoted to a + shared chart metric. Switching it now changes daily bar heights, Y-axis units, + selected-day details, valid interaction dates, legend visibility, and ranking + order together while preserving the approved hit-target behavior. +- Iteration 11: hover date selection was moved from calendar-distance lookup to + the chart's rendered date lanes and given bounded boundary hysteresis. + Ranking typography, provider-icon size, and row rhythm were matched to the + pinned-day model list to reduce density and remove the visual jump. +- Iteration 12: the complete Usage & Spend surface was audited for typography + drift. Model rows, values, tool titles, section titles, metadata, badges, + axes, legends, disclosures, and segmented controls now use one shared + 15/14/13/12/11-point hierarchy. Model rows across pinned-day, ranking, + daily-spend, and tool cards share the same 16-point icon in a 20-point frame. +- Iteration 13: token and estimated-spend chart modes now share one stable + vertical structure. Both render a single accent-colored daily-total bar; the + token-only five-item legend was removed, while the semantic input, output, + cache, and reasoning buckets remain available in the pinned-day detail. +- Iteration 14: pinned-day model names remain 14-point primary content while + their token splits and estimated costs move to 12-point secondary content. +- Iteration 15: subscription rows use a quieter 13-point name/value tier with + 11-point rank and plan metadata, 18-point provider marks, and tighter row + rhythm so the provider summary no longer competes with model analysis. + Ranking and aligned monetary values use a 14-point medium weight instead of + semibold, and Simplified Chinese now renders the complementary metric as + “令牌” instead of the mixed-script “token 用量”. +- Iteration 15: model ranking rows were flattened into a single scan line. + Token sorting now shows cost as its compact complementary value, spend + sorting shows total tokens, and the selected value plus share stay together + at the trailing edge. Input, output, cache, and reasoning splits remain in + the pinned-day detail instead of repeating throughout the ranking. +- Iteration 16: pinned-day model rows now default to one summary value and + share instead of repeating every token bucket. Token mode keeps the aggregate + bucket composition once at day level; spend mode replaces it with explicit + priced-model coverage. Per-model token splits are available through a compact + disclosure and reset when the selected day or metric changes. +- Iteration 17: native QA first captured a stale running process that still + showed plan names on a second line. After quitting and relaunching the freshly + packaged app, its background scan produced a true one-line provider/plan + layout. The focused side-by-side comparison verifies the intended density, + and both history charts now use a 144-point plot. +- Iteration 18: cumulative model charts now treat isolated legacy samples as + framing noise only when they precede a gap of at least 21 days, represent no + more than 10% of active dates, and contribute no more than 5% of either tokens + or spend. The scan evaluates qualifying gaps from left to right, so a larger + normal gap later in the history cannot preserve a negligible early island. + Rankings, totals, and drill-down history retain every sample. +- Iteration 18 native capture is pending. The packaged app passed signing and + model-domain regression tests, but the local SkyComputerUseService repeatedly + crashed while resolving three installed CodexBar copies with the same bundle + identifier. The attempted isolated QA copy could not be created because the + execution approval service had reached its usage limit. Do not treat the + pre-restart February-axis screenshot as evidence for the final build. **Implementation Checklist** - Capture model, tool, and daily-spend hover states from the development app. -- Verify current Gemini multicolor star, Kimi blue dot, MiniMax gradient, Claude - coral mark, and Antigravity full-color mark at 20 points. -- Verify Cursor and OpenAI remain legible in light and dark appearance. +- Verify the 15-point semibold tool title reads as one level above the 14-point + model row without making MiniMax Code or other provider titles look oversized. +- Verify captions remain readable at default macOS text size and that long + localized labels do not collide with values. +- Verify provider icons remain optically balanced inside their 22-point frames + in light and dark appearance. - Verify tooltip placement does not obscure the hovered bar at narrow and wide Settings window widths. +- Verify the compact tooltip shows only total tokens and estimated spend, and + clicking a bar exposes the detailed token buckets below. +- Verify both ranking sort modes, the five-row collapsed state, expansion, and + collapse remain readable with long localized model names. +- Verify the daily-spend click target pins the intended day and presents the + tool/model hierarchy below without duplicating the large hover card. +- Verify the 48-point child indent remains readable at the narrowest supported + Settings width and that long model values still retain adequate trailing room. +- Verify every position between adjacent active dates selects the intended + nearest day, dragging scrubs without toggling dates off, and distant outer + regions do not unexpectedly select old activity. +- Verify rank numbers align as one column and that model icons remain correct + when the model is used through a different tool provider. +- Verify long model names leave enough room for the 84-point metric column, + currency values retain symbols and decimals, and the disclosure capsule + remains compact in all supported localizations. +- Verify switching to estimated spend visibly rescales the chart to currency, + removes the token-bucket legend, and preserves the selected-day click and + drag behavior; switching back must restore token buckets without stale dates. +- Verify slow movement near a midpoint remains on the current date until the + pointer clearly enters the neighboring lane, while a quick sweep across + several columns follows immediately. +- Verify ranking and pinned-day model rows now have matching optical title and + icon sizes, and that five collapsed rows fit comfortably in the panel. +- Verify the shared 15/14/13/12/11-point hierarchy remains visually distinct + without any default SwiftUI font leaking into model, tool, subscription, + chart-axis, legend, badge, or disclosure content. +- Verify switching between token and estimated-spend modes leaves the ranking + control and first ranked row at the same vertical position, and clicking a + token bar still reveals every available semantic token bucket. +- Verify long pinned-day token splits read as secondary data rather than + competing with model names, and that ranking currency values no longer look + like a second set of section headings. +- Verify collapsed and expanded rankings stay one line per model in both sort + modes, long model names truncate before the trailing value, and no token + bucket breakdown leaks back into the ranking. +- Verify token mode shows one aggregate category breakdown, spend mode shows + priced-model coverage instead of token categories, unpriced rows remain + understandable, and expanding one model reveals only that model's buckets. +- Verify cumulative token and spend charts begin near the first meaningful + activity cluster when a negligible legacy island precedes a 21+ day gap, and + that meaningful early activity remains visible. **Open Questions** -- None about intended behavior; only rendered native evidence is missing. +- Re-run the cumulative-model native capture after the desktop-control service + is available; compare the first visible axis label against the actual first + meaningful activity cluster. **Follow-up Polish** -- Adjust per-icon optical sizing only if the eventual native capture shows a - first-party asset appearing materially smaller than its peers. +- Adjust individual optical icon sizing or row spacing only if the eventual + native capture reveals a concrete imbalance; keep the semantic type hierarchy + shared across providers and tools. final result: blocked From 23f84a9a0230386d94d714f3defa328e304def8a Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Wed, 29 Jul 2026 00:41:04 +0800 Subject: [PATCH 13/14] test: update intentionallyUnchanged list in LocalizationLanguageCatalogTests for new spend strings --- .../LocalizationLanguageCatalogTests.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift index 51ea9ebbc5..2adbc4d92b 100644 --- a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift +++ b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift @@ -518,15 +518,27 @@ struct LocalizationLanguageCatalogTests { + "Sources are not deduplicated across providers.", "No", "Oasis-Token", + "Observed history for the same model and time range; workload differences still apply.", "Other", "Output", "Partial model history: incomplete source-days are excluded.", "Password", "Provider", + "Reuse unavailable", + "Same-model comparison", + "Show fewer models", "Token", "Tokens", "%@ %@", + "%@ per 1M tokens", "%@: %@", + "%d days", + "%d days covered", + "%d models", + "%d projects", + "%d sessions", + "%d%% context reuse", + "%d%% reuse", "byte_unit_byte", "byte_unit_gigabyte", "byte_unit_kilobyte", From a587488d2a470addff2d3f85e3e4153bb32754d2 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Wed, 29 Jul 2026 01:03:48 +0800 Subject: [PATCH 14/14] fix: restore persistedDepletion check to prevent suppressing fresh quota depletion alerts --- Sources/CodexBar/UsageStore+SessionQuotaTransition.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBar/UsageStore+SessionQuotaTransition.swift b/Sources/CodexBar/UsageStore+SessionQuotaTransition.swift index 98bc1eae43..2f284b13ff 100644 --- a/Sources/CodexBar/UsageStore+SessionQuotaTransition.swift +++ b/Sources/CodexBar/UsageStore+SessionQuotaTransition.swift @@ -69,8 +69,10 @@ extension UsageStore { return } let previousState = self.sessionQuotaTransitionStates[provider] + let persistedDepletion = self.persistedSessionQuotaDepletionProviders() let suppressRepeatedStartupDepletion = previousState == nil && - SessionQuotaNotificationLogic.isDepleted(currentRemaining) + SessionQuotaNotificationLogic.isDepleted(currentRemaining) && + persistedDepletion.contains(provider) let forceBaseline = provider == .codex && self.codexSessionQuotaBaselineRequirement != nil let evaluation = SessionQuotaTransitionReducer.evaluate( previous: previousState,