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/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 83e7f31038..b22eeac54e 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("Cumulative") default: return codexBarLocalizedInteger(days) } return template.replacingOccurrences( @@ -24,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 { @@ -47,14 +49,10 @@ func spendDashboardModelHistoryPresentation( struct SpendDashboardPane: View { @Bindable var settings: SettingsStore @Bindable var store: UsageStore - @State private var controller: SpendDashboardController - - init(settings: SettingsStore, store: UsageStore) { - self.settings = settings - self.store = store - self._controller = State(initialValue: SpendDashboardController(requestBuilder: { mode in - await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) - })) + @Bindable var controller: SpendDashboardController + private static let automaticReloadInterval: TimeInterval = 5 * 60 + private var selectedModelDays: Int { + self.controller.selectedDays } var body: some View { @@ -69,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) } } @@ -106,10 +102,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() @@ -145,18 +142,44 @@ struct SpendDashboardPane: View { .frame(maxWidth: .infinity, minHeight: 220) } } 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) + SpendCurrencySection( + group: group, + requestedDays: self.controller.model.requestedDays, + subscriptionNames: subscriptionNames, + modelAnalysis: group.id == modelHostGroupID + ? self.controller.model.modelAnalysis(for: self.selectedModelDays) + : nil, + modelChartDomain: group.id == modelHostGroupID + ? self.controller.model.modelChartDomain(for: self.selectedModelDays) + : nil, + activityAnalysis: group.id == modelHostGroupID + ? self.controller.model.modelAnalysis(for: 365) + : nil) } } 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 { @@ -189,10 +212,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) @@ -229,6 +281,10 @@ 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? var body: some View { VStack(alignment: .leading, spacing: 12) { @@ -268,9 +324,19 @@ private struct SpendCurrencySection: View { } } - SpendProviderPanel(group: self.group) - SpendModelPanel(group: self.group) + SpendProviderPanel(group: self.group, subscriptionNames: self.subscriptionNames) + if let modelAnalysis { + SpendModelsSection( + analysis: modelAnalysis, + chartDomain: self.modelChartDomain, + selectedDays: self.requestedDays) + } SpendDailyChart(group: self.group) + if let activityAnalysis { + SpendDashboardPanel { + SpendActivityHeatmapView(analysis: activityAnalysis) + } + } } } } @@ -293,6 +359,7 @@ private struct SpendSummaryValue: View { private struct SpendProviderPanel: View { let group: SpendDashboardModel.CurrencyGroup + let subscriptionNames: [String: String] var body: some View { SpendDashboardPanel { @@ -308,7 +375,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) @@ -323,65 +401,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 @@ -415,38 +434,79 @@ 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( 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) } else { - Chart(self.group.dailyPoints) { point in - BarMark( - x: .value(L("Day"), point.day, unit: .day), - yStart: .value(L("Estimated spend"), point.stackStart), - yEnd: .value(L("Estimated spend"), point.stackEnd), - width: .ratio(0.72)) - .foregroundStyle(by: .value(L("Provider"), point.providerName)) - .accessibilityLabel(Text(self.pointAccessibilityLabel(point))) - .accessibilityValue(Text(UsageFormatter.currencyString( - point.cost, - currencyCode: self.group.currencyCode))) + 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.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( @@ -456,12 +516,79 @@ 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) + .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)], + alignment: .leading, + spacing: 7) + { + 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) + .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 { + 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 { @@ -474,10 +601,147 @@ 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 { + 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)) + } + } } -private struct SpendProviderIcon: View { +struct SpendProviderIcon: View { let provider: UsageProvider + var size: CGFloat = 20 var body: some View { Group { @@ -487,12 +751,13 @@ private struct SpendProviderIcon: View { Image(systemName: "circle.dotted") } } - .frame(width: 20, height: 20) + .foregroundStyle(.primary) + .frame(width: self.size, height: self.size) .accessibilityHidden(true) } } -private struct SpendDashboardPanel: View { +struct SpendDashboardPanel: View { @ViewBuilder let content: Content var body: some View { 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 new file mode 100644 index 0000000000..8f60ba85ed --- /dev/null +++ b/Sources/CodexBar/PreferencesSpendModelsView.swift @@ -0,0 +1,1049 @@ +import AppKit +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: L("Tokens") + case .estimatedSpend: L("Estimated spend") + } + } +} + +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") + } + } +} + +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)) + } + + 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 + { + 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 + 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 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, + 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 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? + /// 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? + @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. + @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 { + 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 + 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) + } + } + + var body: some View { + SpendDashboardPanel { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .firstTextBaseline) { + Text(L("Models")) + .font(.headline) + Spacer() + Picker(L("View"), selection: self.$viewMode) { + ForEach(SpendModelsViewMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + .labelsHidden() + .pickerStyle(.segmented) + .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 { + self.chart + if self.selectedMetric == .tokens { + self.tokenLegend + } + if let pinnedDay = self.pinnedDay, + let detail = SpendModelsDayDetailPresentation( + analysis: self.analysis, + day: pinnedDay, + metric: self.selectedMetric) + { + SpendModelsDayDetailView( + detail: detail, + metric: self.selectedMetric, + colorForModel: { _ in Color.accentColor }) + } + self.ranking + } + if self.presentation.coverage == .partial { + Text(L("Partial model history: incomplete source-days are excluded.")) + .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 { + 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)) + .foregroundStyle(Color.secondary.opacity(0.4)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 3])) + } + 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.tooltip(selectedDay) + } + } + } + .chartXScale( + domain: self.chartDomain ?? self.fallbackDomain, + range: .plotDimension(startPadding: 10, endPadding: 30)) + .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 + AxisGridLine() + .foregroundStyle(Color.secondary.opacity(0.10)) + AxisValueLabel { + if let amount = value.as(Double.self) { + Text(self.axisMetricText(amount)) + .foregroundStyle(.secondary) + } + } + } + } + .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) + .chartOverlay { proxy in + GeometryReader { geo in + 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) + } + } + } + + private var ranking: some View { + self.rankingContent + } + + private var rankingContent: some 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: 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)) + .font(SpendModelsListStyle.secondaryFont) + .foregroundStyle(.secondary) + .monospacedDigit() + .lineLimit(1) + } + Spacer() + Text(self.shareText(row.value)) + .font(SpendModelsListStyle.primaryFont.weight(.medium)) + .monospacedDigit() + .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) + } + } + } + + 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 "—" } + return UsageFormatter.percentString(value / total * 100) + } + + private func tooltip(_ day: Date) -> some 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 { + 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) + } + } + } + .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.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.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)! + } + + private var xAxisDates: [Date] { + SpendModelsAxisDates.make( + selectedDays: self.selectedDays, + dataDays: self.chartPresentation.points.map(\.day), + domain: self.chartDomain ?? self.fallbackDomain) + } + + 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 { + 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 { + SpendModelsEnglishFormatter.dayText(day) + } + + 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 = 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 +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/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/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 0000000000..d9c43c5d5c Binary files /dev/null and b/Sources/CodexBar/Resources/ProviderIcon-antigravity.png differ diff --git a/Sources/CodexBar/Resources/ProviderIcon-antigravity.svg b/Sources/CodexBar/Resources/ProviderIcon-antigravity.svg deleted file mode 100644 index a915939711..0000000000 --- a/Sources/CodexBar/Resources/ProviderIcon-antigravity.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/Sources/CodexBar/Resources/ProviderIcon-claude.png b/Sources/CodexBar/Resources/ProviderIcon-claude.png new file mode 100644 index 0000000000..c6c29f77ce Binary files /dev/null and b/Sources/CodexBar/Resources/ProviderIcon-claude.png differ 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 0000000000..a2ccb0ffe9 Binary files /dev/null and b/Sources/CodexBar/Resources/ProviderIcon-gemini.png differ 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 0000000000..213b9b83d6 Binary files /dev/null and b/Sources/CodexBar/Resources/ProviderIcon-kimi.png differ 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/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index be1b7a75dc..9384a5a896 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -1271,6 +1271,18 @@ "Subscriptions" = "الاشتراكات"; "By subscription" = "حسب الاشتراك"; "No model-level history" = "لا يوجد سجل على مستوى النموذج"; +"Tokens" = "Tokens"; +"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" = "لا يوجد سجل للنماذج المسعّرة"; +"Other" = "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." = "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" = "لا يمكن أن ينفد الحد الأسبوعي قبل إعادة التعيين بهذه الوتيرة"; @@ -1280,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 لإزالته."; @@ -1345,3 +1371,25 @@ "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" = "لا يوجد سجل نماذج لكل عميل"; + +"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 55985b0af6..2a89c1d356 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -1270,6 +1270,18 @@ "Subscriptions" = "Subscripcions"; "By subscription" = "Per subscripció"; "No model-level history" = "Sense historial per model"; +"Tokens" = "Tokens"; +"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 hi ha historial de models amb preu"; +"Other" = "Other"; +"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"; @@ -1279,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."; @@ -1344,3 +1370,25 @@ "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"; + +"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 37d2b62165..5e973bd3cd 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -1268,6 +1268,18 @@ "Subscriptions" = "Abonnements"; "By subscription" = "Nach Abonnement"; "No model-level history" = "Kein Verlauf auf Modellebene"; +"Tokens" = "Tokens"; +"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" = "Kein Verlauf bepreister Modelle"; +"Other" = "Other"; +"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"; @@ -1277,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."; @@ -1342,3 +1368,25 @@ "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"; + +"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 5cc6b2defb..f8b56f67e5 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -1266,12 +1266,24 @@ "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"; "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."; +"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"; @@ -1281,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."; @@ -1346,3 +1372,26 @@ "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"; + +"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 90159b2bc2..4dceef36aa 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -1266,6 +1266,18 @@ "Subscriptions" = "Suscripciones"; "By subscription" = "Por suscripción"; "No model-level history" = "No hay historial por modelo"; +"Tokens" = "Tokens"; +"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 hay historial de modelos con precio"; +"Other" = "Other"; +"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"; @@ -1275,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."; @@ -1340,3 +1366,25 @@ "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"; + +"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 789090531a..44d6dc1628 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -1271,6 +1271,18 @@ "Subscriptions" = "اشتراک‌ها"; "By subscription" = "بر اساس اشتراک"; "No model-level history" = "تاریخچه‌ای در سطح مدل وجود ندارد"; +"Tokens" = "Tokens"; +"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" = "تاریخچه مدل قیمت گذاری شده وجود ندارد"; +"Other" = "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." = "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" = "با این روند، سهم هفتگی پیش از بازنشانی تمام نمی‌شود"; @@ -1280,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 را بزنید."; @@ -1345,3 +1371,25 @@ "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" = "بدون سابقه مدل برای هر کلاینت"; + +"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 fafb4abed0..8f84aaa2dc 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -1267,6 +1267,18 @@ "Subscriptions" = "Abonnements"; "By subscription" = "Par abonnement"; "No model-level history" = "Aucun historique au niveau des modèles"; +"Tokens" = "Tokens"; +"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" = "Aucun historique de modèles tarifés"; +"Other" = "Other"; +"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"; @@ -1276,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."; @@ -1341,3 +1367,25 @@ "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"; + +"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 82cc28af19..7883bdab5c 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -1267,6 +1267,18 @@ "Subscriptions" = "Subscricións"; "By subscription" = "Por subscrición"; "No model-level history" = "Sen historial por modelo"; +"Tokens" = "Tokens"; +"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" = "Non hai historial de modelos con prezo"; +"Other" = "Other"; +"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"; @@ -1276,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."; @@ -1341,3 +1367,25 @@ "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"; + +"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 abebb2f911..5b26543c10 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -1271,6 +1271,18 @@ "Subscriptions" = "Langganan"; "By subscription" = "Berdasarkan langganan"; "No model-level history" = "Tidak ada riwayat tingkat model"; +"Tokens" = "Tokens"; +"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" = "Tidak ada riwayat model berharga"; +"Other" = "Other"; +"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"; @@ -1280,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."; @@ -1345,3 +1371,25 @@ "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"; + +"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 402383c328..895ada7916 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -1271,6 +1271,18 @@ "Subscriptions" = "Abbonamenti"; "By subscription" = "Per abbonamento"; "No model-level history" = "Nessuna cronologia a livello di modello"; +"Tokens" = "Tokens"; +"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" = "Nessuno storico dei modelli con prezzo"; +"Other" = "Other"; +"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"; @@ -1280,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."; @@ -1345,3 +1371,25 @@ "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"; + +"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 51f5987788..146f0aaf79 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -1268,6 +1268,18 @@ "Subscriptions" = "サブスクリプション"; "By subscription" = "サブスクリプション別"; "No model-level history" = "モデル別の履歴はありません"; +"Tokens" = "Tokens"; +"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" = "価格設定済みモデルの履歴がありません"; +"Other" = "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." = "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" = "このペースではリセット前に週間枠を使い切れません"; @@ -1277,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 キーを押すと削除できます。"; @@ -1342,3 +1368,25 @@ "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" = "クライアント別のモデル履歴なし"; + +"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 57d7245382..7f631ddcab 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -1235,6 +1235,18 @@ "Subscriptions" = "구독"; "By subscription" = "구독별"; "No model-level history" = "모델별 내역이 없습니다"; +"Tokens" = "Tokens"; +"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" = "가격이 책정된 모델 기록 없음"; +"Other" = "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." = "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" = "이 속도라면 재설정 전에 주간 한도를 소진할 수 없습니다"; @@ -1244,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 키를 누르면 제거됩니다."; @@ -1309,3 +1335,25 @@ "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" = "클이언트별 모델 기록 없음"; + +"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 fbf7cbf4df..1aeec7e912 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -1267,6 +1267,18 @@ "Subscriptions" = "Abonnementen"; "By subscription" = "Per abonnement"; "No model-level history" = "Geen geschiedenis op modelniveau"; +"Tokens" = "Tokens"; +"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" = "Geen geschiedenis van geprijsde modellen"; +"Other" = "Other"; +"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"; @@ -1276,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."; @@ -1341,3 +1367,25 @@ "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"; + +"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 92351c1788..b03911f7f2 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -1271,6 +1271,18 @@ "Subscriptions" = "Subskrypcje"; "By subscription" = "Według subskrypcji"; "No model-level history" = "Brak historii na poziomie modeli"; +"Tokens" = "Tokens"; +"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" = "Brak historii wycenionych modeli"; +"Other" = "Other"; +"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"; @@ -1280,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ąć."; @@ -1345,3 +1371,25 @@ "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"; + +"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 9fc56f647d..7b541ea1df 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -1268,6 +1268,18 @@ "Subscriptions" = "Assinaturas"; "By subscription" = "Por assinatura"; "No model-level history" = "Sem histórico por modelo"; +"Tokens" = "Tokens"; +"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" = "Sem histórico de modelos precificados"; +"Other" = "Other"; +"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"; @@ -1277,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."; @@ -1342,3 +1368,25 @@ "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"; + +"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 debe92c848..3f6993085a 100644 --- a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings @@ -1269,6 +1269,18 @@ "Subscriptions" = "Подписки"; "By subscription" = "По подпискам"; "No model-level history" = "Нет истории по моделям"; +"Tokens" = "Tokens"; +"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" = "Нет истории по тарифицированным моделям"; +"Other" = "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." = "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" = "При таком темпе недельный лимит не может закончиться до сброса"; @@ -1278,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, чтобы удалить его."; @@ -1343,3 +1369,25 @@ "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" = "Нет истории моделей по клиентам"; + +"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 18e1d3c828..91124e64f5 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -1266,6 +1266,18 @@ "Subscriptions" = "Abonnemang"; "By subscription" = "Per abonnemang"; "No model-level history" = "Ingen historik på modellnivå"; +"Tokens" = "Tokens"; +"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" = "Ingen historik för prissatta modeller"; +"Other" = "Other"; +"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"; @@ -1275,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."; @@ -1340,3 +1366,25 @@ "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"; + +"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 686d42e6ef..4adbe95f5b 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -1271,6 +1271,18 @@ "Subscriptions" = "การสมัครสมาชิก"; "By subscription" = "แยกตามการสมัครสมาชิก"; "No model-level history" = "ไม่มีประวัติระดับโมเดล"; +"Tokens" = "Tokens"; +"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" = "ไม่มีประวัติโมเดลที่กำหนดราคา"; +"Other" = "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." = "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" = "ด้วยอัตรานี้ โควตารายสัปดาห์จะไม่หมดก่อนรีเซ็ต"; @@ -1280,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 เพื่อลบ"; @@ -1345,3 +1371,25 @@ "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" = "ไม่มีประวัติโมเดลต่อไคลเอนต์"; + +"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 b8eb70a926..ac3fdecf6f 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -1269,6 +1269,18 @@ "Subscriptions" = "Abonelikler"; "By subscription" = "Aboneliğe göre"; "No model-level history" = "Model düzeyinde geçmiş yok"; +"Tokens" = "Tokens"; +"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" = "Fiyatlandırılmış model geçmişi yok"; +"Other" = "Other"; +"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"; @@ -1278,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."; @@ -1343,3 +1369,25 @@ "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"; + +"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 359780b371..e1cd0c4e20 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -1267,6 +1267,18 @@ "Subscriptions" = "Підписки"; "By subscription" = "За підписками"; "No model-level history" = "Немає історії за моделями"; +"Tokens" = "Tokens"; +"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" = "Немає історії моделей з ціною"; +"Other" = "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." = "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" = "За такого темпу тижневий ліміт не може вичерпатися до скидання"; @@ -1276,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, щоб видалити його."; @@ -1341,3 +1367,25 @@ "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" = "Немає історії моделей за клієнтами"; + +"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 14dbaabac6..0b2bdddc02 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -1268,6 +1268,18 @@ "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" = "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" = "Không có lịch sử mô hình đã định giá"; +"Other" = "Other"; +"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"; @@ -1277,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."; @@ -1342,3 +1368,25 @@ "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"; + +"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 ae94fdb4b8..2d3acffc84 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -1237,12 +1237,24 @@ "Spend unavailable" = "支出数据不可用"; "Model breakdown unavailable" = "模型明细不可用"; "Local estimated history" = "本地估算历史"; -"Coverage" = "覆盖范围"; +"Coverage" = "共同完整覆盖"; "Estimated spend" = "估算支出"; "Tracked tokens" = "已跟踪 token"; "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." = "模型名称会在去除首尾空白并忽略大小写后精确合并;不同提供商之间不会自动去重。"; +"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" = "按此速度,每周额度无法在重置前用完"; @@ -1252,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 键可将其移除。"; @@ -1317,3 +1343,26 @@ "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" = "暂无按工具划分的模型历史"; + +"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 c5a328b8c9..c232b70aec 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -1292,12 +1292,24 @@ "Spend unavailable" = "無法取得支出資料"; "Model breakdown unavailable" = "無法取得模型明細"; "Local estimated history" = "本機預估歷史"; -"Coverage" = "涵蓋範圍"; +"Coverage" = "共同完整涵蓋"; "Estimated spend" = "預估支出"; "Tracked tokens" = "已追蹤 token"; "Subscriptions" = "訂閱"; "By subscription" = "依訂閱"; "No model-level history" = "尚無模型層級歷史"; +"Tokens" = "Tokens"; +"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" = "沒有已計價模型歷史記錄"; +"Other" = "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." = "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" = "依此速度,每週額度無法在重置前用完"; @@ -1307,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 鍵可將其移除。"; @@ -1372,3 +1398,26 @@ "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" = "暫無按工具劃分的模型歷史"; + +"Cumulative %@ tokens as of %@" = "截至 %2$@ 累計 %1$@ 個 Token"; +"%@ tokens in the week of %@" = "%2$@ 當週使用了 %1$@ 個 Token"; 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/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 new file mode 100644 index 0000000000..59bc86f4da --- /dev/null +++ b/Sources/CodexBar/SpendActivityHeatmap.swift @@ -0,0 +1,506 @@ +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) + } + + /// 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 { + /// 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 } + } + + /// GitHub contribution-graph greens (light mode), as a tribute. Level 0 is the empty track. + static func color(forLevel level: Int) -> Color { + switch level { + 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: - 视图 + +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: + SpendActivityWeekGrid( + series: series, + values: SpendActivityLevels.weeklyTotals(series.daily), + cumulative: false) + self.barCaption(text: L("Each column = 1 week")) + case .cumulative: + SpendActivityWeekGrid( + series: series, + values: SpendActivityLevels.cumulativeTotals(SpendActivityLevels.weeklyTotals(series.daily)), + cumulative: true) + 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 + ? 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 + } + } + } + .aspectRatio(CGFloat(self.columns) / CGFloat(self.rows), contentMode: .fit) + } + } + + /// 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..8dae5321b4 --- /dev/null +++ b/Sources/CodexBar/SpendBillingAttribution.swift @@ -0,0 +1,319 @@ +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)) + // 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, + 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 new file mode 100644 index 0000000000..25f1b41a1e --- /dev/null +++ b/Sources/CodexBar/SpendClientsView.swift @@ -0,0 +1,263 @@ +import CodexBarCore +import SwiftUI + +// MARK: - 按工具分组数据 + +/// 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 +} + +/// 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". + let providerName: String + 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 + } + + var displayTitle: String { + self.toolName + } +} + +enum SpendClientBreakdown { + /// 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, identity: SpendToolIdentity, 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 = bySource[contribution.sourceID] + ?? ( + contribution.provider, + SpendToolIdentity.resolve( + provider: contribution.provider, + sourceName: contribution.sourceName, + providerName: contribution.providerName), + 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 = 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 + } + } + + return bySource.map { sourceID, 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) + } + .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( + sourceID: sourceID, + provider: bucket.provider, + 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 } + } + + 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? + } + + private static func completeSum(_ values: [Int?]) -> Int? { + guard values.allSatisfy({ $0 != nil }) else { return nil } + return values.compactMap(\.self).reduce(0, +) + } +} + +// 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) { + SpendProviderIcon( + provider: group.provider, + 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) + .foregroundStyle(.secondary) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(Color.secondary.opacity(0.15), in: Capsule()) + } + Spacer() + Text(self.totalText(group)) + .font(SpendModelsListStyle.primaryFont) + .foregroundStyle(.secondary) + .monospacedDigit() + } + .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) + } + } + .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(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) + } + } + .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 tokenSummary(_ group: SpendClientGroup) -> String? { + var parts: [String] = [] + if let input = group.inputTokens, input > 0 { + parts.append("Input \(UsageFormatter.tokenCountString(input))") + } + if let output = group.outputTokens, output > 0 { + parts.append("Output \(UsageFormatter.tokenCountString(output))") + } + if let cacheRead = group.cacheReadTokens, cacheRead > 0 { + parts.append("Cache read \(UsageFormatter.tokenCountString(cacheRead))") + } + if let cacheWrite = group.cacheCreationTokens, cacheWrite > 0 { + parts.append("Cache write \(UsageFormatter.tokenCountString(cacheWrite))") + } + 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/SpendDashboardController.swift b/Sources/CodexBar/SpendDashboardController.swift index 6f9ec8c361..2fe3d94093 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,15 +68,42 @@ struct SpendDashboardLoadRequest: Sendable { let unavailableSourceIDs: Set let confirmedEmptySourceIDs: Set let codexRequests: [CodexSpendScanRequest] + 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, + miniMaxHomePath: String? = nil, + antigravityHomePath: String? = nil, now: Date, force: Bool) { @@ -79,6 +112,23 @@ struct SpendDashboardLoadRequest: Sendable { self.unavailableSourceIDs = unavailableSourceIDs self.confirmedEmptySourceIDs = confirmedEmptySourceIDs self.codexRequests = codexRequests + 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 } @@ -114,11 +164,51 @@ struct CodexSpendSnapshotLoadContext: Sendable { let includePiSessions: Bool } +struct KimiCodeSpendSnapshotLoadContext: Sendable { + let homePath: String + let now: Date + 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 +} + +struct AntigravitySpendSnapshotLoadContext: Sendable { + let homePath: String + let now: Date + let historyDays: Int +} + enum SpendDashboardSource { typealias CodexSnapshotLoader = @Sendable (CodexSpendSnapshotLoadContext) async throws -> CostUsageTokenSnapshot - - static let scanDays = 30 + 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? + typealias AntigravitySnapshotLoader = @Sendable (AntigravitySpendSnapshotLoadContext) async throws + -> CostUsageTokenSnapshot? + + static let scanDays = 365 @MainActor static func configuration(settings: SettingsStore, store: UsageStore) -> SpendDashboardConfiguration { @@ -142,7 +232,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( @@ -233,6 +324,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( @@ -241,19 +335,74 @@ enum SpendDashboardSource { unavailableSourceIDs: unavailableSourceIDs, confirmedEmptySourceIDs: confirmedEmptySourceIDs, codexRequests: codexRequests, + localHistoryRequests: self.localHistoryRequests(store: store), 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) + }, + geminiSnapshotLoader: { context in + try await self.loadGeminiSnapshot(context) + }, + openCodeSnapshotLoader: { context in + try await self.loadOpenCodeSnapshot(context) + }, + miniMaxSnapshotLoader: { context in + try await self.loadMiniMaxSnapshot(context) + }, + antigravitySnapshotLoader: { context in + try await self.loadAntigravitySnapshot(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) async -> SpendDashboardLoadResult + codexSnapshotLoader: CodexSnapshotLoader, + 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) + }, + antigravitySnapshotLoader: @escaping AntigravitySnapshotLoader = { context in + try await Self.loadAntigravitySnapshot(context) + }) async -> SpendDashboardLoadResult { var inputs = request.capturedInputs var failedSourceIDs = request.unavailableSourceIDs @@ -288,6 +437,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)" }) @@ -299,6 +449,79 @@ enum SpendDashboardSource { failedSourceIDs.insert(sourceID) } } + 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() { + 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) + return SpendDashboardLoadResult( + inputs: [], + failedSourceIDs: failedSourceIDs, + invalidatedSourceIDs: invalidatedSourceIDs) + } catch { + failedSourceIDs.insert(source.sourceID) + } + } let lateInvalidatedSourceIDs = Set(request.codexRequests.compactMap { account in self.currentAuthFingerprint(for: account) == account.authFingerprint ? nil @@ -327,6 +550,128 @@ enum SpendDashboardSource { includePiSessions: context.includePiSessions) } + private static func loadKimiCodeSnapshot( + _ context: KimiCodeSpendSnapshotLoadContext) async throws -> CostUsageTokenSnapshot? + { + try await CostUsageScanExecutor.run { checkCancellation in + try KimiCodeSessionScanner.scanCancellable( + environment: [KimiSettingsReader.codeHomeEnvironmentKey: context.homePath], + historyDays: context.historyDays, + now: context.now, + checkCancellation: checkCancellation) + } + } + + private static func loadGeminiSnapshot( + _ context: GeminiSpendSnapshotLoadContext) async throws -> CostUsageTokenSnapshot? + { + try await CostUsageScanExecutor.run { checkCancellation in + try GeminiSessionScanner.scanCancellable( + environment: [GeminiSessionScanner.cliHomeEnvironmentKey: context.homePath], + historyDays: context.historyDays, + now: context.now, + checkCancellation: checkCancellation) + } + } + + private static func loadOpenCodeSnapshot( + _ context: OpenCodeSpendSnapshotLoadContext) async throws -> CostUsageTokenSnapshot? + { + try await CostUsageScanExecutor.run { checkCancellation in + try OpenCodeSessionScanner.scanCancellable( + environment: [OpenCodeSessionScanner.dataHomeEnvironmentKey: context.homePath], + historyDays: context.historyDays, + now: context.now, + checkCancellation: checkCancellation) + } + } + + private static func loadMiniMaxSnapshot( + _ context: MiniMaxSpendSnapshotLoadContext) async throws -> CostUsageTokenSnapshot? + { + try await CostUsageScanExecutor.run { checkCancellation in + try MiniMaxSessionScanner.scanCancellable( + environment: [MiniMaxSessionScanner.homeEnvironmentKey: context.homePath], + historyDays: context.historyDays, + now: context.now, + checkCancellation: checkCancellation) + } + } + + private static func loadAntigravitySnapshot( + _ context: AntigravitySpendSnapshotLoadContext) async throws -> CostUsageTokenSnapshot? + { + try await CostUsageScanExecutor.run { checkCancellation in + try AntigravitySessionScanner.scanCancellable( + environment: [AntigravitySessionScanner.homeEnvironmentKey: context.homePath], + historyDays: context.historyDays, + now: context.now, + checkCancellation: checkCancellation) + } + } + + /// 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) + } + + /// 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 { @@ -334,6 +679,43 @@ enum SpendDashboardSource { } } + @MainActor + static func localModelHistoryProviders(store: UsageStore) -> [UsageProvider] { + store.enabledProvidersForDisplay().filter { + !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) + } + } + @MainActor static func codexRequests(settings: SettingsStore, store: UsageStore) -> [CodexSpendScanRequest] { let accounts = settings.codexVisibleAccountProjection.visibleAccounts @@ -650,6 +1032,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 @@ -662,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 @@ -717,12 +1101,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 @@ -883,8 +1269,10 @@ 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 self.isRefreshing = false self.phase = .ordinary self.loadTask = nil @@ -918,7 +1306,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) @@ -931,6 +1321,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) @@ -944,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) } @@ -993,6 +1402,7 @@ final class SpendDashboardController { provider: input.provider, displayName: displayName, modelProviderName: input.modelProviderName, + subscriptionName: input.subscriptionName, snapshot: input.snapshot) } @@ -1044,6 +1454,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+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 c3ed207f96..1c262ab946 100644 --- a/Sources/CodexBar/SpendDashboardModel.swift +++ b/Sources/CodexBar/SpendDashboardModel.swift @@ -7,19 +7,27 @@ 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). + var costSource: CostUsageCostSource { + self.snapshot.costSource + } + init( id: String? = nil, 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 } } @@ -29,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 @@ -51,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 @@ -59,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 { @@ -66,11 +129,157 @@ 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 inputTokens: Int? + let outputTokens: Int? + let cacheReadTokens: Int? + let cacheCreationTokens: Int? + let reasoningTokens: 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 modelProvider: UsageProvider + let rawModelNames: [String] + let providers: [UsageProvider] + let providerNames: [String] + let contributions: [ModelSourceContribution] + let totalTokens: Int? + 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, + modelProvider: UsageProvider? = nil, + 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.modelProvider = modelProvider ?? SpendProviderIdentity.modelProvider( + rawNames: rawModelNames, + fallbackProviders: providers) + 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 { + let modelID: String + let modelName: String + let day: Date + let totalTokens: Int? + 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 { + 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] + var dailySpendDetails: [DailySpendDetail] = [] let totalTokens: Int? let totalCost: Double? let coveredDayCount: Int @@ -84,6 +293,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 +352,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 +387,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 +445,101 @@ 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 + } + + 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 + } + private struct DailyKey: Hashable { let day: Date let sourceID: String @@ -155,11 +548,14 @@ 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 } +} +extension SpendDashboardModel { private static func buildCurrencyGroup( currencyCode: String, inputs: [ProviderInput], @@ -171,23 +567,41 @@ struct SpendDashboardModel: Equatable, Sendable { 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 } let modelSummary = Self.modelSummary(summaries: completeModelSummaries) + let modelAnalysis = Self.modelAnalysis(summaries: summaries) let modelHistoryCompleteness = completeModelSummaries.count == summaries.count ? ModelHistoryCompleteness.complete : ModelHistoryCompleteness.incomplete + // 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, - totalTokens: Self.completeIntSum(providers.map(\.totalTokens)), - totalCost: Self.completeCostSum(providers.map(\.totalCost)), + 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 + // 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) @@ -230,11 +644,27 @@ struct SpendDashboardModel: Equatable, Sendable { 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. + // + // 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 { + availableDailyCostSum + } else { + nil + } return InputSummary( input: input, entries: entries, @@ -262,6 +692,7 @@ struct SpendDashboardModel: Equatable, Sendable { 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) @@ -351,6 +782,442 @@ 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) + 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) + 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 = 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 + 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 + 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 + 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: buckets.inputTokens, + outputTokens: buckets.outputTokens, + estimatedCost: estimatedCost, + cacheReadTokens: buckets.cacheReadTokens, + cacheCreationTokens: buckets.cacheCreationTokens, + reasoningTokens: buckets.reasoningTokens, + costIsEstimated: aggregate.sawEstimatedCost) + } + .sorted(by: self.modelAnalysisRowOrder) + } + + 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) + 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, + to: dailyValue.inputTokens, + overflowed: &dailyValue.overflowedInputTokens) + dailyValue.outputTokens = Self.add( + 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: &source.cacheReadTokens, + saw: &source.sawCacheReadTokens, + missing: &source.missingCacheReadTokens, + overflowed: &source.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: &source.cacheCreationTokens, + saw: &source.sawCacheCreationTokens, + missing: &source.missingCacheCreationTokens, + overflowed: &source.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: &source.reasoningTokens, + saw: &source.sawReasoningTokens, + missing: &source.missingReasoningTokens, + overflowed: &source.overflowedReasoningTokens) + Self.addOptionalTokenBucket( + split.reasoning, + into: &dailyValue.reasoningTokens, + saw: &dailyValue.sawReasoningTokens, + missing: &dailyValue.missingReasoningTokens, + overflowed: &dailyValue.overflowedReasoningTokens) + } else { + aggregate.invalidTokenSplit = true + source.invalidTokenSplit = true + dailyValue.invalidTokenSplit = true + } + 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) + } + + 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. + 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) -> ModelTokenSplit? + { + 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, + // Reasoning is billed as output, so it can never exceed the output bucket. + breakdown.reasoningTokens.map({ $0 >= 0 && $0 <= output }) ?? true + else { + return nil + } + + 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 { + 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 identity = SpendModelIdentity(rawName: rawName, provider: provider) + return (identity.id, identity.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) @@ -478,9 +1345,14 @@ struct SpendDashboardModel: Equatable, Sendable { 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) @@ -507,6 +1379,7 @@ struct SpendDashboardModel: Equatable, Sendable { sourceID: key.sourceID, provider: value.provider, providerName: value.providerName, + toolKind: value.toolKind, day: day, cost: cost, stackStart: start, @@ -516,6 +1389,93 @@ struct SpendDashboardModel: Equatable, Sendable { } } + 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 @@ -533,6 +1493,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, @@ -642,6 +1612,23 @@ struct SpendDashboardModel: Equatable, Sendable { 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 new file mode 100644 index 0000000000..f5d04171eb --- /dev/null +++ b/Sources/CodexBar/SpendModelIdentity.swift @@ -0,0 +1,307 @@ +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 = Self.brandStyledDisplayName(pretty, canonicalID: canonical) + } + } + + // 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", + "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", + ] + + /// 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 } + 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/CodexBar/SpendProviderIdentity.swift b/Sources/CodexBar/SpendProviderIdentity.swift new file mode 100644 index 0000000000..ec3e9462f7 --- /dev/null +++ b/Sources/CodexBar/SpendProviderIdentity.swift @@ -0,0 +1,93 @@ +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), + (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 + } + + // 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/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/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/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 d5b7849b0b..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 @@ -277,6 +292,15 @@ 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? + /// 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? @@ -288,6 +312,14 @@ public struct CostUsageDailyReport: Sendable, Decodable { case costUSD case cost case totalTokens + case inputTokens + case cacheReadTokens + case cacheCreationTokens + case cacheReadInputTokens + case cacheCreationInputTokens + case outputTokens + case reasoningTokens + case reasoningOutputTokens = "reasoning_output_tokens" case requestCount case requests case standardCostUSD @@ -303,6 +335,17 @@ 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.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) @@ -316,6 +359,11 @@ public struct CostUsageDailyReport: Sendable, Decodable { modelName: String, costUSD: Double?, totalTokens: Int? = nil, + inputTokens: Int? = nil, + cacheReadTokens: Int? = nil, + cacheCreationTokens: Int? = nil, + outputTokens: Int? = nil, + reasoningTokens: Int? = nil, requestCount: Int? = nil, standardCostUSD: Double? = nil, priorityCostUSD: Double? = nil, @@ -325,6 +373,11 @@ 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.reasoningTokens = reasoningTokens self.requestCount = requestCount self.standardCostUSD = standardCostUSD self.priorityCostUSD = priorityCostUSD @@ -530,6 +583,21 @@ 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 reasoningTokens: Int = 0 + var sawReasoningTokens = false + var missingReasoningTokens = false var costUSD: Double = 0 var sawCost = false var standardCostUSD: Double = 0 @@ -546,6 +614,36 @@ 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 reasoningTokens = breakdown.reasoningTokens { + self.reasoningTokens += reasoningTokens + self.sawReasoningTokens = true + } else { + self.missingReasoningTokens = true + } if let costUSD = breakdown.costUSD { self.costUSD += costUSD self.sawCost = true @@ -573,6 +671,13 @@ 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, + 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/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/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index d46bf4b5b2..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 = "48ac20dad61e9a7f" + static let value = "de433e82c0e0fa12" } 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/Antigravity/AntigravityProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift index db210b6069..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), @@ -35,6 +36,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/Antigravity/AntigravitySessionScanner.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravitySessionScanner.swift new file mode 100644 index 0000000000..e57cc28268 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravitySessionScanner.swift @@ -0,0 +1,605 @@ +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? + { + 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) + let end = calendar.startOfDay(for: now) + let start = calendar.date(byAdding: .day, value: -(days - 1), to: end) ?? end + var values: [DayModelKey: TokenAccumulator] = [:] + 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 } + 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, + 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 + // 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 { + try checkCancellation() + 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: pricing.catalog, + cacheRoot: pricing.cacheRoot, + seenResponseIDs: &seenResponseIDs) + { + onRow(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/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 4cb101eb3f..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), @@ -36,6 +37,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/Gemini/GeminiSessionScanner.swift b/Sources/CodexBarCore/Providers/Gemini/GeminiSessionScanner.swift new file mode 100644 index 0000000000..f0458ea008 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Gemini/GeminiSessionScanner.swift @@ -0,0 +1,507 @@ +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? + { + 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( + 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 { + try checkCancellation() + 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 { + 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) + 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/Kimi/KimiCodeSessionScanner.swift b/Sources/CodexBarCore/Providers/Kimi/KimiCodeSessionScanner.swift new file mode 100644 index 0000000000..d988ab17ee --- /dev/null +++ b/Sources/CodexBarCore/Providers/Kimi/KimiCodeSessionScanner.swift @@ -0,0 +1,319 @@ +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 + var cost = 0.0 + var sawCost = false + + 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), + 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 + if let cost = Self.estimatedCost( + model: model, + usage: usage, + pricingDate: pricingDate, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + { + 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 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 + 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 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 + } + + 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( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default, + historyDays: Int = defaultHistoryDays, + now: Date = Date(), + 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) + 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() + 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 { + continue + } + if let modificationDate = try? url.resourceValues(forKeys: [.contentModificationDateKey]) + .contentModificationDate, + modificationDate < start + { + 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 + } + } catch is CancellationError { + throw CancellationError() + } catch { + continue + } + } + + 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, + 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: "Kimi Code CLI", + costSource: .estimated, + 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/KimiProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift index 14d93764e8..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), @@ -34,6 +35,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/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/Providers/MiniMax/MiniMaxProviderDescriptor.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift index 4d02782100..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), @@ -34,6 +35,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/MiniMax/MiniMaxSessionScanner.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSessionScanner.swift new file mode 100644 index 0000000000..e2070915ad --- /dev/null +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSessionScanner.swift @@ -0,0 +1,418 @@ +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, 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 + + /// 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, + 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), + 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.estimatedCost( + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + { + 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? + + /// 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, + 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 } + 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 } + let key = DayModelKey(day: CostUsageLocalDay.key(from: day, calendar: calendar), model: row.model) + var value = values[key] ?? TokenAccumulator() + guard value.add(row, modelsDevCatalog: modelsDevCatalog, modelsDevCacheRoot: modelsDevCacheRoot) + 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", + costSource: .estimated, + 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, + 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 + // 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 + 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 } + + 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/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/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/OpenCode/OpenCodeSessionScanner.swift b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeSessionScanner.swift new file mode 100644 index 0000000000..3491905129 --- /dev/null +++ b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeSessionScanner.swift @@ -0,0 +1,597 @@ +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? + { + 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] = [] + try records.append(contentsOf: self.scanJSONMessages( + environment: environment, + fileManager: fileManager, + context: &context, + checkCancellation: checkCancellation)) + try records.append(contentsOf: self.scanDatabaseMessages( + environment: environment, + 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 } + 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, + checkCancellation: () throws -> Void) throws -> [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 { + try checkCancellation() + 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, + checkCancellation: () throws -> Void) throws -> [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' + 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 [] } + + 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/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/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/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..da8b4ad3ab --- /dev/null +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+ThirdParty.swift @@ -0,0 +1,113 @@ +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 == "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), + ("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) + } + } + + // 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 + } + + 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 8bdbeec25d..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 @@ -1448,6 +1494,10 @@ extension CostUsageScanner { modelName: model, costUSD: cost, totalTokens: totalTokens, + 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 2f61dabada..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) } @@ -816,7 +852,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/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/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/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/CostUsageDailyReportMergeTests.swift b/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift index e7a42293c9..37af3ce96f 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, @@ -163,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/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/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 dd40efcbf7..510b92a34b 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) @@ -235,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() @@ -6415,7 +6513,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/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/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 new file mode 100644 index 0000000000..190205a7a2 --- /dev/null +++ b/Tests/CodexBarTests/KimiCodeSessionScannerTests.swift @@ -0,0 +1,161 @@ +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 == "USD") + #expect(snapshot.last30DaysTokens == 82) + #expect(snapshot.last30DaysRequests == 3) + #expect(snapshot.last30DaysCostUSD != nil) + #expect(snapshot.costSource == .estimated) + #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]) + 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]) + } + + @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) + } + + @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)! + 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..51ea9ebbc5 100644 --- a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift +++ b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift @@ -513,11 +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", "Oasis-Token", + "Other", + "Output", + "Partial model history: incomplete source-days are excluded.", "Password", "Provider", "Token", + "Tokens", "%@ %@", "%@: %@", "byte_unit_byte", diff --git a/Tests/CodexBarTests/MiniMaxSessionScannerTests.swift b/Tests/CodexBarTests/MiniMaxSessionScannerTests.swift new file mode 100644 index 0000000000..a0561a154c --- /dev/null +++ b/Tests/CodexBarTests/MiniMaxSessionScannerTests.swift @@ -0,0 +1,135 @@ +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) + } + + @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 + } + 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 + } + guard oldRowCount > 0 else { return } + for index in 0.. 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/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/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/ShareStatsTests.swift b/Tests/CodexBarTests/ShareStatsTests.swift index adb0a24651..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") @@ -152,6 +166,7 @@ struct ShareStatsTests { coveredDayCount: 7), ], models: rows, + modelAnalysis: .empty, dailyPoints: [], totalTokens: 1, totalCost: nil, @@ -207,6 +222,7 @@ struct ShareStatsTests { totalTokens: nil, totalCost: nil), ], + modelAnalysis: .empty, dailyPoints: [], totalTokens: 10, totalCost: -.infinity, @@ -367,6 +383,7 @@ struct ShareStatsTests { totalTokens: 1000, totalCost: 1), ], + modelAnalysis: .empty, dailyPoints: [], totalTokens: 300, totalCost: 12, @@ -402,6 +419,7 @@ struct ShareStatsTests { totalTokens: 200, totalCost: 4) }, + modelAnalysis: .empty, dailyPoints: [], totalTokens: nil, totalCost: nil, diff --git a/Tests/CodexBarTests/SpendBillingAttributionTests.swift b/Tests/CodexBarTests/SpendBillingAttributionTests.swift new file mode 100644 index 0000000000..1ef62a22da --- /dev/null +++ b/Tests/CodexBarTests/SpendBillingAttributionTests.swift @@ -0,0 +1,344 @@ +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 `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( + 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) + #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( + date: String = "2026-07-24", + model: String, + cost: Double, + tokens: Int) -> CostUsageDailyReport.Entry + { + CostUsageDailyReport.Entry( + date: date, + 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/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], diff --git a/Tests/CodexBarTests/SpendDashboardControllerTests.swift b/Tests/CodexBarTests/SpendDashboardControllerTests.swift index 17e398ce42..eee2ce8a0d 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) } @@ -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 { @@ -764,6 +767,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) } @@ -772,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") @@ -1045,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( @@ -1259,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..1fb333eba5 100644 --- a/Tests/CodexBarTests/SpendDashboardDateTruthTests.swift +++ b/Tests/CodexBarTests/SpendDashboardDateTruthTests.swift @@ -310,7 +310,7 @@ struct SpendDashboardDateTruthTests { let usd = try #require(groups.first(where: { $0.currencyCode == "USD" })) #expect(usd.totalCost == 7) - #expect(usd.totalTokens == nil) + #expect(usd.totalTokens == 10) #expect(usd.modelHistoryCompleteness == .complete) #expect(usd.models.map(\.totalCost) == [4, 3]) #expect(usd.models.first(where: { $0.provider == .claude })?.totalTokens == nil) @@ -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]) @@ -396,7 +396,7 @@ struct SpendDashboardDateTruthTests { calendar: Self.calendar).groups.first) #expect(group.totalCost == 20) - #expect(group.totalTokens == nil) + #expect(group.totalTokens == 62) #expect(group.modelHistoryCompleteness == .complete) #expect(group.models.map(\.modelName) == ["overflow", "mismatch", "negative", "valid"]) #expect(group.models.map(\.totalCost) == [7, 6, 5, 2]) @@ -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]) @@ -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) @@ -602,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]) @@ -710,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/SpendDashboardKimiModelTests.swift b/Tests/CodexBarTests/SpendDashboardKimiModelTests.swift new file mode 100644 index 0000000000..809804c76d --- /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/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 9223e633ca..4bb39e7e13 100644 --- a/Tests/CodexBarTests/SpendDashboardModelTests.swift +++ b/Tests/CodexBarTests/SpendDashboardModelTests.swift @@ -3,28 +3,75 @@ import Foundation import Testing @testable import CodexBar +// swiftlint:disable type_body_length struct SpendDashboardModelTests { @Test func `count labels avoid plural agreement and localize numbers`() { CodexBarLocalizationOverride.$appLanguage.withValue("en") { #expect(spendDashboardRefreshFailureText(1) == "Refresh failures: 1") #expect(spendDashboardRefreshFailureText(2) == "Refresh failures: 2") - #expect(spendDashboardCoverageText(covered: 3, requested: 7) == "Coverage: 3 / 7") + #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) == "۱۲") #expect(spendDashboardDayRangeText(7) == "۷ روز") #expect(spendDashboardDayRangeText(30) == "۳۰ روز") + #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 @@ -71,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( @@ -180,6 +246,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( @@ -260,8 +382,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"]) @@ -319,6 +441,443 @@ 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 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: [ + 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" })) + + // Explicit buckets are carried as-is: cache reads no longer fold into the input bucket. + #expect(split.totalTokens == 100) + #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(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 func `ISO history stays Gregorian while preserving the injected timezone`() throws { let timeZone = try #require(TimeZone(secondsFromGMT: 7 * 60 * 60)) @@ -395,7 +954,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) } @@ -418,6 +977,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 @@ -513,7 +1076,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 @@ -595,7 +1158,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 +1318,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", @@ -815,6 +1380,7 @@ struct SpendDashboardModelTests { currency: String, entries: [CostUsageDailyReport.Entry], historyDays: Int = 30, + costSource: CostUsageCostSource = .estimated, updatedAt: Date = now) -> CostUsageTokenSnapshot { CostUsageTokenSnapshot( @@ -824,6 +1390,7 @@ struct SpendDashboardModelTests { last30DaysCostUSD: nil, currencyCode: currency, historyDays: historyDays, + costSource: costSource, daily: entries, updatedAt: updatedAt) } @@ -869,3 +1436,5 @@ struct SpendDashboardModelTests { return calendar } } + +// swiftlint:enable type_body_length diff --git a/Tests/CodexBarTests/SpendDashboardSourceConcurrencyTests.swift b/Tests/CodexBarTests/SpendDashboardSourceConcurrencyTests.swift index fda8fb8878..43d9d74159 100644 --- a/Tests/CodexBarTests/SpendDashboardSourceConcurrencyTests.swift +++ b/Tests/CodexBarTests/SpendDashboardSourceConcurrencyTests.swift @@ -5,6 +5,235 @@ 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 `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 @@ -102,7 +331,7 @@ struct SpendDashboardSourceConcurrencyTests { } @Test - func `Codex removal relabels retained failed account from second to first`() async throws { + func `Codex removal retains failed account across billing aggregation`() async throws { let gate = SpendDashboardResultBatchGate() let requestGate = SpendDashboardProviderBatchGate() let initialRequests = [ @@ -155,10 +384,9 @@ struct SpendDashboardSourceConcurrencyTests { controller.update(configuration: replacement) let pendingRows = try #require(controller.model.groups.first?.providers) - #expect(Dictionary(uniqueKeysWithValues: pendingRows.map { ($0.id, $0.displayName) }) == [ - "codex:b": "Codex · #1", - "codex:c": "Codex · #2", - ]) + #expect(pendingRows.count == 1) + #expect(pendingRows.first?.displayName == "Codex") + #expect(pendingRows.first?.totalCost == 12) await Self.waitForProviderGate(requestGate) #expect(await gate.pendingCount == 0) await requestGate.resume() @@ -169,11 +397,9 @@ struct SpendDashboardSourceConcurrencyTests { await Self.waitUntil { !controller.isRefreshing } let finalRows = try #require(controller.model.groups.first?.providers) - #expect(Dictionary(uniqueKeysWithValues: finalRows.map { ($0.id, $0.displayName) }) == [ - "codex:b": "Codex · #1", - "codex:c": "Codex · #2", - ]) - #expect(finalRows.first { $0.id == "codex:b" }?.totalCost == 5) + #expect(finalRows.count == 1) + #expect(finalRows.first?.displayName == "Codex") + #expect(finalRows.first?.totalCost == 13) #expect(controller.failedSourceCount == 1) } @@ -527,6 +753,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 { @@ -585,6 +835,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..ac5f86fec3 --- /dev/null +++ b/Tests/CodexBarTests/SpendModelIdentityTests.swift @@ -0,0 +1,132 @@ +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") + #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( + 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 + 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 new file mode 100644 index 0000000000..139a55ebf0 --- /dev/null +++ b/Tests/CodexBarTests/SpendModelsPresentationTests.swift @@ -0,0 +1,455 @@ +import Foundation +import Testing +@testable import CodexBar + +struct SpendModelsPresentationTests { + @Test + func `dashboard range labels stay English and preserve compact order`() { + #expect([7, 30, 365].map(spendDashboardDayRangeText) == ["7d", "30d", "Cumulative"]) + } + + @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) + } + + @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] = [], + cacheReadTokens: Int? = nil, + cacheCreationTokens: Int? = nil, + reasoningTokens: Int? = nil, + costIsEstimated: Bool = false) -> SpendDashboardModel.ModelAnalysisRow + { + SpendDashboardModel.ModelAnalysisRow( + id: id, + displayName: id, + rawModelNames: [id], + providers: [], + providerNames: providers, + contributions: [], + totalTokens: tokens, + inputTokens: inputTokens, + outputTokens: outputTokens, + 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) +} 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/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 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 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.