diff --git a/Sources/CodexBar/CostHistoryChartMenuView.swift b/Sources/CodexBar/CostHistoryChartMenuView.swift index 6152c035a9..dbe3965734 100644 --- a/Sources/CodexBar/CostHistoryChartMenuView.swift +++ b/Sources/CodexBar/CostHistoryChartMenuView.swift @@ -12,19 +12,28 @@ struct CostHistoryChartMenuView: View { case edges } + /// What the bar chart plots on the Y axis. + enum ChartMetric: Equatable { + case cost + case tokens + } + private struct Point: Identifiable { let id: String let date: Date - let costUSD: Double + let costUSD: Double? let totalTokens: Int? let requestCount: Int? + /// Value used for bar height (cost dollars or token count). + let chartValue: Double - init(date: Date, costUSD: Double, totalTokens: Int?, requestCount: Int?) { + init(date: Date, costUSD: Double?, totalTokens: Int?, requestCount: Int?, chartValue: Double) { self.date = date self.costUSD = costUSD self.totalTokens = totalTokens self.requestCount = requestCount - self.id = "\(Int(date.timeIntervalSince1970))-\(costUSD)" + self.chartValue = chartValue + self.id = "\(Int(date.timeIntervalSince1970))-\(chartValue)" } } @@ -75,7 +84,10 @@ struct CostHistoryChartMenuView: View { } var body: some View { - let model = Self.makeModel(provider: self.provider, daily: self.daily) + let model = Self.makeModel( + provider: self.provider, + daily: self.daily, + historyDays: self.historyDays) let selectedDateKey = self.selectedDateKey ?? Self.defaultSelectedDateKey(model: model) VStack(alignment: .leading, spacing: Self.outerSpacing) { if model.points.isEmpty { @@ -88,25 +100,38 @@ struct CostHistoryChartMenuView: View { ForEach(model.points) { point in BarMark( x: .value(L("Day"), point.date, unit: .day), - y: .value(L("Cost"), point.costUSD)) - .foregroundStyle(model.barColor) + y: .value(model.yAxisTitle, point.chartValue)) + // Match Codex cost-chart bar width / gap feel. + .foregroundStyle(model.barColor) } if let peak = Self.peakPoint(model: model) { - let capStart = max(peak.costUSD - Self.capHeight(maxValue: model.maxCostUSD), 0) + let capStart = max(peak.chartValue - Self.capHeight(maxValue: model.maxChartValue), 0) BarMark( x: .value(L("Day"), peak.date, unit: .day), yStart: .value(L("Cap start"), capStart), - yEnd: .value(L("Cap end"), peak.costUSD)) + yEnd: .value(L("Cap end"), peak.chartValue)) .foregroundStyle(Color(nsColor: .systemYellow)) } } + // Leave headroom above the tallest bar (Codex charts read less cramped at the top). + .chartYScale(domain: 0...(model.maxChartValue > 0 + ? model.maxChartValue * Self.yAxisHeadroom + : 1)) .chartYAxis { - AxisMarks(position: .leading, values: Self.yAxisTickValues(maxCostUSD: model.maxCostUSD)) { value in + AxisMarks( + position: .leading, + values: Self.yAxisTickValues( + maxValue: model.maxChartValue, + metric: model.chartMetric)) + { value in AxisGridLine().foregroundStyle(Color.clear) AxisTick().foregroundStyle(Color.clear) AxisValueLabel(centered: false) { if let raw = value.as(Double.self) { - Text(Self.yAxisCostString(raw, currencyCode: self.currencyCode)) + Text(Self.yAxisLabelString( + raw, + metric: model.chartMetric, + currencyCode: self.currencyCode)) .font(.caption2) .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) .padding(.leading, 4) @@ -234,8 +259,8 @@ struct CostHistoryChartMenuView: View { alignment: .topLeading) } - if let total = self.totalCostUSD { - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: 2) { + if let total = self.totalCostUSD { Text(String( format: L("Est. total (%@): %@"), self.windowLabel ?? Self.windowLabel(days: self.historyDays), @@ -245,13 +270,31 @@ struct CostHistoryChartMenuView: View { .lineLimit(1) .truncationMode(.head) .frame(height: Self.detailPrimaryLineHeight, alignment: .leading) - if let disclaimer = Self.estimateDisclaimer(provider: self.provider) { - Text(disclaimer) - .font(.caption2) - .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) - .lineLimit(1) - .truncationMode(.tail) - } + } + if let tokenTotal = Self.windowTokenTotal(daily: self.daily), tokenTotal > 0 { + Text(String( + format: L("Total tokens (%@): %@"), + self.windowLabel ?? Self.windowLabel(days: self.historyDays), + UsageFormatter.tokenCountString(tokenTotal))) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.head) + .frame(height: Self.detailPrimaryLineHeight, alignment: .leading) + } + if let breakdown = Self.windowTokenBreakdownLine(daily: self.daily) { + Text(breakdown) + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + } + if let disclaimer = Self.estimateDisclaimer(provider: self.provider) { + Text(disclaimer) + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) } } @@ -310,6 +353,31 @@ struct CostHistoryChartMenuView: View { } } + static func chartMetric(for provider: UsageProvider, daily: [DailyEntry]) -> ChartMetric { + // Provider-specific by design: Grok often omits cost ticks; plot tokens so activity remains visible. + if provider == .grok { return .tokens } + // Fall back to tokens for any provider when every day lacks cost. + let hasAnyCost = daily.contains { ($0.costUSD ?? 0) > 0 } + return hasAnyCost ? .cost : .tokens + } + + static func windowTokenTotal(daily: [DailyEntry]) -> Int? { + let sum = daily.compactMap(\.totalTokens).reduce(0, +) + return sum > 0 ? sum : nil + } + + static func windowTokenBreakdownLine(daily: [DailyEntry]) -> String? { + let input = daily.compactMap(\.inputTokens).reduce(0, +) + let cache = daily.compactMap(\.cacheReadTokens).reduce(0, +) + let output = daily.compactMap(\.outputTokens).reduce(0, +) + guard input + cache + output > 0 else { return nil } + return String( + format: L("Uncached %@ · Cache %@ · Output %@"), + UsageFormatter.tokenCountString(input), + UsageFormatter.tokenCountString(cache), + UsageFormatter.tokenCountString(output)) + } + private struct Model { let points: [Point] let pointsByDateKey: [String: Point] @@ -318,7 +386,9 @@ struct CostHistoryChartMenuView: View { let axisDates: [Date] let barColor: Color let peakKey: String? - let maxCostUSD: Double + let maxChartValue: Double + let chartMetric: ChartMetric + let yAxisTitle: String let detailViewportRowCount: Int let hasDetailOverflow: Bool let detailRowHeight: CGFloat @@ -334,10 +404,14 @@ struct CostHistoryChartMenuView: View { private static let detailSpacing: CGFloat = 6 private static let detailHintHeight: CGFloat = 13 private static let chartHeight: CGFloat = 130 + /// Extra vertical domain above peak so bars don't sit flush with the chart top. + private static let yAxisHeadroom: Double = 1.18 private static let outerSpacing: CGFloat = 10 + /// Shared with Credits/Codex cost charts for a consistent bar palette. + private static let chartBarColor = Color(red: 73 / 255, green: 163 / 255, blue: 176 / 255) private static let projectRowHeight: CGFloat = 31 private static let projectRowSpacing: CGFloat = 5 - private static let maxVisibleProjectRows = 5 + private static let maxVisibleProjectRows = 10 private static let projectSourceRowHeight: CGFloat = 29 private static let projectSourceSpacing: CGFloat = 3 private static let projectSourceIndent: CGFloat = 10 @@ -451,131 +525,14 @@ struct CostHistoryChartMenuView: View { maxValue * 0.05 } - /// Y-axis tick values for the cost chart: 0, mid, max when the range is at - /// $1 or more; 0 and max for smaller ranges; empty for flat/no data so the - /// axis renders no labels. - private static func yAxisTickValues(maxCostUSD: Double) -> [Double] { - guard maxCostUSD > 0 else { return [] } - if maxCostUSD < 1.0 { - return [0, maxCostUSD] - } - return [0, maxCostUSD / 2, maxCostUSD] - } - - private static func makeModel(provider: UsageProvider, daily: [DailyEntry]) -> Model { - let sorted = daily.sorted { lhs, rhs in lhs.date < rhs.date } - var points: [Point] = [] - points.reserveCapacity(sorted.count) - - var pointsByKey: [String: Point] = [:] - pointsByKey.reserveCapacity(sorted.count) - - var entriesByKey: [String: DailyEntry] = [:] - entriesByKey.reserveCapacity(sorted.count) - - var dateKeys: [(key: String, date: Date)] = [] - dateKeys.reserveCapacity(sorted.count) - - var peak: (key: String, costUSD: Double)? - var maxCostUSD: Double = 0 - var maxDetailRows = 0 - var hasModeDetails = false - for entry in sorted { - guard let (costUSD, date) = self.chartPointInput(for: entry) else { continue } - let point = Point( - date: date, - costUSD: costUSD, - totalTokens: entry.totalTokens, - requestCount: entry.requestCount) - points.append(point) - pointsByKey[entry.date] = point - entriesByKey[entry.date] = entry - dateKeys.append((entry.date, date)) - let modelBreakdowns = entry.modelBreakdowns ?? [] - maxDetailRows = max(maxDetailRows, modelBreakdowns.count) - hasModeDetails = hasModeDetails || modelBreakdowns.contains { Self.hasModeSubtitle($0) } - if let cur = peak { - if costUSD > cur.costUSD { - peak = (entry.date, costUSD) - } - } else { - peak = (entry.date, costUSD) - } - maxCostUSD = max(maxCostUSD, costUSD) - } - - let axisDates: [Date] = { - guard let first = dateKeys.first?.date, let last = dateKeys.last?.date else { return [] } - if Calendar.current.isDate(first, inSameDayAs: last) { - return [first] - } - return [first, last] - }() - - let barColor = Self.barColor(for: provider) - return Model( - points: points, - pointsByDateKey: pointsByKey, - entriesByDateKey: entriesByKey, - dateKeys: dateKeys, - axisDates: axisDates, - barColor: barColor, - peakKey: maxCostUSD > 0 ? peak?.key : nil, - maxCostUSD: maxCostUSD, - detailViewportRowCount: min(maxDetailRows, self.maxVisibleDetailLines), - hasDetailOverflow: maxDetailRows > self.maxVisibleDetailLines, - detailRowHeight: hasModeDetails ? self.expandedDetailRowHeight : self.compactDetailRowHeight) - } - - private static func axisLabelPlacement(for dates: [Date]) -> AxisLabelPlacement { - switch dates.count { - case 0: .hidden - case 1: .centered - default: .edges - } - } - - private static func xAxisLabelAnchor(for date: Date, axisDates: [Date]) -> UnitPoint { - switch self.axisLabelPlacement(for: axisDates) { - case .hidden, .centered: - .top - case .edges: - if let first = axisDates.first, Calendar.current.isDate(date, inSameDayAs: first) { - .topLeading - } else if let last = axisDates.last, Calendar.current.isDate(date, inSameDayAs: last) { - .topTrailing - } else { - .top - } + /// Y-axis tick values: 0, mid, max for large ranges; 0 and max for small; empty for flat data. + private static func yAxisTickValues(maxValue: Double, metric: ChartMetric) -> [Double] { + guard maxValue > 0 else { return [] } + let smallThreshold: Double = metric == .cost ? 1.0 : 1000 + if maxValue < smallThreshold { + return [0, maxValue] } - } - - private static func barColor(for provider: UsageProvider) -> Color { - let color = ProviderDescriptorRegistry.descriptor(for: provider).branding.color - return Color(red: color.red, green: color.green, blue: color.blue) - } - - private static func dateFromDayKey(_ key: String) -> Date? { - let parts = key.split(separator: "-") - guard parts.count == 3, - let year = Int(parts[0]), - let month = Int(parts[1]), - let day = Int(parts[2]) else { return nil } - - var comps = DateComponents() - comps.calendar = Calendar.current - comps.timeZone = TimeZone.current - comps.year = year - comps.month = month - comps.day = day - comps.hour = 12 - return comps.date - } - - private static func chartPointInput(for entry: DailyEntry) -> (costUSD: Double, date: Date)? { - guard let costUSD = entry.costUSD, costUSD >= 0 else { return nil } - guard let date = self.dateFromDayKey(entry.date) else { return nil } - return (costUSD, date) + return [0, maxValue / 2, maxValue] } private static func peakPoint(model: Model) -> Point? { @@ -624,13 +581,15 @@ struct CostHistoryChartMenuView: View { static func visibleProjectSources( _ project: CostUsageProjectBreakdown) -> [CostUsageProjectSourceBreakdown] { - guard project.sources.count == 1 else { return project.sources } - guard let source = project.sources.first, source.path != project.path else { return [] } - return [source] + project.visibleSourcesForDisplay } private static func defaultSelectedDateKey(model: Model) -> String? { - model.dateKeys.last?.key + // Prefer the latest day with activity so empty zero-fill days aren't selected first. + if let active = model.dateKeys.last(where: { (model.pointsByDateKey[$0.key]?.chartValue ?? 0) > 0 }) { + return active.key + } + return model.dateKeys.last?.key } private func selectionBandRect(model: Model, proxy: ChartProxy, geo: GeometryProxy) -> CGRect? { @@ -780,13 +739,17 @@ struct CostHistoryChartMenuView: View { } let dayLabel = date.formatted(.dateTime.month(.abbreviated).day()) - let cost = self.costString(point.costUSD) - var parts = [cost] + var parts: [String] = [] + if let cost = point.costUSD { + parts.append(self.costString(cost)) + } else { + parts.append("—") + } if let tokens = point.totalTokens { parts.append("\(UsageFormatter.tokenCountString(tokens)) tokens") } if let requests = point.requestCount { - parts.append("\(UsageFormatter.tokenCountString(requests)) requests") + parts.append("\(UsageFormatter.tokenCountString(requests)) calls") } let primary = "\(dayLabel): \(parts.joined(separator: " · "))" return DetailContent(primary: primary, rows: self.breakdownRows(key: key, model: model)) @@ -794,9 +757,24 @@ struct CostHistoryChartMenuView: View { private func breakdownRows(key: String, model: Model) -> [DetailRow] { guard let entry = model.entriesByDateKey[key] else { return [] } - guard let breakdown = entry.modelBreakdowns, !breakdown.isEmpty else { return [] } + var rows: [DetailRow] = [] + + // Token composition row (uncached / cache / output) when available. + if entry.inputTokens != nil || entry.cacheReadTokens != nil || entry.outputTokens != nil { + let uncached = entry.inputTokens.map(UsageFormatter.tokenCountString) ?? "—" + let cache = entry.cacheReadTokens.map(UsageFormatter.tokenCountString) ?? "—" + let output = entry.outputTokens.map(UsageFormatter.tokenCountString) ?? "—" + rows.append(DetailRow( + id: "token-breakdown-\(key)", + title: L("Token breakdown"), + subtitle: String(format: L("Uncached %@ · Cache %@ · Output %@"), uncached, cache, output), + modeSubtitle: nil, + accentColor: model.barColor.opacity(0.9))) + } + + guard let breakdown = entry.modelBreakdowns, !breakdown.isEmpty else { return rows } - return Self.orderedBreakdownItems(breakdown) + rows.append(contentsOf: Self.orderedBreakdownItems(breakdown) .enumerated() .map { index, item in DetailRow( @@ -805,7 +783,8 @@ struct CostHistoryChartMenuView: View { subtitle: self.modelBreakdownTotalSubtitle(item), modeSubtitle: self.modelBreakdownModeSubtitle(item), accentColor: model.barColor.opacity(Self.breakdownAccentOpacity(for: index))) - } + }) + return rows } static func orderedBreakdownItems( @@ -876,6 +855,25 @@ struct CostHistoryChartMenuView: View { UsageFormatter.currencyString(value, currencyCode: currencyCode) } + private static func yAxisLabelString( + _ value: Double, + metric: ChartMetric, + currencyCode: String) -> String + { + switch metric { + case .cost: + return self.yAxisCostString(value, currencyCode: currencyCode) + case .tokens: + if value >= 1_000_000 { + return String(format: "%.1fM", value / 1_000_000) + } + if value >= 1000 { + return String(format: "%.0fK", value / 1000) + } + return String(format: "%.0f", value) + } + } + private static func yAxisCostString(_ value: Double, currencyCode: String) -> String { UsageFormatter.compactCurrencyString(value, currencyCode: currencyCode) } @@ -886,6 +884,217 @@ struct CostHistoryChartMenuView: View { } } +extension CostHistoryChartMenuView { + // MARK: - Chart model building + + private static func makeModel( + provider: UsageProvider, + daily: [DailyEntry], + historyDays: Int = 30) -> Model + { + let metric = self.chartMetric(for: provider, daily: daily) + let sorted = daily.sorted { lhs, rhs in lhs.date < rhs.date } + + var entriesByKey: [String: DailyEntry] = [:] + entriesByKey.reserveCapacity(sorted.count) + var maxDetailRows = 0 + var hasModeDetails = false + for entry in sorted { + entriesByKey[entry.date] = entry + let modelBreakdowns = entry.modelBreakdowns ?? [] + var detailRowCount = modelBreakdowns.count + if entry.inputTokens != nil || entry.cacheReadTokens != nil || entry.outputTokens != nil { + detailRowCount += 1 + } + maxDetailRows = max(maxDetailRows, detailRowCount) + hasModeDetails = hasModeDetails || modelBreakdowns.contains { Self.hasModeSubtitle($0) } + } + + // Continuous day range (like Codex) so sparse Grok days don't collapse bar gaps. + let dayKeys = self.continuousDayKeys(from: sorted, historyDays: historyDays) + var points: [Point] = [] + points.reserveCapacity(dayKeys.count) + var pointsByKey: [String: Point] = [:] + pointsByKey.reserveCapacity(dayKeys.count) + var dateKeys: [(key: String, date: Date)] = [] + dateKeys.reserveCapacity(dayKeys.count) + + var peak: (key: String, value: Double)? + var maxChartValue: Double = 0 + for dayKey in dayKeys { + guard let date = self.dateFromDayKey(dayKey) else { continue } + let entry = entriesByKey[dayKey] + let chartValue: Double + let costUSD: Double? + let totalTokens: Int? + let requestCount: Int? + if let entry { + if let parsed = self.chartPointInput(for: entry, metric: metric) { + chartValue = parsed.chartValue + costUSD = parsed.costUSD + totalTokens = entry.totalTokens + requestCount = entry.requestCount + } else if metric == .tokens { + chartValue = Double(entry.totalTokens ?? 0) + costUSD = entry.costUSD + totalTokens = entry.totalTokens + requestCount = entry.requestCount + } else { + chartValue = entry.costUSD ?? 0 + costUSD = entry.costUSD + totalTokens = entry.totalTokens + requestCount = entry.requestCount + } + } else { + // Empty day placeholder keeps bar spacing even. + chartValue = 0 + costUSD = nil + totalTokens = 0 + requestCount = nil + } + let point = Point( + date: date, + costUSD: costUSD, + totalTokens: totalTokens, + requestCount: requestCount, + chartValue: chartValue) + points.append(point) + pointsByKey[dayKey] = point + dateKeys.append((dayKey, date)) + if chartValue > 0 { + if let cur = peak { + if chartValue > cur.value { + peak = (dayKey, chartValue) + } + } else { + peak = (dayKey, chartValue) + } + maxChartValue = max(maxChartValue, chartValue) + } + } + + let axisDates: [Date] = { + guard let first = dateKeys.first?.date, let last = dateKeys.last?.date else { return [] } + if Calendar.current.isDate(first, inSameDayAs: last) { + return [first] + } + return [first, last] + }() + + // Grok uses Codex teal; other providers keep their branding colors. + let barColor = Self.barColor(for: provider) + let yAxisTitle = metric == .tokens ? L("Tokens") : L("Cost") + return Model( + points: points, + pointsByDateKey: pointsByKey, + entriesByDateKey: entriesByKey, + dateKeys: dateKeys, + axisDates: axisDates, + barColor: barColor, + peakKey: maxChartValue > 0 ? peak?.key : nil, + maxChartValue: maxChartValue, + chartMetric: metric, + yAxisTitle: yAxisTitle, + detailViewportRowCount: min(maxDetailRows, self.maxVisibleDetailLines), + hasDetailOverflow: maxDetailRows > self.maxVisibleDetailLines, + detailRowHeight: hasModeDetails ? self.expandedDetailRowHeight : self.compactDetailRowHeight) + } + + /// Continuous `YYYY-MM-DD` keys for the last `historyDays` ending at the latest data day + /// (falls back to first→last span when history is shorter). + private static func continuousDayKeys(from sorted: [DailyEntry], historyDays: Int) -> [String] { + guard let last = sorted.last?.date, let end = self.dateFromDayKey(last) else { + return sorted.map(\.date) + } + let calendar = Calendar.current + let endDay = calendar.startOfDay(for: end) + let span = max(1, historyDays) + let startDay = calendar.date(byAdding: .day, value: -(span - 1), to: endDay) ?? endDay + // Prefer full history window; if data starts later, still fill from window start (zeros). + var keys: [String] = [] + var cursor = startDay + while cursor <= endDay { + keys.append(Self.dayKey(from: cursor)) + guard let next = calendar.date(byAdding: .day, value: 1, to: cursor) else { break } + cursor = next + } + return keys.isEmpty ? sorted.map(\.date) : keys + } + + private static func dayKey(from date: Date) -> String { + let comps = Calendar.current.dateComponents([.year, .month, .day], from: date) + return String(format: "%04d-%02d-%02d", comps.year ?? 1970, comps.month ?? 1, comps.day ?? 1) + } + + private static func axisLabelPlacement(for dates: [Date]) -> AxisLabelPlacement { + switch dates.count { + case 0: .hidden + case 1: .centered + default: .edges + } + } + + private static func xAxisLabelAnchor(for date: Date, axisDates: [Date]) -> UnitPoint { + switch self.axisLabelPlacement(for: axisDates) { + case .hidden, .centered: + .top + case .edges: + if let first = axisDates.first, Calendar.current.isDate(date, inSameDayAs: first) { + .topLeading + } else if let last = axisDates.last, Calendar.current.isDate(date, inSameDayAs: last) { + .topTrailing + } else { + .top + } + } + } + + private static func barColor(for provider: UsageProvider) -> Color { + // Provider-specific by design: Grok cost charts share the Codex teal palette. + if provider == .grok { + return self.chartBarColor + } + let color = ProviderDescriptorRegistry.descriptor(for: provider).branding.color + return Color(red: color.red, green: color.green, blue: color.blue) + } + + private static func dateFromDayKey(_ key: String) -> Date? { + let parts = key.split(separator: "-") + guard parts.count == 3, + let year = Int(parts[0]), + let month = Int(parts[1]), + let day = Int(parts[2]) else { return nil } + + var comps = DateComponents() + comps.calendar = Calendar.current + comps.timeZone = TimeZone.current + comps.year = year + comps.month = month + comps.day = day + comps.hour = 12 + return comps.date + } + + /// Builds a chart point for a daily entry. + /// - Cost metric: requires a non-nil cost (legacy Codex behavior). + /// - Tokens metric: includes any day with token activity, even when cost is missing. + private static func chartPointInput( + for entry: DailyEntry, + metric: ChartMetric) -> (date: Date, chartValue: Double, costUSD: Double?)? + { + guard let date = self.dateFromDayKey(entry.date) else { return nil } + switch metric { + case .cost: + guard let costUSD = entry.costUSD, costUSD >= 0 else { return nil } + return (date, costUSD, costUSD) + case .tokens: + let tokens = entry.totalTokens ?? 0 + guard tokens > 0 || (entry.costUSD ?? 0) > 0 else { return nil } + return (date, Double(tokens), entry.costUSD) + } + } +} + extension CostHistoryChartMenuView { struct RenderFingerprint: Equatable { let currencyCode: String @@ -947,8 +1156,10 @@ extension CostHistoryChartMenuView { from snapshot: CostUsageTokenSnapshot, provider: UsageProvider) -> RenderFingerprint { - let projects = provider == .codex ? snapshot.projects : [] - let sessions = provider == .codex ? snapshot.sessions : [] + // Provider-specific by design: project/session lists only for local session providers. + let projects = (provider == .codex || provider == .grok) ? snapshot.projects : [] + let sessions = (provider == .codex || provider == .grok) ? snapshot.sessions : [] + let metric = self.chartMetric(for: provider, daily: snapshot.daily) return RenderFingerprint( currencyCode: snapshot.currencyCode, historyDays: snapshot.historyDays, @@ -956,7 +1167,7 @@ extension CostHistoryChartMenuView { totalCostBitPattern: snapshot.last30DaysCostUSD.map(\.bitPattern), hasDailyEntries: !snapshot.daily.isEmpty, daily: snapshot.daily - .filter { self.chartPointInput(for: $0) != nil } + .filter { self.chartPointInput(for: $0, metric: metric) != nil } .sorted { $0.date < $1.date } .map(self.visibleDailyFingerprint), projects: Array(projects.prefix(self.maxVisibleProjectRows)).map { project in @@ -1031,7 +1242,7 @@ extension CostHistoryChartMenuView { } static func _yAxisTickValuesForTesting(maxCostUSD: Double) -> [Double] { - self.yAxisTickValues(maxCostUSD: maxCostUSD) + self.yAxisTickValues(maxValue: maxCostUSD, metric: .cost) } static func _yAxisCostStringForTesting(_ value: Double, currencyCode: String = "USD") -> String { diff --git a/Sources/CodexBar/InlineUsageDashboardContent.swift b/Sources/CodexBar/InlineUsageDashboardContent.swift index 5c535e91b7..ab76f961c6 100644 --- a/Sources/CodexBar/InlineUsageDashboardContent.swift +++ b/Sources/CodexBar/InlineUsageDashboardContent.swift @@ -87,10 +87,17 @@ extension UsageMenuCardView.Model { /// Provider branding color for the inline usage bars, matching the provider's switcher tab and /// detailed cost-history chart. static func inlineDashboardBarColor(for provider: UsageProvider) -> Color { + // Provider-specific by design: Grok cost/token history uses Codex teal. + if provider == .grok { + return self.codexStyleChartBarColor + } let color = ProviderDescriptorRegistry.descriptor(for: provider).branding.color return Color(red: color.red, green: color.green, blue: color.blue) } + /// Codex brand teal — shared cost-chart palette (matches Credits/Codex cost bars). + static let codexStyleChartBarColor = Color(red: 73 / 255, green: 163 / 255, blue: 176 / 255) + private static func resolveInlineUsageDashboard(input: Input) -> InlineUsageDashboardModel? { let menuCard = ProviderDescriptorRegistry.descriptor(for: input.provider).presentation.menuCard if menuCard.usesProviderCostHistoryAsPrimaryDashboard, @@ -185,14 +192,14 @@ extension UsageMenuCardView.Model { } else { L("%@ cost", historyDays == 1 ? L("Today") : String(format: L("Last %d days"), historyDays)) } - let points = snapshot.daily.suffix(historyDays).compactMap { entry -> InlineUsageDashboardModel.Point? in - guard let cost = entry.costUSD else { return nil } - return InlineUsageDashboardModel.Point( - id: entry.date, - label: Self.shortDayLabel(entry.date), - value: convertedValue(cost), - accessibilityValue: "\(entry.date): \(convertedString(cost))") - } + // Grok (and sparse-cost histories) plot daily tokens so subscription days without + // costUsdTicks still appear. Cost remains in KPIs/details. + let plotTokens = Self.shouldPlotTokensOnInlineCostChart(provider: provider, snapshot: snapshot) + let points = Self.inlineChartPoints( + snapshot: snapshot, + historyDays: historyDays, + plotTokens: plotTokens, + preferredCurrencyCode: preferredCurrencyCode) let latest = CostUsageTokenSnapshot.latestEntry(in: snapshot.daily) let usesLatestPrimary = tokenCost.primaryValue == .latestDaily let primaryCostUSD = usesLatestPrimary ? latest?.costUSD : snapshot.sessionCostUSD @@ -212,6 +219,13 @@ extension UsageMenuCardView.Model { if let topModel = Self.topCostModel(from: snapshot.daily) { details.append("\(L("Top model")): \(Self.shortModelName(topModel))") } + // Provider-specific by design: Grok adds token composition and project notes on the cost card. + if provider == .grok { + details.append(contentsOf: Self.grokCostHistoryDetailLines(snapshot: snapshot)) + } + if snapshot.historyIsIncomplete { + details.append(L("History incomplete: some large session logs were only partially scanned.")) + } let hintLines = Self.tokenUsageHintLines(provider: provider) if tokenCost.hintPlacement == .beforeRequestHistory { details.append(contentsOf: hintLines) @@ -233,7 +247,9 @@ extension UsageMenuCardView.Model { let accessibilityLabel = L( "%@: %@", providerName, - accessibilityCostLabel) + plotTokens + ? (snapshot.historyLabel.map { "\($0) tokens" } ?? L("token usage")) + : accessibilityCostLabel) var kpis = [ InlineUsageDashboardModel.KPI( title: usesLatestPrimary ? L("Latest") : L("Today"), @@ -245,6 +261,13 @@ extension UsageMenuCardView.Model { .map(convertedString) ?? "—", emphasis: false), ] + // Provider-specific by design: Grok leads with today's tokens to match token bars. + if provider == .grok { + let todayTokens = snapshot.sessionTokens.map(UsageFormatter.tokenCountString) ?? "—" + kpis.insert( + .init(title: L("Today tokens"), value: todayTokens, emphasis: true), + at: 0) + } let tokenHistoryKPI = InlineUsageDashboardModel.KPI( title: tokenHistoryTitle, value: snapshot.last30DaysTokens.map(UsageFormatter.tokenCountString) ?? "—", @@ -267,14 +290,166 @@ extension UsageMenuCardView.Model { } var model = InlineUsageDashboardModel( accessibilityLabel: accessibilityLabel, - valueStyle: Self.costValueStyle(currencyCode: displayCurrencyCode), + valueStyle: plotTokens ? .tokens : Self.costValueStyle(currencyCode: displayCurrencyCode), kpis: kpis, points: points, detailLines: details) - model.currencyCode = displayCurrencyCode + // Provider-specific by design: Grok cost chart bars use Codex teal. + if provider == .grok { + model.barColor = self.codexStyleChartBarColor + } + if !plotTokens { + model.currencyCode = displayCurrencyCode + } return model } + private static func grokCostHistoryDetailLines(snapshot: CostUsageTokenSnapshot) -> [String] { + var details: [String] = [] + let input = snapshot.daily.compactMap(\.inputTokens).reduce(0, +) + let cache = snapshot.daily.compactMap(\.cacheReadTokens).reduce(0, +) + let output = snapshot.daily.compactMap(\.outputTokens).reduce(0, +) + if input + cache + output > 0 { + details.append(String( + format: L("Uncached %@ · Cache %@ · Output %@"), + UsageFormatter.tokenCountString(input), + UsageFormatter.tokenCountString(cache), + UsageFormatter.tokenCountString(output))) + } + let daysWithCost = snapshot.daily.count(where: { ($0.costUSD ?? 0) > 0 }) + details.append(String( + format: L("Cost reported on %d/%d days"), + daysWithCost, + snapshot.daily.count)) + if let topProject = snapshot.projects.first { + let tokens = topProject.totalTokens.map(UsageFormatter.tokenCountString) ?? "—" + details.append(String(format: L("Top project: %@ · %@"), topProject.name, tokens)) + } + return details + } + + /// Prefer token bars when cost history is incomplete (Grok subscription) or absent. + static func shouldPlotTokensOnInlineCostChart( + provider: UsageProvider, + snapshot: CostUsageTokenSnapshot) -> Bool + { + // Provider-specific by design: Grok subscription days often omit cost ticks. + if provider == .grok { return true } + let days = snapshot.daily + guard !days.isEmpty else { return false } + let withCost = days.count(where: { ($0.costUSD ?? 0) > 0 }) + return withCost == 0 + } + + /// Continuous daily points for the main-menu mini chart (zero-fill gaps like Codex). + static func inlineChartPoints( + snapshot: CostUsageTokenSnapshot, + historyDays: Int, + plotTokens: Bool, + preferredCurrencyCode: String = "auto") -> [InlineUsageDashboardModel.Point] + { + let sorted = snapshot.daily.sorted { $0.date < $1.date } + guard let lastKey = sorted.last?.date else { return [] } + guard let end = Self.dateFromDayKey(lastKey) else { + return sorted.suffix(historyDays).compactMap { entry in + self.inlinePoint( + for: entry, + plotTokens: plotTokens, + providerCurrencyCode: snapshot.currencyCode, + preferredCurrencyCode: preferredCurrencyCode) + } + } + let calendar = Calendar.current + let start = calendar.date(byAdding: .day, value: -(historyDays - 1), to: calendar.startOfDay(for: end)) + ?? calendar.startOfDay(for: end) + var byDay: [String: CostUsageDailyReport.Entry] = [:] + for entry in sorted { + byDay[entry.date] = entry + } + var points: [InlineUsageDashboardModel.Point] = [] + var cursor = calendar.startOfDay(for: start) + let endDay = calendar.startOfDay(for: end) + while cursor <= endDay { + let key = Self.dayKey(from: cursor) + if let entry = byDay[key], + let point = self.inlinePoint( + for: entry, + plotTokens: plotTokens, + providerCurrencyCode: snapshot.currencyCode, + preferredCurrencyCode: preferredCurrencyCode) + { + points.append(point) + } else { + points.append(InlineUsageDashboardModel.Point( + id: key, + label: Self.shortDayLabel(key), + value: 0, + accessibilityValue: "\(key): —")) + } + guard let next = calendar.date(byAdding: .day, value: 1, to: cursor) else { break } + cursor = next + } + return points + } + + private static func inlinePoint( + for entry: CostUsageDailyReport.Entry, + plotTokens: Bool, + providerCurrencyCode: String, + preferredCurrencyCode: String) -> InlineUsageDashboardModel.Point? + { + if plotTokens { + let tokens = entry.totalTokens ?? 0 + let costNote: String = { + guard let cost = entry.costUSD else { return "" } + let formatted = UsageFormatter.convertedCostString( + cost, + preferredCurrency: preferredCurrencyCode, + providerCurrency: providerCurrencyCode) + return " · \(formatted)" + }() + return InlineUsageDashboardModel.Point( + id: entry.date, + label: Self.shortDayLabel(entry.date), + value: Double(tokens), + accessibilityValue: "\(entry.date): \(UsageFormatter.tokenCountString(tokens)) tokens\(costNote)") + } + guard let cost = entry.costUSD else { return nil } + let converted = UsageFormatter.convertedCost( + cost, + preferredCurrency: preferredCurrencyCode, + providerCurrency: providerCurrencyCode) + let costString = UsageFormatter.convertedCostString( + cost, + preferredCurrency: preferredCurrencyCode, + providerCurrency: providerCurrencyCode) + return InlineUsageDashboardModel.Point( + id: entry.date, + label: Self.shortDayLabel(entry.date), + value: converted.value, + accessibilityValue: "\(entry.date): \(costString)") + } + + private static func dateFromDayKey(_ key: String) -> Date? { + let parts = key.split(separator: "-") + guard parts.count == 3, + let year = Int(parts[0]), + let month = Int(parts[1]), + let day = Int(parts[2]) else { return nil } + var comps = DateComponents() + comps.calendar = Calendar.current + comps.year = year + comps.month = month + comps.day = day + comps.hour = 12 + return comps.date + } + + private static func dayKey(from date: Date) -> String { + let comps = Calendar.current.dateComponents([.year, .month, .day], from: date) + return String(format: "%04d-%02d-%02d", comps.year ?? 1970, comps.month ?? 1, comps.day ?? 1) + } + private static func costHistoryTrailingKPIs( snapshot: CostUsageTokenSnapshot, latest: CostUsageDailyReport.Entry?) diff --git a/Sources/CodexBar/MenuCardView+Costs.swift b/Sources/CodexBar/MenuCardView+Costs.swift index 87b2dfdf64..51b4dcc99e 100644 --- a/Sources/CodexBar/MenuCardView+Costs.swift +++ b/Sources/CodexBar/MenuCardView+Costs.swift @@ -218,27 +218,75 @@ extension UsageMenuCardView.Model { providerCurrency: snapshot.currencyCode) return String(format: L("Cursor-metered: %@ (%@)"), amount, windowLabel.lowercased()) } + var comparisonLines: [String] = [] + if comparisonPeriodsEnabled { + comparisonLines.append(contentsOf: snapshot.comparisonSummaries().map { + Self.costWindowLine( + summary: $0, + currencyCode: UsageFormatter.effectiveCurrencyCode( + preferred: preferredCurrencyCode, + providerCurrency: snapshot.currencyCode), + sourceCurrencyCode: snapshot.currencyCode) + }) + } + // Provider-specific by design: Grok surfaces token composition on the Cost card. + if provider == .grok { + comparisonLines.append(contentsOf: Self.grokTokenDetailLines(snapshot: snapshot)) + } let err = (error?.isEmpty ?? true) ? nil : error return TokenUsageSection( isRefreshing: isRefreshing, sessionLine: sessionLine, monthLine: monthLine, meteredLine: meteredLine, - comparisonLines: comparisonPeriodsEnabled - ? snapshot.comparisonSummaries().map { - Self.costWindowLine( - summary: $0, - currencyCode: UsageFormatter.effectiveCurrencyCode( - preferred: preferredCurrencyCode, - providerCurrency: snapshot.currencyCode), - sourceCurrencyCode: snapshot.currencyCode) - } - : [], + comparisonLines: comparisonLines, hintLine: Self.tokenUsageHint(provider: provider), errorLine: err, errorCopyText: (error?.isEmpty ?? true) ? nil : error) } + /// Extra Cost-card lines for Grok local session stats. + static func grokTokenDetailLines(snapshot: CostUsageTokenSnapshot) -> [String] { + var lines: [String] = [] + let input = snapshot.daily.compactMap(\.inputTokens).reduce(0, +) + let cache = snapshot.daily.compactMap(\.cacheReadTokens).reduce(0, +) + let output = snapshot.daily.compactMap(\.outputTokens).reduce(0, +) + if input + cache + output > 0 { + lines.append(String( + format: L("Uncached %@ · Cache %@ · Output %@"), + UsageFormatter.tokenCountString(input), + UsageFormatter.tokenCountString(cache), + UsageFormatter.tokenCountString(output))) + } + var modelTotals: [String: Int] = [:] + for entry in snapshot.daily { + for breakdown in entry.modelBreakdowns ?? [] { + modelTotals[breakdown.modelName, default: 0] += breakdown.totalTokens ?? 0 + } + } + if !modelTotals.isEmpty { + let top = modelTotals.sorted { $0.value > $1.value }.prefix(3) + let parts = top.map { "\($0.key) \(UsageFormatter.tokenCountString($0.value))" } + lines.append(String(format: L("Models: %@"), parts.joined(separator: " · "))) + } + if !snapshot.projects.isEmpty { + let topProjects = snapshot.projects.prefix(3) + let parts = topProjects.map { project in + let tokens = project.totalTokens.map(UsageFormatter.tokenCountString) ?? "—" + return "\(project.name) \(tokens)" + } + lines.append(String(format: L("Top projects: %@"), parts.joined(separator: " · "))) + } + let daysWithCost = snapshot.daily.count(where: { ($0.costUSD ?? 0) > 0 }) + if daysWithCost < snapshot.daily.count { + lines.append(String( + format: L("Cost reported on %d/%d days"), + daysWithCost, + snapshot.daily.count)) + } + return lines + } + static func costWindowLine( summary: CostUsageWindowSummary, currencyCode: String, diff --git a/Sources/CodexBar/MenuHighlightStyle.swift b/Sources/CodexBar/MenuHighlightStyle.swift index bb493b5026..25c69f3418 100644 --- a/Sources/CodexBar/MenuHighlightStyle.swift +++ b/Sources/CodexBar/MenuHighlightStyle.swift @@ -2,10 +2,11 @@ import SwiftUI extension EnvironmentValues { @Entry var menuItemHighlighted: Bool = false - /// Optional live-refresh monitor injected into menu card views so the provider card - /// subtitle can reflect the in-flight "Refreshing…" state in place while the NSMenu - /// stays open, without rebuilding the menu during AppKit tracking. - @Entry var menuCardRefreshMonitor: MenuCardRefreshMonitor? + + // Optional live-refresh monitor injected into menu card views so the provider card + // subtitle can reflect the in-flight "Refreshing…" state in place while the NSMenu + // stays open, without rebuilding the menu during AppKit tracking. + @Entry var menuCardRefreshMonitor: MenuCardRefreshMonitor? = nil } enum MenuHighlightStyle { diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index cc7f35d76f..dcd9474011 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -497,6 +497,13 @@ extension SettingsStore { self.userDefaults.set(newValue, forKey: "tokenCostUsageEnabled") if changed { self.costUsageSettingsRevision &+= 1 + // Grok local parse cache retains path/session metadata only while Cost is on. + // Invalidate write tokens + delete so in-flight scans cannot recreate the artifact. + if !newValue { + Task.detached(priority: .utility) { + GrokTurnUsageCacheIO.invalidateAndDelete() + } + } } self.noteBackgroundWorkSettingsChanged() } diff --git a/Sources/CodexBar/SettingsStore+TokenCost.swift b/Sources/CodexBar/SettingsStore+TokenCost.swift index ec1bdb8f8b..8142f6985f 100644 --- a/Sources/CodexBar/SettingsStore+TokenCost.swift +++ b/Sources/CodexBar/SettingsStore+TokenCost.swift @@ -106,6 +106,9 @@ extension SettingsStore { ] + ClaudeDesktopProjectsLocator.roots(homeDirectory: ownerHome, fileManager: fileManager) }() + // Grok session logs are not auto-enable sources: an absent `tokenCostUsageEnabled` + // preference must stay off on upgrade so existing Grok-only installs remain opt-in. + // Users enable Cost tracking explicitly; Grok scanning then runs via the descriptor. return claudeRoots.contains(where: hasAnyJsonl(in:)) } } diff --git a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift index 1dddb9c62b..a2cde5e4d9 100644 --- a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift +++ b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift @@ -419,8 +419,9 @@ extension StatusItemController { currencyCode: tokenSnapshot.currencyCode, historyDays: tokenSnapshot.historyDays, windowLabel: tokenSnapshot.historyLabel, - projects: provider == .codex ? tokenSnapshot.projects : [], - sessions: provider == .codex ? tokenSnapshot.sessions : [], + // Codex + Grok both expose local project/session breakdowns from log scans. + projects: (provider == .codex || provider == .grok) ? tokenSnapshot.projects : [], + sessions: (provider == .codex || provider == .grok) ? tokenSnapshot.sessions : [], width: width) let hosting = MenuHostingView(rootView: chartView) hosting.applyMeasuredHeight( diff --git a/Sources/CodexBarCLI/CLICostCommand.swift b/Sources/CodexBarCLI/CLICostCommand.swift index 9faa15b057..dae228953a 100644 --- a/Sources/CodexBarCLI/CLICostCommand.swift +++ b/Sources/CodexBarCLI/CLICostCommand.swift @@ -48,14 +48,14 @@ extension CodexBarCLI { } let groupBy = Self.decodeCostGroupBy(from: values) if groupBy == .project { - // Provider-specific by design: only Codex JSONL sessions carry the local project attribution index. - let unsupportedProjectProviders = providers.filter { $0 != .codex } + // Codex and Grok both expose local project attribution from session logs. + let unsupportedProjectProviders = providers.filter { $0 != .codex && $0 != .grok } if !unsupportedProjectProviders.isEmpty, !output.jsonOnly { let names = unsupportedProjectProviders .map { ProviderDescriptorRegistry.descriptor(for: $0).metadata.displayName } .sorted() .joined(separator: ", ") - Self.writeStderr("Skipping project grouping for providers without Codex project data: \(names)\n") + Self.writeStderr("Skipping project grouping for providers without project data: \(names)\n") } } @@ -64,8 +64,10 @@ extension CodexBarCLI { var payload: [CostPayload] = [] var exitCode: ExitCode = .success - // Provider-specific by design: project grouping is available only for Codex local session data. - for provider in providers where groupBy != .project || provider == .codex || format == .json { + // Provider-specific by design: project grouping is available for Codex and Grok local session data. + for provider in providers + where groupBy != .project || provider == .codex || provider == .grok || format == .json + { if let error = Self.cursorCostAvailabilityError( provider, settings: cursorCookieSettings, @@ -80,7 +82,7 @@ extension CodexBarCLI { continue } do { - // Claude/Codex cost comes from local logs; Cursor cost is fetched from its + // Claude/Codex/Grok cost comes from local logs; Cursor cost is fetched from its // cookie-authenticated dashboard API via the shared session resolution. let snapshot = try await fetcher.loadTokenSnapshot( provider: provider, @@ -135,13 +137,19 @@ extension CodexBarCLI { useColor: Bool) -> String { let name = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName - // Provider-specific by design: Codex cost is explicitly an API-equivalent local-session estimate. - let title = provider == .codex - ? "\(name) API-equivalent estimate (not billed)" - : "\(name) Cost (API-rate estimate)" + // Provider-specific by design: Codex is an API-equivalent estimate; Grok reports local session ticks. + let title = switch provider { + case .codex: + "\(name) API-equivalent estimate (not billed)" + case .grok: + "\(name) Cost (local session logs)" + default: + "\(name) Cost (API-rate estimate)" + } let header = Self.costHeaderLine(title, useColor: useColor) - if groupBy == .project, provider == .codex { - return Self.renderProjectCostText(header: header, snapshot: snapshot) + // Provider-specific by design: project grouping is available for Codex and Grok local session data. + if groupBy == .project, provider == .codex || provider == .grok { + return Self.renderProjectCostText(provider: provider, header: header, snapshot: snapshot) } let todayCost = snapshot.sessionCostUSD @@ -165,19 +173,84 @@ extension CodexBarCLI { return "Cursor-metered: \(amount) (\(historyLabel.lowercased()))" } + var extraLines: [String] = [] + if snapshot.historyIsIncomplete { + extraLines.append( + "Note: history incomplete — some session logs were only partially scanned (size/budget limits).") + } + // Provider-specific by design: Grok prints token/model/project detail from local session logs. + if provider == .grok { + let input = snapshot.daily.compactMap(\.inputTokens).reduce(0, +) + let cache = snapshot.daily.compactMap(\.cacheReadTokens).reduce(0, +) + let output = snapshot.daily.compactMap(\.outputTokens).reduce(0, +) + if input + cache + output > 0 { + let uncached = UsageFormatter.tokenCountString(input) + let cacheLabel = UsageFormatter.tokenCountString(cache) + let outputLabel = UsageFormatter.tokenCountString(output) + extraLines.append( + "Uncached \(uncached) · Cache \(cacheLabel) · Output \(outputLabel)") + } + var modelTotals: [String: Int] = [:] + for entry in snapshot.daily { + for breakdown in entry.modelBreakdowns ?? [] { + modelTotals[breakdown.modelName, default: 0] += breakdown.totalTokens ?? 0 + } + } + if !modelTotals.isEmpty { + let top = modelTotals.sorted { $0.value > $1.value }.prefix(4) + extraLines.append("Models: " + top.map { + "\($0.key) \(UsageFormatter.tokenCountString($0.value))" + }.joined(separator: " · ")) + } + if !snapshot.projects.isEmpty { + extraLines.append("Projects (\(snapshot.projects.count)):") + for project in snapshot.projects.prefix(8) { + let tokens = project.totalTokens.map(UsageFormatter.tokenCountString) ?? "—" + let cost = project.totalCostUSD.map { + UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) + } ?? "—" + extraLines.append(" \(project.name): \(cost) · \(tokens) tokens") + } + if snapshot.projects.count > 8 { + extraLines.append(" +\(snapshot.projects.count - 8) more") + } + } + if !snapshot.sessions.isEmpty { + extraLines.append("Sessions: \(snapshot.sessions.count)") + } + let daysWithCost = snapshot.daily.count(where: { ($0.costUSD ?? 0) > 0 }) + extraLines.append("Cost reported on \(daysWithCost)/\(snapshot.daily.count) days") + extraLines.append("Daily:") + for entry in snapshot.daily.sorted(by: { $0.date < $1.date }) { + let tokens = entry.totalTokens.map(UsageFormatter.tokenCountString) ?? "—" + let cost = entry.costUSD.map { + UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) + } ?? "—" + let uncached = entry.inputTokens.map(UsageFormatter.tokenCountString) ?? "—" + let cache = entry.cacheReadTokens.map(UsageFormatter.tokenCountString) ?? "—" + let output = entry.outputTokens.map(UsageFormatter.tokenCountString) ?? "—" + extraLines.append( + " \(entry.date): \(cost) · \(tokens) tok (uncached \(uncached) · cache \(cache) · out \(output))") + } + } + let hintLine = Self.costEstimateHint(provider: provider) - return [header, todayLine, monthLine, meteredLine, hintLine] + return ([header, todayLine, monthLine, meteredLine] + extraLines.map { Optional.some($0) } + [hintLine]) .compactMap(\.self) .joined(separator: "\n") } - private static func renderProjectCostText(header: String, snapshot: CostUsageTokenSnapshot) -> String { + private static func renderProjectCostText( + provider: UsageProvider, + header: String, + snapshot: CostUsageTokenSnapshot) -> String + { let historyLabel = snapshot.historyLabel ?? (snapshot.historyDays == 1 ? "Today" : "Last \(snapshot.historyDays) days") var lines = [header, "Projects (\(historyLabel)):"] guard !snapshot.projects.isEmpty else { lines.append("—") - lines.append(Self.costEstimateHint(provider: .codex)) + lines.append(Self.costEstimateHint(provider: provider)) return lines.joined(separator: "\n") } for project in snapshot.projects { @@ -189,7 +262,8 @@ extension CodexBarCLI { if let path = project.path { lines.append(" \(path)") } - for source in project.sources { + // Match menu: omit sole same-path self-sources so Grok project rows are not duplicated. + for source in project.visibleSourcesForDisplay { let sourceCost = source.totalCostUSD .map { UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) } ?? "—" let sourceTokens = source.totalTokens.map { UsageFormatter.tokenCountString($0) } @@ -200,14 +274,25 @@ extension CodexBarCLI { } } } - lines.append(Self.costEstimateHint(provider: .codex)) + // Match non-project renderer: partial/deferred scans must not look like complete totals. + if snapshot.historyIsIncomplete { + lines.append( + "Note: history incomplete — some session logs were only partially scanned (size/budget limits).") + } + lines.append(Self.costEstimateHint(provider: provider)) return lines.joined(separator: "\n") } private static func costEstimateHint(provider: UsageProvider) -> String { - provider == .codex - ? "Not a subscription bill or plan value · local usage × public API prices" - : UsageFormatter.costEstimateHint(provider: provider) + // Provider-specific by design: Codex is an API-rate estimate; Grok reports session ticks. + switch provider { + case .codex: + "Not a subscription bill or plan value · local usage × public API prices" + case .grok: + "Local Grok session logs (turn_completed). Cost only when reported." + default: + UsageFormatter.costEstimateHint(provider: provider) + } } private static func costHeaderLine(_ header: String, useColor: Bool) -> String { @@ -225,7 +310,8 @@ extension CodexBarCLI { error: Error?) -> CostPayload { let daily = snapshot?.daily.map(Self.costDailyPayload(from:)) ?? [] - let projects = provider == .codex + // Provider-specific by design: project rollups only for local session providers. + let projects = (provider == .codex || provider == .grok) ? snapshot?.projects.map { project in CostProjectPayload( name: project.name, @@ -259,6 +345,7 @@ extension CodexBarCLI { last30DaysTokens: snapshot?.last30DaysTokens, last30DaysCostUSD: snapshot?.last30DaysCostUSD, meteredCostUSD: snapshot?.meteredCostUSD, + historyIsIncomplete: snapshot.map(\.historyIsIncomplete), daily: daily, projects: projects, totals: snapshot.flatMap(Self.costTotals(from:)), @@ -493,6 +580,7 @@ struct CostPayload: Encodable, Sendable { let last30DaysTokens: Int? let last30DaysCostUSD: Double? let meteredCostUSD: Double? + let historyIsIncomplete: Bool? let daily: [CostDailyEntryPayload] let projects: [CostProjectPayload] let totals: CostTotalsPayload? @@ -510,6 +598,7 @@ struct CostPayload: Encodable, Sendable { last30DaysTokens: Int?, last30DaysCostUSD: Double?, meteredCostUSD: Double? = nil, + historyIsIncomplete: Bool? = nil, daily: [CostDailyEntryPayload], projects: [CostProjectPayload] = [], totals: CostTotalsPayload?, @@ -526,6 +615,7 @@ struct CostPayload: Encodable, Sendable { self.last30DaysTokens = last30DaysTokens self.last30DaysCostUSD = last30DaysCostUSD self.meteredCostUSD = meteredCostUSD + self.historyIsIncomplete = historyIsIncomplete self.daily = daily self.projects = projects self.totals = totals diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index f459c144ff..801e3395ed 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -473,6 +473,7 @@ public struct CostUsageFetcher: Sendable { historyDays: clampedHistoryDays, calendar: scanOptions.calendar, historyCoverageIsEstablished: scanResult.historyCoverageIsEstablished, + historyIsIncomplete: scanResult.historyIsIncomplete, projects: scanResult.projects, sessions: scanResult.sessions, updatedAt: scanResult.staleSnapshotUpdatedAt) @@ -484,6 +485,7 @@ public struct CostUsageFetcher: Sendable { let sessions: [CostUsageSessionBreakdown] let staleSnapshotUpdatedAt: Date? let historyCoverageIsEstablished: Bool + let historyIsIncomplete: Bool } private struct LocalTokenScanOptions: Sendable { @@ -506,6 +508,28 @@ public struct CostUsageFetcher: Sendable { // These synchronous scans can run for minutes on large archives. The dedicated queue keeps // them off the cooperative pool and bridges task cancellation into scanner-level checks. return try await CostUsageScanExecutor.run { checkCancellation in + // Provider-specific by design: Grok uses a single-pass local session-log scanner. + if provider == .grok { + var grokOptions = GrokTurnUsageScanner.Options() + if let override = options.scanOptions.grokSessionsRoot { + grokOptions.sessionsRoot = override + } + grokOptions.cacheRoot = options.scanOptions.cacheRoot + let bundle = try GrokTurnUsageScanner.loadScanBundle( + since: since, + until: now, + now: now, + options: grokOptions, + checkCancellation: checkCancellation) + return LocalTokenScanResult( + daily: bundle.daily, + projects: bundle.projects, + sessions: bundle.sessions, + staleSnapshotUpdatedAt: nil, + historyCoverageIsEstablished: true, + historyIsIncomplete: bundle.historyIsIncomplete) + } + var daily = try CostUsageScanner.loadDailyReportCancellable( provider: provider, since: since, @@ -592,7 +616,8 @@ public struct CostUsageFetcher: Sendable { sessions: sessions, staleSnapshotUpdatedAt: staleSnapshotUpdatedAt, historyCoverageIsEstablished: provider != .codex - || Self.codexHistoryCoverageIsEstablished(options: options.scanOptions)) + || Self.codexHistoryCoverageIsEstablished(options: options.scanOptions), + historyIsIncomplete: false) } } @@ -1041,6 +1066,7 @@ public struct CostUsageFetcher: Sendable { meteredCostUSD: Double? = nil, credentialScopeFingerprint: String? = nil, historyLabel: String? = nil, + historyIsIncomplete: Bool = false, projects: [CostUsageProjectBreakdown] = [], sessions: [CostUsageSessionBreakdown] = [], updatedAt: Date? = nil) -> CostUsageTokenSnapshot @@ -1079,6 +1105,7 @@ public struct CostUsageFetcher: Sendable { historyDays: historyDays, historyCoverageIsEstablished: historyCoverageIsEstablished, historyLabel: historyLabel, + historyIsIncomplete: historyIsIncomplete, meteredCostUSD: meteredCostUSD, credentialScopeFingerprint: credentialScopeFingerprint, daily: daily.data, diff --git a/Sources/CodexBarCore/CostUsageModels.swift b/Sources/CodexBarCore/CostUsageModels.swift index 322f7d59f5..62d18685a2 100644 --- a/Sources/CodexBarCore/CostUsageModels.swift +++ b/Sources/CodexBarCore/CostUsageModels.swift @@ -89,6 +89,9 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { public let historyDays: Int public let historyCoverageIsEstablished: Bool public let historyLabel: String? + /// True when totals may undercount because some local session logs were only partially + /// scanned (for example oversized archives read under a per-file budget). + public let historyIsIncomplete: Bool /// Provider-metered spend over the same window as `last30DaysCostUSD` — what the plan /// actually deducts, as opposed to the API-rate estimate. Only some providers (e.g. Cursor) /// report this; `nil` when unknown. @@ -112,6 +115,7 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { historyDays: Int = 30, historyCoverageIsEstablished: Bool = true, historyLabel: String? = nil, + historyIsIncomplete: Bool = false, meteredCostUSD: Double? = nil, credentialScopeFingerprint: String? = nil, daily: [CostUsageDailyReport.Entry], @@ -130,6 +134,7 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { self.historyDays = historyDays self.historyCoverageIsEstablished = historyCoverageIsEstablished self.historyLabel = historyLabel + self.historyIsIncomplete = historyIsIncomplete self.meteredCostUSD = meteredCostUSD self.credentialScopeFingerprint = credentialScopeFingerprint self.daily = daily @@ -258,6 +263,17 @@ public struct CostUsageProjectBreakdown: Sendable, Equatable { self.modelBreakdowns = modelBreakdowns self.sources = sources } + + /// Sources to show under a project row in menus/CLI. + /// + /// When a project has a single source that is the project itself (same path), + /// omit it so totals are not listed twice. Keep multi-source rows and sources + /// with a distinct path (for example Codex worktrees). + public var visibleSourcesForDisplay: [CostUsageProjectSourceBreakdown] { + guard self.sources.count == 1 else { return self.sources } + guard let source = self.sources.first, source.path != self.path else { return [] } + return [source] + } } public struct CostUsageProjectSourceBreakdown: Sendable, Equatable { diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index b901ff1ba8..753f5de135 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 = "b975eb705f905b9a" + static let value = "0ccbad6bd5c6b94b" } diff --git a/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift index b79de5684f..8ba3c1ab78 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift @@ -47,8 +47,18 @@ public enum GrokProviderDescriptor { ProviderColor(hex: 0xFDFDFD), ]), tokenCost: ProviderTokenCostConfig( - supportsTokenCost: false, - noDataMessage: { "Grok cost summary is not supported yet." }), + supportsTokenCost: true, + noDataMessage: { "No local Grok session usage found yet." }, + menuHintLines: [ + .literal("Local Grok session logs (turn_completed)."), + .literal("Chart uses daily tokens; $ only when ticks reported."), + ], + supportsTokenSnapshot: true, + showsHintInProviderDetails: true, + historyTitleStyle: .compact, + hintPlacement: .beforeRequestHistory, + chartEstimateDisclaimer: .literal( + "Bars show daily tokens. Cost only when Grok reported ticks.")), pace: ProviderPaceCapability(resetWindowPace: .custom { window, now in guard Self.primaryLabel(window: window, now: now) == "Weekly", let resetsAt = window.resetsAt @@ -59,19 +69,23 @@ public enum GrokProviderDescriptor { && timeUntilReset > 0 && timeUntilReset <= TimeInterval(windowMinutes) * 60 }), - presentation: ProviderUsagePresentation(rateWindowLabeler: { metadata, snapshot, now in - ProviderRateWindowLabels( - primary: Self.primaryLabel(window: snapshot.primary, now: now) ?? metadata.sessionLabel, - secondary: metadata.weeklyLabel, - tertiary: metadata.opusLabel ?? "Sonnet", - showsTertiary: metadata.supportsOpus) - }), + presentation: ProviderUsagePresentation( + rateWindowLabeler: { metadata, snapshot, now in + ProviderRateWindowLabels( + primary: Self.primaryLabel(window: snapshot.primary, now: now) ?? metadata.sessionLabel, + secondary: metadata.weeklyLabel, + tertiary: metadata.opusLabel ?? "Sonnet", + showsTertiary: metadata.supportsOpus) + }, + menuCard: ProviderMenuCardPresentation( + supportsInlineTokenCostDashboard: true)), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .cli, .web], pipeline: ProviderFetchPipeline(resolveStrategies: self.resolveStrategies)), cli: ProviderCLIConfig( name: "grok", versionDetector: { _ in GrokStatusProbe.detectVersion() }, + supportsCostCommand: true, browserSupportExemption: { _, _, _ in true })) } diff --git a/Sources/CodexBarCore/Providers/Grok/GrokTurnUsageCache.swift b/Sources/CodexBarCore/Providers/Grok/GrokTurnUsageCache.swift new file mode 100644 index 0000000000..8de509971a --- /dev/null +++ b/Sources/CodexBarCore/Providers/Grok/GrokTurnUsageCache.swift @@ -0,0 +1,382 @@ +import Foundation + +/// Disk cache for per-file Grok `updates.jsonl` parse results so budget-deferred archives +/// catch up across refreshes without re-reading unchanged newest sessions every time. +/// +/// Retention contract (privacy): +/// - Local-only under the user Caches directory; never uploaded. +/// - Entries older than ``maxEntryAge`` (by session-file mtime) are dropped on load/save. +/// - The whole artifact is deleted when Cost tracking is disabled (see Settings) or via +/// `codexbar cache clear --cost` / Debug clear. +/// - In-flight scans capture a write generation at start; after Cost-off invalidation they +/// must not recreate the cache (``save`` rejects stale write tokens). +public enum GrokTurnUsageCacheIO { + private static let artifactVersion = 2 + private static let generationLock = NSLock() + /// Per cache-file generation, bumped by ``invalidateAndDelete`` so concurrent scans + /// cannot repersist after opt-out. Keyed by resolved cache path for test isolation. + private nonisolated(unsafe) static var writeGenerationByPath: [String: UInt64] = [:] + + /// Match shared cost-cache safety budgets: decode/encode is whole-document JSON, so an + /// unbounded local history can otherwise grow the artifact without limit and spike memory. + public static let maxCacheFileBytes: Int = 256 * 1024 * 1024 + public static let maxCacheLoadBytes: Int = 320 * 1024 * 1024 + /// Soft cap on cached session files; oldest (by mtime) are dropped first when over budget. + public static let maxCacheFileEntries: Int = 10000 + /// Drop session-file entries whose mtime is older than this age (privacy expiry). + public static let maxEntryAgeDays: Int = 90 + public static var maxEntryAge: TimeInterval { + TimeInterval(maxEntryAgeDays) * 24 * 60 * 60 + } + + /// Test-only override for the default cache root so Settings disable/delete paths + /// do not touch the real user Caches directory. + nonisolated(unsafe) static var testDefaultCacheRoot: URL? + + private static func defaultCacheRoot() -> URL { + if let override = testDefaultCacheRoot { + return override + } + let root = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! + return root.appendingPathComponent("CodexBar", isDirectory: true) + } + + public static func cacheFileURL(cacheRoot: URL? = nil) -> URL { + let root = cacheRoot ?? self.defaultCacheRoot() + return root + .appendingPathComponent("cost-usage", isDirectory: true) + .appendingPathComponent("grok-turns-v\(Self.artifactVersion).json", isDirectory: false) + } + + private static func generationKey(cacheRoot: URL?) -> String { + self.cacheFileURL(cacheRoot: cacheRoot).path + } + + /// Snapshot the current write generation for a scan that may later call ``save``. + public static func beginWriteToken(cacheRoot: URL? = nil) -> UInt64 { + let key = Self.generationKey(cacheRoot: cacheRoot) + Self.generationLock.lock() + defer { Self.generationLock.unlock() } + return Self.writeGenerationByPath[key, default: 0] + } + + static func isWriteTokenValid(_ token: UInt64, cacheRoot: URL? = nil) -> Bool { + let key = Self.generationKey(cacheRoot: cacheRoot) + Self.generationLock.lock() + defer { Self.generationLock.unlock() } + return token == Self.writeGenerationByPath[key, default: 0] + } + + /// Invalidate outstanding write tokens and delete the on-disk cache. + /// Call when Cost tracking is turned off so in-flight scans cannot recreate the artifact. + @discardableResult + public static func invalidateAndDelete(cacheRoot: URL? = nil) -> Bool { + let key = Self.generationKey(cacheRoot: cacheRoot) + Self.generationLock.lock() + Self.writeGenerationByPath[key, default: 0] &+= 1 + Self.generationLock.unlock() + return Self.deleteCache(cacheRoot: cacheRoot) + } + + /// Remove the on-disk Grok parse cache (best-effort). Safe when the file is already gone. + /// Prefer ``invalidateAndDelete`` on Cost-off so concurrent saves are also aborted. + @discardableResult + public static func deleteCache(cacheRoot: URL? = nil) -> Bool { + let url = self.cacheFileURL(cacheRoot: cacheRoot) + guard FileManager.default.fileExists(atPath: url.path) else { return false } + do { + try FileManager.default.removeItem(at: url) + return true + } catch { + return false + } + } + + static func load( + cacheRoot: URL? = nil, + maxLoadBytes: Int = GrokTurnUsageCacheIO.maxCacheLoadBytes, + now: Date = Date(), + maxEntryAge: TimeInterval = GrokTurnUsageCacheIO.maxEntryAge) -> GrokTurnUsageCache + { + let url = self.cacheFileURL(cacheRoot: cacheRoot) + let fileSize = (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? NSNumber)? + .int64Value ?? 0 + // Refuse oversized artifacts before materializing them (same pattern as CostUsageCacheIO). + guard fileSize > 0, fileSize <= Int64(max(0, maxLoadBytes)) else { + return GrokTurnUsageCache(version: Self.artifactVersion) + } + guard let data = try? Data(contentsOf: url), + data.count <= maxLoadBytes, + var decoded = try? JSONDecoder().decode(GrokTurnUsageCache.self, from: data), + decoded.version == Self.artifactVersion + else { + return GrokTurnUsageCache(version: Self.artifactVersion) + } + let beforeCount = decoded.files.count + Self.pruneExpired(&decoded, now: now, maxAge: maxEntryAge) + // Drop empty/fully-expired artifacts so path keys are not retained on disk. + if decoded.files.isEmpty { + if beforeCount > 0 || fileSize > 0 { + _ = Self.deleteCache(cacheRoot: cacheRoot) + } + return GrokTurnUsageCache(version: Self.artifactVersion) + } + return decoded + } + + /// - Parameter writeToken: Generation from ``beginWriteToken`` at scan start. When the token + /// no longer matches (Cost-off invalidation), this is a no-op and the cache stays deleted. + /// Pass `nil` only for tests that intentionally write without a scan lifecycle. + @discardableResult + static func save( + cache: GrokTurnUsageCache, + cacheRoot: URL? = nil, + maxFileBytes: Int = GrokTurnUsageCacheIO.maxCacheFileBytes, + maxFileEntries: Int = GrokTurnUsageCacheIO.maxCacheFileEntries, + now: Date = Date(), + maxEntryAge: TimeInterval = GrokTurnUsageCacheIO.maxEntryAge, + writeToken: UInt64? = nil) -> Bool + { + // Stale token: Cost was disabled after this scan started — do not recreate the artifact. + if let writeToken, !Self.isWriteTokenValid(writeToken, cacheRoot: cacheRoot) { + return false + } + + let url = self.cacheFileURL(cacheRoot: cacheRoot) + let dir = url.deletingLastPathComponent() + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + + var cache = cache + cache.version = Self.artifactVersion + Self.pruneExpired(&cache, now: now, maxAge: maxEntryAge) + if cache.files.isEmpty { + _ = Self.deleteCache(cacheRoot: cacheRoot) + return false + } + Self.pruneForBudget( + &cache, + maxFileBytes: maxFileBytes, + maxFileEntries: maxFileEntries) + if cache.files.isEmpty { + _ = Self.deleteCache(cacheRoot: cacheRoot) + return false + } + + // Re-check after pruning so a mid-scan invalidation still wins. + if let writeToken, !Self.isWriteTokenValid(writeToken, cacheRoot: cacheRoot) { + return false + } + + let tmp = dir.appendingPathComponent(".tmp-grok-\(UUID().uuidString).json", isDirectory: false) + guard let data = try? JSONEncoder().encode(cache), !data.isEmpty else { return false } + // Last-resort: if still over budget after entry pruning, do not persist an oversized artifact. + guard data.count <= max(0, maxFileBytes) else { + // Drop half the oldest files and try once more; give up rather than write unbounded. + Self.dropOldestFiles(&cache, keepCount: max(1, cache.files.count / 2)) + guard let retry = try? JSONEncoder().encode(cache), + retry.count <= max(0, maxFileBytes) + else { return false } + if let writeToken, !Self.isWriteTokenValid(writeToken, cacheRoot: cacheRoot) { + return false + } + self.writeAtomically(retry, to: url, temporary: tmp) + // Post-write fence: if Cost was disabled during the write, drop the recreated file. + if let writeToken, !Self.isWriteTokenValid(writeToken, cacheRoot: cacheRoot) { + _ = Self.deleteCache(cacheRoot: cacheRoot) + return false + } + return true + } + if let writeToken, !Self.isWriteTokenValid(writeToken, cacheRoot: cacheRoot) { + return false + } + self.writeAtomically(data, to: url, temporary: tmp) + if let writeToken, !Self.isWriteTokenValid(writeToken, cacheRoot: cacheRoot) { + _ = Self.deleteCache(cacheRoot: cacheRoot) + return false + } + return true + } + + /// Drop session-file entries whose mtime is older than `maxAge`. + static func pruneExpired( + _ cache: inout GrokTurnUsageCache, + now: Date = Date(), + maxAge: TimeInterval = GrokTurnUsageCacheIO.maxEntryAge) + { + guard maxAge > 0, !cache.files.isEmpty else { return } + let cutoffMs = Int64(((now.timeIntervalSince1970 - maxAge) * 1000).rounded()) + cache.files = cache.files.filter { _, file in + file.mtimeUnixMs >= cutoffMs + } + } + + /// Prefer newest session files; drop oldest when over entry or encoded-size budget. + static func pruneForBudget( + _ cache: inout GrokTurnUsageCache, + maxFileBytes: Int, + maxFileEntries: Int) + { + if maxFileEntries > 0, cache.files.count > maxFileEntries { + self.dropOldestFiles(&cache, keepCount: maxFileEntries) + } + // Estimate before encoding when possible; encode only if still large after entry trim. + guard maxFileBytes > 0 else { return } + guard let data = try? JSONEncoder().encode(cache) else { return } + guard data.count > maxFileBytes else { return } + + // Binary-search-ish: drop oldest files until under budget or nearly empty. + var keep = max(1, cache.files.count / 2) + while cache.files.count > 1 { + Self.dropOldestFiles(&cache, keepCount: keep) + guard let trimmed = try? JSONEncoder().encode(cache) else { return } + if trimmed.count <= maxFileBytes { return } + keep = max(1, cache.files.count / 2) + if keep >= cache.files.count { break } + } + } + + static func dropOldestFiles(_ cache: inout GrokTurnUsageCache, keepCount: Int) { + guard keepCount >= 0, cache.files.count > keepCount else { return } + let ordered = cache.files.sorted { lhs, rhs in + if lhs.value.mtimeUnixMs != rhs.value.mtimeUnixMs { + return lhs.value.mtimeUnixMs > rhs.value.mtimeUnixMs // newest first + } + return lhs.key < rhs.key + } + let kept = ordered.prefix(keepCount) + cache.files = Dictionary(uniqueKeysWithValues: kept.map { ($0.key, $0.value) }) + } + + private static func writeAtomically(_ data: Data, to url: URL, temporary tmp: URL) { + do { + try data.write(to: tmp, options: [.atomic]) + if FileManager.default.fileExists(atPath: url.path) { + _ = try FileManager.default.replaceItemAt(url, withItemAt: tmp) + } else { + try FileManager.default.moveItem(at: tmp, to: url) + } + } catch { + try? FileManager.default.removeItem(at: tmp) + } + } +} + +struct GrokTurnUsageCache: Codable, Equatable { + var version: Int + /// Path → last successful full-file parse. + var files: [String: GrokTurnUsageCachedFile] = [:] + + init(version: Int = 2) { + self.version = version + } +} + +struct GrokTurnUsageCachedFile: Codable, Equatable { + var mtimeUnixMs: Int64 + var size: Int64 + var sessionID: String + var cwd: String? + /// True when only a bounded slice of the file was parsed (oversized archive). + var isPartial: Bool + var turns: [GrokTurnUsageCachedTurn] + + init( + mtimeUnixMs: Int64, + size: Int64, + sessionID: String, + cwd: String?, + isPartial: Bool = false, + turns: [GrokTurnUsageCachedTurn]) + { + self.mtimeUnixMs = mtimeUnixMs + self.size = size + self.sessionID = sessionID + self.cwd = cwd + self.isPartial = isPartial + self.turns = turns + } +} + +struct GrokTurnUsageCachedTurn: Codable, Equatable { + var eventID: String + var sessionID: String + var dayKey: String + var timestampUnixMs: Int64 + var cwd: String? + var inputTokens: Int + var cacheReadTokens: Int + var outputTokens: Int + var reasoningTokens: Int + var totalTokens: Int + var modelCalls: Int + var costUSD: Double? + var modelUsages: [GrokTurnUsageCachedModel] + + init(from record: GrokTurnUsageScanner.TurnRecord) { + self.eventID = record.eventID + self.sessionID = record.sessionID + self.dayKey = record.dayKey + self.timestampUnixMs = Int64((record.timestamp.timeIntervalSince1970 * 1000).rounded()) + self.cwd = record.cwd + self.inputTokens = record.inputTokens + self.cacheReadTokens = record.cacheReadTokens + self.outputTokens = record.outputTokens + self.reasoningTokens = record.reasoningTokens + self.totalTokens = record.totalTokens + self.modelCalls = record.modelCalls + self.costUSD = record.costUSD + self.modelUsages = record.modelUsages.map(GrokTurnUsageCachedModel.init(from:)) + } + + func asTurnRecord() -> GrokTurnUsageScanner.TurnRecord { + GrokTurnUsageScanner.TurnRecord( + eventID: self.eventID, + sessionID: self.sessionID, + dayKey: self.dayKey, + timestamp: Date(timeIntervalSince1970: TimeInterval(self.timestampUnixMs) / 1000), + cwd: self.cwd, + inputTokens: self.inputTokens, + cacheReadTokens: self.cacheReadTokens, + outputTokens: self.outputTokens, + reasoningTokens: self.reasoningTokens, + totalTokens: self.totalTokens, + modelCalls: self.modelCalls, + costUSD: self.costUSD, + modelUsages: self.modelUsages.map { $0.asModelUsage() }) + } +} + +struct GrokTurnUsageCachedModel: Codable, Equatable { + var modelName: String + var inputTokens: Int + var cacheReadTokens: Int + var outputTokens: Int + var reasoningTokens: Int + var totalTokens: Int + var modelCalls: Int + var costUSD: Double? + + init(from usage: GrokTurnUsageScanner.ModelUsage) { + self.modelName = usage.modelName + self.inputTokens = usage.inputTokens + self.cacheReadTokens = usage.cacheReadTokens + self.outputTokens = usage.outputTokens + self.reasoningTokens = usage.reasoningTokens + self.totalTokens = usage.totalTokens + self.modelCalls = usage.modelCalls + self.costUSD = usage.costUSD + } + + func asModelUsage() -> GrokTurnUsageScanner.ModelUsage { + GrokTurnUsageScanner.ModelUsage( + modelName: self.modelName, + inputTokens: self.inputTokens, + cacheReadTokens: self.cacheReadTokens, + outputTokens: self.outputTokens, + reasoningTokens: self.reasoningTokens, + totalTokens: self.totalTokens, + modelCalls: self.modelCalls, + costUSD: self.costUSD) + } +} diff --git a/Sources/CodexBarCore/Providers/Grok/GrokTurnUsageScanner.swift b/Sources/CodexBarCore/Providers/Grok/GrokTurnUsageScanner.swift new file mode 100644 index 0000000000..354f6a5ffc --- /dev/null +++ b/Sources/CodexBarCore/Providers/Grok/GrokTurnUsageScanner.swift @@ -0,0 +1,925 @@ +import Foundation + +/// Scans local Grok session logs for per-turn API usage (`turn_completed` in `updates.jsonl`). +/// +/// Maps into the shared `CostUsageDailyReport` shape so Grok reuses the same Cost menu UI as Codex. +public enum GrokTurnUsageScanner { + /// 1 USD = 10^10 ticks (matches Grok headless `total_cost_usd_ticks`). + public static let costUsdTicksPerDollar: Double = 10_000_000_000 + + public struct Options: Sendable { + public var sessionsRoot: URL? + /// Where per-file parse results are persisted so deferred archives catch up later. + public var cacheRoot: URL? + public var environment: [String: String] + /// Not Sendable; kept only for local filesystem reads (same pattern as other scanners). + public nonisolated(unsafe) var fileManager: FileManager + /// Skip any single `updates.jsonl` larger than this (0 = unlimited). Default 256 MiB. + public var maxSessionFileBytes: Int64 + /// Soft budget for newly-read session bytes in one refresh (0 = unlimited). Default 512 MiB. + public var maxScanBytesPerRefresh: Int64 + /// Prefer newest session files first so recent usage lands before catch-up work. + public var preferNewestSessionsFirst: Bool + + public init( + sessionsRoot: URL? = nil, + cacheRoot: URL? = nil, + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default, + maxSessionFileBytes: Int64 = 256 * 1024 * 1024, + maxScanBytesPerRefresh: Int64 = 512 * 1024 * 1024, + preferNewestSessionsFirst: Bool = true) + { + self.sessionsRoot = sessionsRoot + self.cacheRoot = cacheRoot + self.environment = environment + self.fileManager = fileManager + self.maxSessionFileBytes = max(0, maxSessionFileBytes) + self.maxScanBytesPerRefresh = max(0, maxScanBytesPerRefresh) + self.preferNewestSessionsFirst = preferNewestSessionsFirst + } + } + + /// Per-refresh work limiter (mirrors Codex cost scan protection). + final class ScanBudget: @unchecked Sendable { + let maxFileBytes: Int64 + let maxBytesPerRefresh: Int64 + private(set) var bytesConsumed: Int64 = 0 + private(set) var partialOversizedFileCount = 0 + private(set) var deferredByBudgetFileCount = 0 + private(set) var skippedStaleFileCount = 0 + private(set) var cacheHitFileCount = 0 + private(set) var freshlyScannedFileCount = 0 + + init(maxFileBytes: Int64, maxBytesPerRefresh: Int64) { + self.maxFileBytes = max(0, maxFileBytes) + self.maxBytesPerRefresh = max(0, maxBytesPerRefresh) + } + + func markCacheHit() { + self.cacheHitFileCount += 1 + } + + func markFreshScan() { + self.freshlyScannedFileCount += 1 + } + + enum Admission { + /// `bytes` is the max this refresh may read; `isPartial` when the file is larger. + case allow(bytes: Int64, isPartial: Bool) + case deferBudget + } + + func admit(fileBytes: Int64) -> Admission { + let work = max(0, fileBytes) + let fileCap = self.maxFileBytes > 0 ? self.maxFileBytes : Int64.max + let capped = min(work, fileCap) + let isPartial = capped < work + if isPartial { + self.partialOversizedFileCount += 1 + } + // Normal-size files: whole-file only. Oversized: allow a bounded tail slice. + if self.maxBytesPerRefresh > 0 { + let remaining = max(0, self.maxBytesPerRefresh - self.bytesConsumed) + if capped > remaining { + if isPartial, remaining > 0 { + return .allow(bytes: remaining, isPartial: true) + } + self.deferredByBudgetFileCount += 1 + return .deferBudget + } + } + return .allow(bytes: capped, isPartial: isPartial) + } + + func consume(workBytes: Int64) { + self.bytesConsumed += max(0, workBytes) + } + + func markSkippedStale() { + self.skippedStaleFileCount += 1 + } + } + + /// Per-model usage nested under a turn's `modelUsage` map. + struct ModelUsage: Sendable, Equatable { + let modelName: String + /// Uncached input tokens (full input − cache read) for this model. + let inputTokens: Int + let cacheReadTokens: Int + let outputTokens: Int + let reasoningTokens: Int + let totalTokens: Int + let modelCalls: Int + let costUSD: Double? + + init( + modelName: String, + inputTokens: Int, + cacheReadTokens: Int, + outputTokens: Int, + reasoningTokens: Int, + totalTokens: Int, + modelCalls: Int, + costUSD: Double?) + { + self.modelName = modelName + self.inputTokens = inputTokens + self.cacheReadTokens = cacheReadTokens + self.outputTokens = outputTokens + self.reasoningTokens = reasoningTokens + self.totalTokens = totalTokens + self.modelCalls = modelCalls + self.costUSD = costUSD + } + } + + struct TurnRecord: Sendable, Equatable { + let eventID: String + let sessionID: String + let dayKey: String + let timestamp: Date + let cwd: String? + /// Uncached input tokens (full input − cache read). + let inputTokens: Int + let cacheReadTokens: Int + let outputTokens: Int + let reasoningTokens: Int + let totalTokens: Int + let modelCalls: Int + let costUSD: Double? + /// Nested per-model totals from `modelUsage` (empty when the payload only has turn totals). + let modelUsages: [ModelUsage] + + init( + eventID: String, + sessionID: String, + dayKey: String, + timestamp: Date, + cwd: String?, + inputTokens: Int, + cacheReadTokens: Int, + outputTokens: Int, + reasoningTokens: Int, + totalTokens: Int, + modelCalls: Int, + costUSD: Double?, + modelUsages: [ModelUsage]) + { + self.eventID = eventID + self.sessionID = sessionID + self.dayKey = dayKey + self.timestamp = timestamp + self.cwd = cwd + self.inputTokens = inputTokens + self.cacheReadTokens = cacheReadTokens + self.outputTokens = outputTokens + self.reasoningTokens = reasoningTokens + self.totalTokens = totalTokens + self.modelCalls = modelCalls + self.costUSD = costUSD + self.modelUsages = modelUsages + } + + var models: [String] { + self.modelUsages.map(\.modelName) + } + } + + // MARK: - Public + + public struct ScanBundle: Sendable { + public let daily: CostUsageDailyReport + public let sessions: [CostUsageSessionBreakdown] + public let projects: [CostUsageProjectBreakdown] + /// True when at least one session log was only partially scanned (oversized budget). + public let historyIsIncomplete: Bool + } + + struct ScanResult: Sendable { + let turns: [TurnRecord] + let historyIsIncomplete: Bool + } + + /// Single-pass scan used by the Cost pipeline (daily + sessions + projects). + public static func loadScanBundle( + since: Date, + until: Date, + now: Date = Date(), + options: Options = Options(), + checkCancellation: (() throws -> Void)? = nil) throws -> ScanBundle + { + _ = now + let range = CostUsageScanner.CostUsageDayRange(since: since, until: until) + let result = try self.scanTurns( + since: since, + until: until, + options: options, + checkCancellation: checkCancellation) + let inRange = result.turns.filter { + CostUsageScanner.CostUsageDayRange.isInRange( + dayKey: $0.dayKey, + since: range.sinceKey, + until: range.untilKey) + } + return ScanBundle( + daily: self.dailyReport(from: inRange), + sessions: self.sessionBreakdowns(from: inRange), + projects: self.projectBreakdowns(from: inRange), + historyIsIncomplete: result.historyIsIncomplete) + } + + public static func loadDailyReport( + since: Date, + until: Date, + now: Date = Date(), + options: Options = Options(), + checkCancellation: (() throws -> Void)? = nil) throws -> CostUsageDailyReport + { + try self.loadScanBundle( + since: since, + until: until, + now: now, + options: options, + checkCancellation: checkCancellation).daily + } + + public static func loadSessionBreakdowns( + since: Date, + until: Date, + options: Options = Options(), + checkCancellation: (() throws -> Void)? = nil) throws -> [CostUsageSessionBreakdown] + { + try self.loadScanBundle( + since: since, + until: until, + options: options, + checkCancellation: checkCancellation).sessions + } + + public static func loadProjectBreakdowns( + since: Date, + until: Date, + options: Options = Options(), + checkCancellation: (() throws -> Void)? = nil) throws -> [CostUsageProjectBreakdown] + { + try self.loadScanBundle( + since: since, + until: until, + options: options, + checkCancellation: checkCancellation).projects + } + + /// Resolve `~/.grok/sessions` (or `GROK_HOME/sessions`). + public static func sessionsRoot(options: Options = Options()) -> URL { + if let override = options.sessionsRoot { + return override + } + return GrokCredentialsStore.grokHomeURL(env: options.environment, fileManager: options.fileManager) + .appendingPathComponent("sessions", isDirectory: true) + } + + // MARK: - Scan + + private struct SessionLogFile { + let url: URL + let sessionID: String + let size: Int64 + let modifiedAt: Date + } + + static func scanTurns( + since: Date = Date.distantPast, + until: Date = Date.distantFuture, + options: Options, + checkCancellation: (() throws -> Void)?, + budget: ScanBudget? = nil) throws -> ScanResult + { + let root = self.sessionsRoot(options: options) + let fileManager = options.fileManager + guard fileManager.fileExists(atPath: root.path) else { + return ScanResult(turns: [], historyIsIncomplete: false) + } + + let activeBudget = budget ?? ScanBudget( + maxFileBytes: options.maxSessionFileBytes, + maxBytesPerRefresh: options.maxScanBytesPerRefresh) + + var candidates = try self.listSessionLogFiles( + root: root, + fileManager: fileManager, + checkCancellation: checkCancellation) + if options.preferNewestSessionsFirst { + candidates.sort { lhs, rhs in + if lhs.modifiedAt != rhs.modifiedAt { return lhs.modifiedAt > rhs.modifiedAt } + return lhs.url.path < rhs.url.path + } + } else { + candidates.sort { $0.url.path < $1.url.path } + } + + // Files that have not been touched since before the window cannot contain in-range turns. + let staleCutoff = since + var byEventID: [String: TurnRecord] = [:] + var cwdBySession: [String: String] = [:] + // Capture generation before load/scan so Cost-off invalidation can abort the final save. + let writeToken = GrokTurnUsageCacheIO.beginWriteToken(cacheRoot: options.cacheRoot) + let cache = GrokTurnUsageCacheIO.load(cacheRoot: options.cacheRoot) + var nextCache = GrokTurnUsageCache(version: cache.version) + var cacheDirty = false + var historyIsIncomplete = false + + for file in candidates { + try checkCancellation?() + if file.modifiedAt < staleCutoff { + activeBudget.markSkippedStale() + // Drop stale entries so the cache does not grow without bound. + if cache.files[file.url.path] != nil { + cacheDirty = true + } + continue + } + + let mtimeMs = Int64((file.modifiedAt.timeIntervalSince1970 * 1000).rounded()) + if let cached = cache.files[file.url.path], + cached.size == file.size, + cached.mtimeUnixMs == mtimeMs + { + // Unchanged file: reuse parse results without spending refresh budget. + activeBudget.markCacheHit() + nextCache.files[file.url.path] = cached + if cached.isPartial { + historyIsIncomplete = true + } + for turn in cached.turns { + let record = turn.asTurnRecord() + if byEventID[record.eventID] == nil { + byEventID[record.eventID] = record + } + } + continue + } + + switch activeBudget.admit(fileBytes: file.size) { + case .deferBudget: + // Persist prior results for deferred files so history is not permanently lost. + if let prior = cache.files[file.url.path] { + nextCache.files[file.url.path] = prior + if prior.isPartial { + historyIsIncomplete = true + } + for turn in prior.turns { + let record = turn.asTurnRecord() + if byEventID[record.eventID] == nil { + byEventID[record.eventID] = record + } + } + } else { + // First-seen but deferred: totals omit it until a later refresh catches up. + historyIsIncomplete = true + } + continue + case let .allow(allowedBytes, isPartial): + if cwdBySession[file.sessionID] == nil { + cwdBySession[file.sessionID] = self.readCwd( + sessionDirectory: file.url.deletingLastPathComponent(), + fileManager: fileManager) + } + let cwd = cwdBySession[file.sessionID] + var scanned: [TurnRecord] = [] + // Oversized files: read the newest tail so recent turns still contribute. + let startOffset = isPartial ? max(Int64(0), file.size - allowedBytes) : 0 + let readBytes = try self.scanSessionLogFile( + url: file.url, + sessionID: file.sessionID, + cwd: cwd, + startOffset: startOffset, + maxBytesToRead: allowedBytes, + until: until, + checkCancellation: checkCancellation) + { record in + scanned.append(record) + if byEventID[record.eventID] == nil { + byEventID[record.eventID] = record + } + } + activeBudget.consume(workBytes: readBytes) + activeBudget.markFreshScan() + if isPartial { + historyIsIncomplete = true + } + nextCache.files[file.url.path] = GrokTurnUsageCachedFile( + mtimeUnixMs: mtimeMs, + size: file.size, + sessionID: file.sessionID, + cwd: cwd, + isPartial: isPartial, + turns: scanned.map(GrokTurnUsageCachedTurn.init(from:))) + cacheDirty = true + } + } + + // Persist parse results / drop deleted paths so later refreshes can catch up. + // Stale writeToken (Cost disabled mid-scan) skips save so the opt-out delete sticks. + if nextCache != cache || cacheDirty { + _ = GrokTurnUsageCacheIO.save( + cache: nextCache, + cacheRoot: options.cacheRoot, + writeToken: writeToken) + } + + let turns = byEventID.values.sorted { lhs, rhs in + if lhs.timestamp != rhs.timestamp { return lhs.timestamp < rhs.timestamp } + return lhs.eventID < rhs.eventID + } + return ScanResult(turns: turns, historyIsIncomplete: historyIsIncomplete) + } + + private static func listSessionLogFiles( + root: URL, + fileManager: FileManager, + checkCancellation: (() throws -> Void)?) throws -> [SessionLogFile] + { + let keys: Set = [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey] + guard let enumerator = fileManager.enumerator( + at: root, + includingPropertiesForKeys: Array(keys), + options: [.skipsHiddenFiles, .skipsPackageDescendants]) + else { return [] } + + var files: [SessionLogFile] = [] + for case let url as URL in enumerator { + try checkCancellation?() + guard url.lastPathComponent == "updates.jsonl" else { continue } + let values = try? url.resourceValues(forKeys: keys) + guard values?.isRegularFile == true else { continue } + let size = Int64(values?.fileSize ?? 0) + let modifiedAt = values?.contentModificationDate ?? Date.distantPast + let sessionID = url.deletingLastPathComponent().lastPathComponent + files.append(SessionLogFile( + url: url, + sessionID: sessionID, + size: size, + modifiedAt: modifiedAt)) + } + return files + } + + /// Stream-read a session log up to `maxBytesToRead` without loading the whole file into memory. + /// When `startOffset > 0` (oversized tail reads), skips the first partial line after the seek. + @discardableResult + private static func scanSessionLogFile( + url: URL, + sessionID: String, + cwd: String?, + startOffset: Int64 = 0, + maxBytesToRead: Int64, + until: Date, + checkCancellation: (() throws -> Void)?, + onRecord: (TurnRecord) -> Void) throws -> Int64 + { + guard maxBytesToRead > 0 else { return 0 } + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + + let seekOffset = max(Int64(0), startOffset) + if seekOffset > 0 { + try handle.seek(toOffset: UInt64(seekOffset)) + } + + var bytesRead: Int64 = 0 + var pending = Data() + pending.reserveCapacity(16 * 1024) + var reachedEOF = false + var discardPartialLead = seekOffset > 0 + + func consumeLine(_ lineData: Data) throws { + try checkCancellation?() + guard let line = String(data: lineData, encoding: .utf8) else { return } + guard line.contains("turn_completed") else { return } + guard let record = self.parseTurnLine(line, sessionID: sessionID, cwd: cwd) else { return } + // Drop turns clearly after the window (defensive; normal scans set until=now). + if record.timestamp > until { return } + onRecord(record) + } + + while bytesRead < maxBytesToRead { + try checkCancellation?() + let remaining = maxBytesToRead - bytesRead + let chunkSize = min(256 * 1024, Int(remaining)) + guard chunkSize > 0 else { break } + let chunk = try handle.read(upToCount: chunkSize) ?? Data() + if chunk.isEmpty { + reachedEOF = true + break + } + bytesRead += Int64(chunk.count) + pending.append(chunk) + + while let newline = pending.firstIndex(of: UInt8(ascii: "\n")) { + let lineData = pending.subdata(in: pending.startIndex.. TurnRecord? { + guard let data = line.data(using: .utf8), + let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return nil } + + let params = root["params"] as? [String: Any] ?? [:] + let update = params["update"] as? [String: Any] ?? [:] + guard (update["sessionUpdate"] as? String) == "turn_completed" else { return nil } + + let usage = update["usage"] as? [String: Any] ?? [:] + guard !usage.isEmpty else { return nil } + + let inputFull = self.intValue(usage["inputTokens"]) ?? 0 + let cacheRead = self.intValue(usage["cachedReadTokens"]) ?? 0 + let output = self.intValue(usage["outputTokens"]) ?? 0 + let reasoning = self.intValue(usage["reasoningTokens"]) ?? 0 + let total = self.intValue(usage["totalTokens"]) ?? (inputFull + output) + let modelCalls = self.intValue(usage["modelCalls"]) ?? 1 + let uncached = max(inputFull - cacheRead, 0) + + let costUSD: Double? = { + guard let ticks = self.intValue(usage["costUsdTicks"]) else { return nil } + return Double(ticks) / self.costUsdTicksPerDollar + }() + + let modelUsages = self.parseModelUsages(from: usage["modelUsage"] as? [String: Any]) + + // Real Grok session logs put `_meta` at the root; keep nested `params._meta` as a fallback. + let meta = (root["_meta"] as? [String: Any]) + ?? (params["_meta"] as? [String: Any]) + ?? [:] + let promptID = update["prompt_id"] as? String + let eventID: String = { + if let id = meta["eventId"] as? String, !id.isEmpty { return id } + let ts = root["timestamp"].map { "\($0)" } ?? "0" + return "\(sessionID):\(promptID ?? "unknown"):\(ts)" + }() + + let resolvedSessionID = (params["sessionId"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? sessionID + let timestamp = self.parseTimestamp(root: root, meta: meta) ?? Date.distantPast + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: timestamp) + + return TurnRecord( + eventID: eventID, + sessionID: resolvedSessionID, + dayKey: dayKey, + timestamp: timestamp, + cwd: cwd, + inputTokens: uncached, + cacheReadTokens: cacheRead, + outputTokens: output, + reasoningTokens: reasoning, + totalTokens: total, + modelCalls: modelCalls, + costUSD: costUSD, + modelUsages: modelUsages) + } + + /// Parse nested `modelUsage` entries so multi-model turns keep separate token/cost totals. + private static func parseModelUsages(from modelUsage: [String: Any]?) -> [ModelUsage] { + guard let modelUsage, !modelUsage.isEmpty else { return [] } + return modelUsage.keys.sorted().compactMap { name in + guard let payload = modelUsage[name] as? [String: Any] else { + // Key present without nested fields — treat as a named model with zero usage. + return ModelUsage( + modelName: name, + inputTokens: 0, + cacheReadTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + totalTokens: 0, + modelCalls: 0, + costUSD: nil) + } + let inputFull = self.intValue(payload["inputTokens"]) ?? 0 + let cacheRead = self.intValue(payload["cachedReadTokens"]) ?? 0 + let output = self.intValue(payload["outputTokens"]) ?? 0 + let reasoning = self.intValue(payload["reasoningTokens"]) ?? 0 + let total = self.intValue(payload["totalTokens"]) ?? (inputFull + output) + let calls = self.intValue(payload["modelCalls"]) ?? 1 + let costUSD: Double? = { + guard let ticks = self.intValue(payload["costUsdTicks"]) else { return nil } + return Double(ticks) / self.costUsdTicksPerDollar + }() + return ModelUsage( + modelName: name, + inputTokens: max(inputFull - cacheRead, 0), + cacheReadTokens: cacheRead, + outputTokens: output, + reasoningTokens: reasoning, + totalTokens: total, + modelCalls: calls, + costUSD: costUSD) + } + } + + /// Attribute nested model totals when present; otherwise fall back to whole-turn totals. + private static func modelContributions(for turn: TurnRecord) + -> [(name: String, tokens: Int, cost: Double?, requests: Int)] + { + if turn.modelUsages.isEmpty { + return [( + name: "unknown", + tokens: turn.totalTokens, + cost: turn.costUSD, + requests: max(turn.modelCalls, 1))] + } + return turn.modelUsages.map { usage in + ( + name: usage.modelName, + tokens: usage.totalTokens, + cost: usage.costUSD, + requests: max(usage.modelCalls, 1)) + } + } + + private static func parseTimestamp(root: [String: Any], meta: [String: Any]) -> Date? { + if let ms = self.intValue(meta["agentTimestampMs"]) { + if ms > 1_000_000_000_000 { + return Date(timeIntervalSince1970: TimeInterval(ms) / 1000) + } + return Date(timeIntervalSince1970: TimeInterval(ms)) + } + if let ts = root["timestamp"] as? Double { + if ts > 1_000_000_000_000 { + return Date(timeIntervalSince1970: ts / 1000) + } + return Date(timeIntervalSince1970: ts) + } + if let ts = root["timestamp"] as? Int { + if ts > 1_000_000_000_000 { + return Date(timeIntervalSince1970: TimeInterval(ts) / 1000) + } + return Date(timeIntervalSince1970: TimeInterval(ts)) + } + return nil + } + + private static func readCwd(sessionDirectory: URL, fileManager: FileManager) -> String? { + let summaryURL = sessionDirectory.appendingPathComponent("summary.json") + guard let data = try? Data(contentsOf: summaryURL), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return nil } + if let info = json["info"] as? [String: Any], + let cwd = info["cwd"] as? String, + !cwd.isEmpty + { + return cwd + } + if let cwd = json["cwd"] as? String, !cwd.isEmpty { + return cwd + } + return nil + } + + private static func intValue(_ any: Any?) -> Int? { + switch any { + case let v as Int: v + case let v as Int64: Int(v) + case let v as Double: Int(v) + case let v as NSNumber: v.intValue + case let v as String: Int(v) + default: nil + } + } + + // MARK: - Aggregate + + static func dailyReport(from turns: [TurnRecord]) -> CostUsageDailyReport { + struct DayBucket { + var input = 0 + var cache = 0 + var output = 0 + var total = 0 + var requests = 0 + var cost: Double = 0 + var sawCost = false + var models: Set = [] + var modelTotals: [String: (tokens: Int, cost: Double, sawCost: Bool, requests: Int)] = [:] + } + + var days: [String: DayBucket] = [:] + for turn in turns { + var bucket = days[turn.dayKey] ?? DayBucket() + bucket.input += turn.inputTokens + bucket.cache += turn.cacheReadTokens + bucket.output += turn.outputTokens + bucket.total += turn.totalTokens + bucket.requests += max(turn.modelCalls, 1) + if let cost = turn.costUSD { + bucket.cost += cost + bucket.sawCost = true + } + for contribution in self.modelContributions(for: turn) { + bucket.models.insert(contribution.name) + var m = bucket.modelTotals[contribution.name] ?? (0, 0, false, 0) + m.tokens += contribution.tokens + m.requests += contribution.requests + if let cost = contribution.cost { + m.cost += cost + m.sawCost = true + } + bucket.modelTotals[contribution.name] = m + } + days[turn.dayKey] = bucket + } + + let entries: [CostUsageDailyReport.Entry] = days.keys.sorted().map { day in + let b = days[day]! + let breakdowns = b.modelTotals.keys.sorted().map { name in + let m = b.modelTotals[name]! + return CostUsageDailyReport.ModelBreakdown( + modelName: name, + costUSD: m.sawCost ? m.cost : nil, + totalTokens: m.tokens, + requestCount: m.requests) + } + return CostUsageDailyReport.Entry( + date: day, + inputTokens: b.input, + outputTokens: b.output, + cacheReadTokens: b.cache, + cacheCreationTokens: nil, + totalTokens: b.total, + requestCount: b.requests, + costUSD: b.sawCost ? b.cost : nil, + modelsUsed: b.models.sorted(), + modelBreakdowns: breakdowns) + } + + let costs = entries.compactMap(\.costUSD) + let summary = CostUsageDailyReport.Summary( + totalInputTokens: entries.compactMap(\.inputTokens).reduce(0, +), + totalOutputTokens: entries.compactMap(\.outputTokens).reduce(0, +), + cacheReadTokens: entries.compactMap(\.cacheReadTokens).reduce(0, +), + cacheCreationTokens: nil, + totalTokens: entries.compactMap(\.totalTokens).reduce(0, +), + totalCostUSD: costs.isEmpty ? nil : costs.reduce(0, +)) + + return CostUsageDailyReport(data: entries, summary: summary) + } + + static func sessionBreakdowns(from turns: [TurnRecord]) -> [CostUsageSessionBreakdown] { + struct SessionBucket { + var lastActivity = Date.distantPast + var input = 0 + var cache = 0 + var output = 0 + var total = 0 + var requests = 0 + var cost: Double = 0 + var sawCost = false + var modelTotals: [String: (tokens: Int, cost: Double, sawCost: Bool, requests: Int)] = [:] + } + + var sessions: [String: SessionBucket] = [:] + for turn in turns { + var b = sessions[turn.sessionID] ?? SessionBucket() + b.lastActivity = max(b.lastActivity, turn.timestamp) + b.input += turn.inputTokens + b.cache += turn.cacheReadTokens + b.output += turn.outputTokens + b.total += turn.totalTokens + b.requests += max(turn.modelCalls, 1) + if let cost = turn.costUSD { + b.cost += cost + b.sawCost = true + } + for contribution in self.modelContributions(for: turn) { + var m = b.modelTotals[contribution.name] ?? (0, 0, false, 0) + m.tokens += contribution.tokens + m.requests += contribution.requests + if let cost = contribution.cost { + m.cost += cost + m.sawCost = true + } + b.modelTotals[contribution.name] = m + } + sessions[turn.sessionID] = b + } + + return sessions.map { sessionID, b in + let breakdowns = b.modelTotals.keys.sorted().map { name in + let m = b.modelTotals[name]! + return CostUsageDailyReport.ModelBreakdown( + modelName: name, + costUSD: m.sawCost ? m.cost : nil, + totalTokens: m.tokens, + requestCount: m.requests) + } + return CostUsageSessionBreakdown( + sessionID: sessionID, + lastActivity: b.lastActivity, + inputTokens: b.input, + cachedInputTokens: b.cache, + outputTokens: b.output, + totalTokens: b.total, + requestCount: b.requests, + costUSD: b.sawCost ? b.cost : nil, + modelBreakdowns: breakdowns) + } + .sorted { $0.lastActivity > $1.lastActivity } + } + + static func projectBreakdowns(from turns: [TurnRecord]) -> [CostUsageProjectBreakdown] { + struct ProjectBucket { + var path: String? + var total = 0 + var cost: Double = 0 + var sawCost = false + var dayTurns: [TurnRecord] = [] + var modelTotals: [String: (tokens: Int, cost: Double, sawCost: Bool, requests: Int)] = [:] + } + + var projects: [String: ProjectBucket] = [:] + for turn in turns { + let key = turn.cwd ?? CostUsageProjectBreakdown.unknownProjectName + var b = projects[key] ?? ProjectBucket(path: turn.cwd) + b.path = turn.cwd + b.total += turn.totalTokens + if let cost = turn.costUSD { + b.cost += cost + b.sawCost = true + } + b.dayTurns.append(turn) + for contribution in self.modelContributions(for: turn) { + var m = b.modelTotals[contribution.name] ?? (0, 0, false, 0) + m.tokens += contribution.tokens + m.requests += contribution.requests + if let cost = contribution.cost { + m.cost += cost + m.sawCost = true + } + b.modelTotals[contribution.name] = m + } + projects[key] = b + } + + return projects.map { key, b in + let name: String = { + if let path = b.path, !path.isEmpty { + return URL(fileURLWithPath: path).lastPathComponent + } + return key + }() + let daily = self.dailyReport(from: b.dayTurns).data + let breakdowns = b.modelTotals.keys.sorted().map { modelName in + let m = b.modelTotals[modelName]! + return CostUsageDailyReport.ModelBreakdown( + modelName: modelName, + costUSD: m.sawCost ? m.cost : nil, + totalTokens: m.tokens, + requestCount: m.requests) + } + return CostUsageProjectBreakdown( + name: name, + path: b.path, + totalTokens: b.total, + totalCostUSD: b.sawCost ? b.cost : nil, + daily: daily, + modelBreakdowns: breakdowns, + sources: [ + CostUsageProjectSourceBreakdown( + name: name, + path: b.path, + totalTokens: b.total, + totalCostUSD: b.sawCost ? b.cost : nil, + daily: daily, + modelBreakdowns: breakdowns), + ]) + } + .sorted { ($0.totalTokens ?? 0) > ($1.totalTokens ?? 0) } + } +} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 3109a3f8c6..d2943a7c46 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -72,6 +72,7 @@ enum CostUsageScanner { struct Options { var codexSessionsRoot: URL? var claudeProjectsRoots: [URL]? + var grokSessionsRoot: URL? var cacheRoot: URL? var codexTraceDatabaseURL: URL? var calendar: Calendar @@ -95,6 +96,7 @@ enum CostUsageScanner { init( codexSessionsRoot: URL? = nil, claudeProjectsRoots: [URL]? = nil, + grokSessionsRoot: URL? = nil, cacheRoot: URL? = nil, codexTraceDatabaseURL: URL? = nil, calendar: Calendar = .current, @@ -108,6 +110,7 @@ enum CostUsageScanner { { self.codexSessionsRoot = codexSessionsRoot self.claudeProjectsRoots = claudeProjectsRoots + self.grokSessionsRoot = grokSessionsRoot self.cacheRoot = cacheRoot self.codexTraceDatabaseURL = codexTraceDatabaseURL self.calendar = calendar @@ -1788,11 +1791,38 @@ enum CostUsageScanner { now: now, options: filtered, checkCancellation: checkCancellation) + case .grok: + return try self.loadGrokDaily( + since: since, + until: until, + now: now, + options: options, + checkCancellation: checkCancellation) default: return emptyReport } } + private static func loadGrokDaily( + since: Date, + until: Date, + now: Date, + options: Options, + checkCancellation: CancellationCheck?) throws -> CostUsageDailyReport + { + var scannerOptions = GrokTurnUsageScanner.Options() + if let override = options.grokSessionsRoot { + scannerOptions.sessionsRoot = override + } + scannerOptions.cacheRoot = options.cacheRoot + return try GrokTurnUsageScanner.loadDailyReport( + since: since, + until: until, + now: now, + options: scannerOptions, + checkCancellation: checkCancellation) + } + // MARK: - Day keys struct CostUsageDayRange { diff --git a/Tests/CodexBarTests/CLICostTests.swift b/Tests/CodexBarTests/CLICostTests.swift index f2c2c0370e..76edc37e65 100644 --- a/Tests/CodexBarTests/CLICostTests.swift +++ b/Tests/CodexBarTests/CLICostTests.swift @@ -111,6 +111,145 @@ struct CLICostTests { #expect(output.contains("Not a subscription bill or plan value · local usage × public API prices")) } + @Test + func `renders grok project grouped cost text with reported-cost semantics`() { + let snap = CostUsageTokenSnapshot( + sessionTokens: 3400, + sessionCostUSD: 0.42, + last30DaysTokens: 12000, + last30DaysCostUSD: 1.8, + historyDays: 30, + daily: [], + projects: [ + CostUsageProjectBreakdown( + name: "demo", + path: "/work/demo", + totalTokens: 8000, + totalCostUSD: 1.1, + daily: [], + modelBreakdowns: nil, + sources: [ + CostUsageProjectSourceBreakdown( + name: "demo", + path: "/work/demo", + totalTokens: 8000, + totalCostUSD: 1.1, + daily: [], + modelBreakdowns: nil), + ]), + CostUsageProjectBreakdown( + name: CostUsageProjectBreakdown.unknownProjectName, + path: nil, + totalTokens: 4000, + totalCostUSD: 0.7, + daily: [], + modelBreakdowns: nil), + ], + updatedAt: Date(timeIntervalSince1970: 0)) + + let output = CodexBarCLI.renderCostText( + provider: .grok, + snapshot: snap, + groupBy: .project, + useColor: false) + .replacingOccurrences(of: "\u{00A0}", with: " ") + .replacingOccurrences(of: "$ ", with: "$") + + #expect(output.contains("Grok Cost (local session logs)")) + #expect(output.contains("Projects (Last 30 days):")) + #expect(output.contains("demo: $1.10 · 8K tokens")) + #expect(output.contains("/work/demo")) + // Sole same-path self-source must not duplicate the project row. + #expect(!output.contains(" - demo:")) + #expect(output.contains("Unknown project: $0.70 · 4K tokens")) + #expect(output.contains("Local Grok session logs (turn_completed). Cost only when reported.")) + #expect(!output.contains("local usage × public API prices")) + #expect(!output.contains("API-equivalent estimate")) + } + + @Test + func `renders grok project sources when path differs from parent`() { + let snap = CostUsageTokenSnapshot( + sessionTokens: 1000, + sessionCostUSD: 0.1, + last30DaysTokens: 1000, + last30DaysCostUSD: 0.1, + historyDays: 30, + daily: [], + projects: [ + CostUsageProjectBreakdown( + name: "demo", + path: "/work/demo", + totalTokens: 1000, + totalCostUSD: 0.1, + daily: [], + modelBreakdowns: nil, + sources: [ + CostUsageProjectSourceBreakdown( + name: "nested", + path: "/work/demo/packages/nested", + totalTokens: 1000, + totalCostUSD: 0.1, + daily: [], + modelBreakdowns: nil), + ]), + ], + updatedAt: Date(timeIntervalSince1970: 0)) + + let output = CodexBarCLI.renderCostText( + provider: .grok, + snapshot: snap, + groupBy: .project, + useColor: false) + .replacingOccurrences(of: "\u{00A0}", with: " ") + .replacingOccurrences(of: "$ ", with: "$") + + #expect(output.contains("demo: $0.10 · 1K tokens")) + #expect(output.contains(" - nested: $0.10 · 1K tokens")) + #expect(output.contains("/work/demo/packages/nested")) + } + + @Test + func `renders grok project grouped cost text with incomplete history warning`() { + let snap = CostUsageTokenSnapshot( + sessionTokens: 3400, + sessionCostUSD: 0.42, + last30DaysTokens: 12000, + last30DaysCostUSD: 1.8, + historyDays: 30, + historyIsIncomplete: true, + daily: [], + projects: [ + CostUsageProjectBreakdown( + name: "demo", + path: "/work/demo", + totalTokens: 8000, + totalCostUSD: 1.1, + daily: [], + modelBreakdowns: nil), + ], + updatedAt: Date(timeIntervalSince1970: 0)) + + let output = CodexBarCLI.renderCostText( + provider: .grok, + snapshot: snap, + groupBy: .project, + useColor: false) + + #expect(output.contains("Projects (Last 30 days):")) + #expect(output.contains("demo:")) + #expect(output.contains( + "Note: history incomplete — some session logs were only partially scanned (size/budget limits).")) + #expect(output.contains("Local Grok session logs (turn_completed). Cost only when reported.")) + // Warning must appear before the source hint so partial totals are qualified. + let warningIdx = output.range(of: "Note: history incomplete")?.lowerBound + let hintIdx = output.range(of: "Local Grok session logs")?.lowerBound + #expect(warningIdx != nil && hintIdx != nil) + if let warningIdx, let hintIdx { + #expect(warningIdx < hintIdx) + } + } + @Test func `encodes cost payload JSON`() throws { let payload = CostPayload( diff --git a/Tests/CodexBarTests/GrokTurnUsageScannerTests.swift b/Tests/CodexBarTests/GrokTurnUsageScannerTests.swift new file mode 100644 index 0000000000..ee3300e454 --- /dev/null +++ b/Tests/CodexBarTests/GrokTurnUsageScannerTests.swift @@ -0,0 +1,767 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct GrokTurnUsageScannerTests { + @Test + func `parses turn_completed matching headless usage fields`() throws { + // Use a released public model ID (not internal -build suffixes) per repo test-model policy. + let usage = """ + "inputTokens":12845,"outputTokens":32,"totalTokens":12877,"cachedReadTokens":10752,\ + "reasoningTokens":27,"modelCalls":1,"apiDurationMs":1772,"costUsdTicks":76036000,\ + "modelUsage":{"grok-4":{"inputTokens":12845,"outputTokens":32,"totalTokens":12877,\ + "cachedReadTokens":10752,"reasoningTokens":27,"modelCalls":1,"apiDurationMs":1772,\ + "costUsdTicks":76036000}},"numTurns":1 + """ + let line = """ + {"timestamp":1784626073,"method":"_x.ai/session/update","params":{"sessionId":\ + "session-fixture-1","update":{"sessionUpdate":"turn_completed","prompt_id":\ + "prompt-fixture-1","stop_reason":"end_turn","usage":{\(usage)}}},"_meta":{"eventId":\ + "session-fixture-1-29","agentTimestampMs":1784626073119}} + """ + + let record = try #require(GrokTurnUsageScanner.parseTurnLine( + line, + sessionID: "session-fixture-1", + cwd: "/tmp/demo-project")) + + #expect(record.eventID == "session-fixture-1-29") + #expect(record.sessionID == "session-fixture-1") + #expect(record.inputTokens == 2093) // uncached = 12845 - 10752 + #expect(record.cacheReadTokens == 10752) + #expect(record.outputTokens == 32) + #expect(record.reasoningTokens == 27) + #expect(record.totalTokens == 12877) + #expect(record.modelCalls == 1) + #expect(record.models == ["grok-4"]) + #expect(record.modelUsages.count == 1) + #expect(record.modelUsages[0].modelName == "grok-4") + #expect(record.modelUsages[0].totalTokens == 12877) + let cost = try #require(record.costUSD) + #expect(abs(cost - 0.0076036) < 0.0000001) + let modelCost = try #require(record.modelUsages[0].costUSD) + #expect(abs(modelCost - 0.0076036) < 0.0000001) + } + + @Test + func `preserves nested multi-model usage totals`() throws { + let modelUsage = """ + "grok-4":{"inputTokens":100,"cachedReadTokens":20,"outputTokens":10,"totalTokens":110,\ + "modelCalls":1,"costUsdTicks":1000000000},"test-grok-model":{"inputTokens":200,\ + "cachedReadTokens":30,"outputTokens":30,"totalTokens":230,"modelCalls":2,\ + "costUsdTicks":2000000000} + """ + let usage = """ + "inputTokens":300,"cachedReadTokens":50,"outputTokens":40,"totalTokens":340,\ + "modelCalls":3,"costUsdTicks":3000000000,"modelUsage":{\(modelUsage)} + """ + let line = """ + {"timestamp":1784626073,"params":{"sessionId":"session-multi","update":{\ + "sessionUpdate":"turn_completed","prompt_id":"p-multi","usage":{\(usage)}}},\ + "_meta":{"eventId":"e-multi","agentTimestampMs":1784626073000}} + """ + + let record = try #require(GrokTurnUsageScanner.parseTurnLine( + line, + sessionID: "session-multi", + cwd: "/tmp/multi")) + + #expect(record.models == ["grok-4", "test-grok-model"]) + #expect(record.modelUsages.count == 2) + #expect(record.modelUsages[0].modelName == "grok-4") + #expect(record.modelUsages[0].totalTokens == 110) + #expect(record.modelUsages[0].modelCalls == 1) + #expect(abs((record.modelUsages[0].costUSD ?? -1) - 0.1) < 0.0000001) + #expect(record.modelUsages[1].modelName == "test-grok-model") + #expect(record.modelUsages[1].totalTokens == 230) + #expect(record.modelUsages[1].modelCalls == 2) + #expect(abs((record.modelUsages[1].costUSD ?? -1) - 0.2) < 0.0000001) + + let report = GrokTurnUsageScanner.dailyReport(from: [record]) + let entry = try #require(report.data.first) + let breakdowns = try #require(entry.modelBreakdowns) + #expect(breakdowns.count == 2) + #expect(breakdowns[0].modelName == "grok-4") + #expect(breakdowns[0].totalTokens == 110) + #expect(abs((breakdowns[0].costUSD ?? -1) - 0.1) < 0.0000001) + #expect(breakdowns[0].requestCount == 1) + #expect(breakdowns[1].modelName == "test-grok-model") + #expect(breakdowns[1].totalTokens == 230) + #expect(abs((breakdowns[1].costUSD ?? -1) - 0.2) < 0.0000001) + #expect(breakdowns[1].requestCount == 2) + // Turn-level totals still reflect the outer usage object. + #expect(entry.totalTokens == 340) + #expect(abs((entry.costUSD ?? -1) - 0.3) < 0.0000001) + } + + @Test + func `ignores non turn_completed lines`() { + let line = #"{"timestamp":1,"params":{"update":{"sessionUpdate":"agent_message_chunk"}}}"# + #expect(GrokTurnUsageScanner.parseTurnLine(line, sessionID: "s", cwd: nil) == nil) + } + + @Test + func `reads nested params meta when root meta is absent`() throws { + let line = """ + {"timestamp":1784626073,"params":{"sessionId":"session-nested","update":{\ + "sessionUpdate":"turn_completed","prompt_id":"p-nested","usage":{"inputTokens":10,\ + "cachedReadTokens":0,"outputTokens":1,"totalTokens":11,"modelCalls":1}},\ + "_meta":{"eventId":"nested-event-1","agentTimestampMs":1784626073000}}} + """ + + let record = try #require(GrokTurnUsageScanner.parseTurnLine( + line, + sessionID: "session-nested", + cwd: nil)) + + #expect(record.eventID == "nested-event-1") + #expect(record.inputTokens == 10) + #expect(record.outputTokens == 1) + } + + @Test + func `daily report aggregates tokens and partial costs`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("grok-cost-test-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + let sessionDir = root + .appendingPathComponent("%2Ftmp%2Fdemo", isDirectory: true) + .appendingPathComponent("session-a", isDirectory: true) + try FileManager.default.createDirectory(at: sessionDir, withIntermediateDirectories: true) + + let summary = #""" + {"info":{"id":"session-a","cwd":"/tmp/demo"},"created_at":"2026-07-21T00:00:00Z"} + """# + try Data(summary.utf8).write(to: sessionDir.appendingPathComponent("summary.json")) + + // Two turns same day: one with cost, one without. + let turn1Usage = """ + "inputTokens":100,"cachedReadTokens":40,"outputTokens":10,"totalTokens":110,\ + "modelCalls":1,"costUsdTicks":1000000000,"modelUsage":{"grok-4":{"inputTokens":100,\ + "outputTokens":10,"totalTokens":110,"modelCalls":1,"costUsdTicks":1000000000}} + """ + let turn2Usage = """ + "inputTokens":200,"cachedReadTokens":50,"outputTokens":20,"totalTokens":220,\ + "modelCalls":2,"modelUsage":{"grok-4":{"inputTokens":200,"outputTokens":20,\ + "totalTokens":220,"modelCalls":2}} + """ + let updates = """ + {"timestamp":1784626073,"params":{"sessionId":"session-a","update":{"sessionUpdate":\ + "turn_completed","prompt_id":"p1","usage":{\(turn1Usage)}}},"_meta":{"eventId":"e1",\ + "agentTimestampMs":1784626073000}} + {"timestamp":1784627000,"params":{"sessionId":"session-a","update":{"sessionUpdate":\ + "turn_completed","prompt_id":"p2","usage":{\(turn2Usage)}}},"_meta":{"eventId":"e2",\ + "agentTimestampMs":1784627000000}} + """ + try Data(updates.utf8).write(to: sessionDir.appendingPathComponent("updates.jsonl")) + + let cacheRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("grok-cost-cache-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: cacheRoot) } + let options = GrokTurnUsageScanner.Options(sessionsRoot: root, cacheRoot: cacheRoot) + let since = Date(timeIntervalSince1970: 1_700_000_000) + let until = Date(timeIntervalSince1970: 1_900_000_000) + let report = try GrokTurnUsageScanner.loadDailyReport( + since: since, + until: until, + options: options) + + #expect(report.data.count == 1) + let entry = try #require(report.data.first) + #expect(entry.totalTokens == 330) + #expect(entry.inputTokens == 210) // (100-40) + (200-50) + #expect(entry.cacheReadTokens == 90) + #expect(entry.outputTokens == 30) + #expect(entry.requestCount == 3) + let cost = try #require(entry.costUSD) + #expect(abs(cost - 0.1) < 0.0000001) // 1e9 ticks + #expect(report.summary?.totalTokens == 330) + let breakdowns = try #require(entry.modelBreakdowns) + #expect(breakdowns.count == 1) + #expect(breakdowns[0].modelName == "grok-4") + #expect(breakdowns[0].totalTokens == 330) + + let sessions = try GrokTurnUsageScanner.loadSessionBreakdowns( + since: since, + until: until, + options: options) + #expect(sessions.count == 1) + #expect(sessions[0].sessionID == "session-a") + #expect(sessions[0].totalTokens == 330) + + let projects = try GrokTurnUsageScanner.loadProjectBreakdowns( + since: since, + until: until, + options: options) + #expect(projects.count == 1) + #expect(projects[0].path == "/tmp/demo") + #expect(projects[0].totalTokens == 330) + } + + @Test + func `cost fetcher supports grok token snapshots`() async throws { + #expect(CostUsageFetcher.supportsTokenSnapshot(.grok)) + + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("grok-fetcher-test-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + let sessionDir = root + .appendingPathComponent("cwd", isDirectory: true) + .appendingPathComponent("sid", isDirectory: true) + try FileManager.default.createDirectory(at: sessionDir, withIntermediateDirectories: true) + + let now = Date() + let ts = Int(now.timeIntervalSince1970) + let usage = """ + "inputTokens":50,"cachedReadTokens":10,"outputTokens":5,"totalTokens":55,"modelCalls":1,\ + "costUsdTicks":500000000,"modelUsage":{"grok-4":{"inputTokens":50,"cachedReadTokens":10,\ + "outputTokens":5,"totalTokens":55,"modelCalls":1,"costUsdTicks":500000000}} + """ + let updates = """ + {"timestamp":\(ts),"params":{"sessionId":"sid","update":{"sessionUpdate":\ + "turn_completed","prompt_id":"p","usage":{\(usage)}}},"_meta":{"eventId":"e-now",\ + "agentTimestampMs":\(ts)000}} + """ + try Data(updates.utf8).write(to: sessionDir.appendingPathComponent("updates.jsonl")) + + var options = CostUsageScanner.Options() + options.grokSessionsRoot = root + options.refreshMinIntervalSeconds = 0 + + let fetcher = CostUsageFetcher(scannerOptions: options) + let snapshot = try await fetcher.loadTokenSnapshot( + provider: .grok, + forceRefresh: true, + historyDays: 7, + allowPricingRefresh: false, + refreshPricingInBackground: false, + includePiSessions: false, + bypassScannerDebounce: true) + + #expect(snapshot.sessionTokens == 55) + #expect(snapshot.last30DaysTokens == 55) + let cost = try #require(snapshot.sessionCostUSD) + #expect(abs(cost - 0.05) < 0.0000001) + } + + @Test + func `descriptor enables token cost`() { + #expect(GrokProviderDescriptor.descriptor.tokenCost.supportsTokenCost) + } + + @Test + func `partially scans oversized session logs and marks history incomplete`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("grok-oversized-\(UUID().uuidString)", isDirectory: true) + let cacheRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("grok-oversized-cache-\(UUID().uuidString)", isDirectory: true) + defer { + try? FileManager.default.removeItem(at: root) + try? FileManager.default.removeItem(at: cacheRoot) + } + + let sessionDir = root + .appendingPathComponent("cwd", isDirectory: true) + .appendingPathComponent("big-session", isDirectory: true) + try FileManager.default.createDirectory(at: sessionDir, withIntermediateDirectories: true) + + let now = Date() + let ts = Int(now.timeIntervalSince1970) + // Root-level `_meta` (real Grok log shape); keep JSON fully closed. + let line = """ + {"timestamp":\(ts),"params":{"sessionId":"big-session","update":{"sessionUpdate":\ + "turn_completed","prompt_id":"p","usage":{"inputTokens":10,"cachedReadTokens":0,\ + "outputTokens":1,"totalTokens":11,"modelCalls":1,"costUsdTicks":100000000}}},"_meta":{\ + "eventId":"e-big","agentTimestampMs":\(ts)000}} + """ + let lineData = Data((line + "\n").utf8) + // Prefix padding makes the file oversized; the full trailing turn still fits in the tail slice. + var payload = Data(repeating: UInt8(ascii: "x"), count: 800) + payload.append(Data("\n".utf8)) + payload.append(lineData) + try payload.write(to: sessionDir.appendingPathComponent("updates.jsonl")) + + let fileSize = Int64(payload.count) + let maxFileBytes = Int64(lineData.count + 50) + #expect(fileSize > maxFileBytes) + + let budget = GrokTurnUsageScanner.ScanBudget( + maxFileBytes: maxFileBytes, + maxBytesPerRefresh: 10000) + let options = GrokTurnUsageScanner.Options( + sessionsRoot: root, + cacheRoot: cacheRoot, + maxSessionFileBytes: maxFileBytes, + maxScanBytesPerRefresh: 10000) + let result = try GrokTurnUsageScanner.scanTurns( + since: now.addingTimeInterval(-86400), + until: now.addingTimeInterval(60), + options: options, + checkCancellation: nil, + budget: budget) + + #expect(result.turns.count == 1) + #expect(result.turns[0].eventID == "e-big") + #expect(result.historyIsIncomplete) + #expect(budget.partialOversizedFileCount == 1) + #expect(budget.bytesConsumed > 0) + #expect(budget.bytesConsumed <= maxFileBytes) + } + + @Test + func `refresh budget prefers newest session and defers older files`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("grok-budget-\(UUID().uuidString)", isDirectory: true) + let cacheRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("grok-budget-cache-\(UUID().uuidString)", isDirectory: true) + defer { + try? FileManager.default.removeItem(at: root) + try? FileManager.default.removeItem(at: cacheRoot) + } + + let now = Date() + let ts = Int(now.timeIntervalSince1970) + + func writeSession(id: String, eventID: String, tokens: Int, modifiedAt: Date) throws { + let sessionDir = root + .appendingPathComponent("cwd", isDirectory: true) + .appendingPathComponent(id, isDirectory: true) + try FileManager.default.createDirectory(at: sessionDir, withIntermediateDirectories: true) + let line = """ + {"timestamp":\(ts),"params":{"sessionId":"\(id)","update":{"sessionUpdate":\ + "turn_completed","prompt_id":"p","usage":{"inputTokens":\(tokens),"cachedReadTokens":0,\ + "outputTokens":1,"totalTokens":\(tokens + 1),"modelCalls":1,"costUsdTicks":100000000}}},\ + "_meta":{"eventId":"\(eventID)","agentTimestampMs":\(ts)000}} + """ + let url = sessionDir.appendingPathComponent("updates.jsonl") + try Data((line + "\n").utf8).write(to: url) + try FileManager.default.setAttributes( + [.modificationDate: modifiedAt], + ofItemAtPath: url.path) + } + + try writeSession( + id: "old-session", + eventID: "e-old", + tokens: 100, + modifiedAt: now.addingTimeInterval(-3600)) + try writeSession( + id: "new-session", + eventID: "e-new", + tokens: 50, + modifiedAt: now) + + // Budget fits only one of the two ~similar-size session files. + let sampleURL = root + .appendingPathComponent("cwd", isDirectory: true) + .appendingPathComponent("new-session", isDirectory: true) + .appendingPathComponent("updates.jsonl") + let sampleSize = try Int64( + (FileManager.default.attributesOfItem(atPath: sampleURL.path)[.size] as? NSNumber)? + .int64Value ?? 0) + #expect(sampleSize > 0) + + let budget = GrokTurnUsageScanner.ScanBudget( + maxFileBytes: sampleSize * 4, + maxBytesPerRefresh: sampleSize) + let options = GrokTurnUsageScanner.Options( + sessionsRoot: root, + cacheRoot: cacheRoot, + maxSessionFileBytes: sampleSize * 4, + maxScanBytesPerRefresh: sampleSize, + preferNewestSessionsFirst: true) + let result = try GrokTurnUsageScanner.scanTurns( + since: now.addingTimeInterval(-86400), + until: now.addingTimeInterval(60), + options: options, + checkCancellation: nil, + budget: budget) + + #expect(result.turns.count == 1) + #expect(result.turns[0].eventID == "e-new") + #expect(result.turns[0].totalTokens == 51) + #expect(result.historyIsIncomplete) // first-seen older file deferred + #expect(budget.deferredByBudgetFileCount == 1) + #expect(budget.bytesConsumed == sampleSize) + } + + @Test + func `later refresh catches up budget-deferred files via cache`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("grok-cache-catchup-\(UUID().uuidString)", isDirectory: true) + let cacheRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("grok-cache-root-\(UUID().uuidString)", isDirectory: true) + defer { + try? FileManager.default.removeItem(at: root) + try? FileManager.default.removeItem(at: cacheRoot) + } + + let now = Date() + let ts = Int(now.timeIntervalSince1970) + + func writeSession(id: String, eventID: String, tokens: Int, modifiedAt: Date) throws -> Int64 { + let sessionDir = root + .appendingPathComponent("cwd", isDirectory: true) + .appendingPathComponent(id, isDirectory: true) + try FileManager.default.createDirectory(at: sessionDir, withIntermediateDirectories: true) + let line = """ + {"timestamp":\(ts),"params":{"sessionId":"\(id)","update":{"sessionUpdate":\ + "turn_completed","prompt_id":"p","usage":{"inputTokens":\(tokens),"cachedReadTokens":0,\ + "outputTokens":1,"totalTokens":\(tokens + 1),"modelCalls":1,"costUsdTicks":100000000}}},\ + "_meta":{"eventId":"\(eventID)","agentTimestampMs":\(ts)000}} + """ + let url = sessionDir.appendingPathComponent("updates.jsonl") + try Data((line + "\n").utf8).write(to: url) + try FileManager.default.setAttributes( + [.modificationDate: modifiedAt], + ofItemAtPath: url.path) + return try Int64( + (FileManager.default.attributesOfItem(atPath: url.path)[.size] as? NSNumber)? + .int64Value ?? 0) + } + + let newSize = try writeSession( + id: "new-session", + eventID: "e-new", + tokens: 50, + modifiedAt: now) + let oldSize = try writeSession( + id: "old-session", + eventID: "e-old", + tokens: 100, + modifiedAt: now.addingTimeInterval(-3600)) + #expect(newSize > 0) + #expect(oldSize > 0) + + // Budget fits only one file per refresh. + let perRefresh = max(newSize, oldSize) + let options = GrokTurnUsageScanner.Options( + sessionsRoot: root, + cacheRoot: cacheRoot, + maxSessionFileBytes: perRefresh * 4, + maxScanBytesPerRefresh: perRefresh, + preferNewestSessionsFirst: true) + + let budget1 = GrokTurnUsageScanner.ScanBudget( + maxFileBytes: perRefresh * 4, + maxBytesPerRefresh: perRefresh) + let firstResult = try GrokTurnUsageScanner.scanTurns( + since: now.addingTimeInterval(-86400), + until: now.addingTimeInterval(60), + options: options, + checkCancellation: nil, + budget: budget1) + #expect(firstResult.turns.map(\.eventID).sorted() == ["e-new"]) + #expect(firstResult.historyIsIncomplete) + #expect(budget1.deferredByBudgetFileCount == 1) + #expect(budget1.freshlyScannedFileCount == 1) + + // Second refresh: newest is a cache hit (free), so budget scans the deferred older file. + let budget2 = GrokTurnUsageScanner.ScanBudget( + maxFileBytes: perRefresh * 4, + maxBytesPerRefresh: perRefresh) + let secondResult = try GrokTurnUsageScanner.scanTurns( + since: now.addingTimeInterval(-86400), + until: now.addingTimeInterval(60), + options: options, + checkCancellation: nil, + budget: budget2) + #expect(Set(secondResult.turns.map(\.eventID)) == Set(["e-new", "e-old"])) + #expect(!secondResult.historyIsIncomplete) + #expect(budget2.cacheHitFileCount == 1) + #expect(budget2.freshlyScannedFileCount == 1) + #expect(budget2.deferredByBudgetFileCount == 0) + } + + @Test + func `skips stale session files outside the since window`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("grok-stale-\(UUID().uuidString)", isDirectory: true) + let cacheRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("grok-stale-cache-\(UUID().uuidString)", isDirectory: true) + defer { + try? FileManager.default.removeItem(at: root) + try? FileManager.default.removeItem(at: cacheRoot) + } + + let now = Date() + let sessionDir = root + .appendingPathComponent("cwd", isDirectory: true) + .appendingPathComponent("stale", isDirectory: true) + try FileManager.default.createDirectory(at: sessionDir, withIntermediateDirectories: true) + let ts = Int(now.addingTimeInterval(-10 * 86400).timeIntervalSince1970) + let line = """ + {"timestamp":\(ts),"params":{"sessionId":"stale","update":{"sessionUpdate":\ + "turn_completed","prompt_id":"p","usage":{"inputTokens":10,"cachedReadTokens":0,\ + "outputTokens":1,"totalTokens":11,"modelCalls":1}}},"_meta":{"eventId":"e-stale",\ + "agentTimestampMs":\(ts)000}} + """ + let url = sessionDir.appendingPathComponent("updates.jsonl") + try Data((line + "\n").utf8).write(to: url) + try FileManager.default.setAttributes( + [.modificationDate: now.addingTimeInterval(-10 * 86400)], + ofItemAtPath: url.path) + + let budget = GrokTurnUsageScanner.ScanBudget( + maxFileBytes: 1024 * 1024, + maxBytesPerRefresh: 1024 * 1024) + let options = GrokTurnUsageScanner.Options(sessionsRoot: root, cacheRoot: cacheRoot) + let result = try GrokTurnUsageScanner.scanTurns( + since: now.addingTimeInterval(-86400), + until: now.addingTimeInterval(60), + options: options, + checkCancellation: nil, + budget: budget) + + #expect(result.turns.isEmpty) + #expect(!result.historyIsIncomplete) + #expect(budget.skippedStaleFileCount == 1) + #expect(budget.bytesConsumed == 0) + } + + @Test + func `refuses oversized Grok cache artifacts before decoding`() throws { + let cacheRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("grok-cache-load-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: cacheRoot) } + let url = GrokTurnUsageCacheIO.cacheFileURL(cacheRoot: cacheRoot) + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true) + // Write a small payload but enforce a tiny maxLoadBytes so load skips decode. + try Data(#"{"version":2,"files":{}}"#.utf8).write(to: url) + let loaded = GrokTurnUsageCacheIO.load(cacheRoot: cacheRoot, maxLoadBytes: 4) + #expect(loaded.files.isEmpty) + #expect(loaded.version == 2) + } + + @Test + func `prunes oldest Grok cache files to honor entry and byte budgets`() throws { + let cacheRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("grok-cache-save-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: cacheRoot) } + + var cache = GrokTurnUsageCache(version: 2) + for index in 0..<20 { + let path = "/tmp/session-\(index)/updates.jsonl" + let turn = GrokTurnUsageCachedTurn( + from: GrokTurnUsageScanner.TurnRecord( + eventID: "e-\(index)", + sessionID: "s-\(index)", + dayKey: "2026-08-01", + timestamp: Date(timeIntervalSince1970: TimeInterval(1_700_000_000 + index)), + cwd: "/tmp/project-\(index)", + inputTokens: 10, + cacheReadTokens: 0, + outputTokens: 1, + reasoningTokens: 0, + totalTokens: 11, + modelCalls: 1, + costUSD: 0.01, + modelUsages: [])) + cache.files[path] = GrokTurnUsageCachedFile( + mtimeUnixMs: Int64(index), + size: 100, + sessionID: "s-\(index)", + cwd: "/tmp/project-\(index)", + isPartial: false, + turns: [turn]) + } + + GrokTurnUsageCacheIO.save( + cache: cache, + cacheRoot: cacheRoot, + maxFileBytes: 256 * 1024 * 1024, + maxFileEntries: 5) + let loaded = GrokTurnUsageCacheIO.load(cacheRoot: cacheRoot) + #expect(loaded.files.count == 5) + // Newest mtimes retained. + #expect(loaded.files.keys.contains("/tmp/session-19/updates.jsonl")) + #expect(loaded.files.keys.contains("/tmp/session-15/updates.jsonl")) + #expect(!loaded.files.keys.contains("/tmp/session-0/updates.jsonl")) + + // Byte budget: force pruning by encoding size. + var fat = GrokTurnUsageCache(version: 2) + for index in 0..<30 { + let padding = String(repeating: "x", count: 200) + let path = "/tmp/fat-\(index)-\(padding)/updates.jsonl" + let turn = GrokTurnUsageCachedTurn( + from: GrokTurnUsageScanner.TurnRecord( + eventID: "fat-\(index)-\(padding)", + sessionID: "fat-\(index)", + dayKey: "2026-08-01", + timestamp: Date(timeIntervalSince1970: TimeInterval(1_700_000_000 + index)), + cwd: "/tmp/fat-\(index)", + inputTokens: 10, + cacheReadTokens: 0, + outputTokens: 1, + reasoningTokens: 0, + totalTokens: 11, + modelCalls: 1, + costUSD: nil, + modelUsages: [])) + fat.files[path] = GrokTurnUsageCachedFile( + mtimeUnixMs: Int64(index), + size: 1000, + sessionID: "fat-\(index)", + cwd: "/tmp/fat-\(index)", + isPartial: false, + turns: [turn]) + } + let encodedAll = try JSONEncoder().encode(fat) + #expect(encodedAll.count > 2000) + GrokTurnUsageCacheIO.save( + cache: fat, + cacheRoot: cacheRoot, + maxFileBytes: 2000, + maxFileEntries: 100) + let afterBytePrune = GrokTurnUsageCacheIO.load(cacheRoot: cacheRoot) + let encodedKept = try JSONEncoder().encode(afterBytePrune) + #expect(encodedKept.count <= 2000) + #expect(afterBytePrune.files.count < 30) + #expect(!afterBytePrune.files.isEmpty) + } + + @Test + func `expires Grok cache entries older than max age and deletes empty artifacts`() { + let cacheRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("grok-cache-expire-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: cacheRoot) } + + let now = Date(timeIntervalSince1970: 1_800_000_000) + let maxAge: TimeInterval = 90 * 24 * 60 * 60 + let freshMs = Int64((now.timeIntervalSince1970 * 1000).rounded()) + let staleMs = Int64(((now.timeIntervalSince1970 - maxAge - 86400) * 1000).rounded()) + + func makeFile(path: String, sessionID: String, mtimeMs: Int64) -> GrokTurnUsageCachedFile { + let turn = GrokTurnUsageCachedTurn( + from: GrokTurnUsageScanner.TurnRecord( + eventID: "e-\(sessionID)", + sessionID: sessionID, + dayKey: "2026-08-01", + timestamp: Date(timeIntervalSince1970: TimeInterval(mtimeMs) / 1000), + cwd: "/tmp/\(sessionID)", + inputTokens: 10, + cacheReadTokens: 0, + outputTokens: 1, + reasoningTokens: 0, + totalTokens: 11, + modelCalls: 1, + costUSD: nil, + modelUsages: [])) + return GrokTurnUsageCachedFile( + mtimeUnixMs: mtimeMs, + size: 100, + sessionID: sessionID, + cwd: "/tmp/\(sessionID)", + isPartial: false, + turns: [turn]) + } + + var cache = GrokTurnUsageCache(version: 2) + cache.files["/tmp/fresh/updates.jsonl"] = makeFile( + path: "/tmp/fresh/updates.jsonl", + sessionID: "fresh", + mtimeMs: freshMs) + cache.files["/tmp/stale/updates.jsonl"] = makeFile( + path: "/tmp/stale/updates.jsonl", + sessionID: "stale", + mtimeMs: staleMs) + + GrokTurnUsageCacheIO.save( + cache: cache, + cacheRoot: cacheRoot, + now: now, + maxEntryAge: maxAge) + let loaded = GrokTurnUsageCacheIO.load( + cacheRoot: cacheRoot, + now: now, + maxEntryAge: maxAge) + #expect(loaded.files.count == 1) + #expect(loaded.files["/tmp/fresh/updates.jsonl"] != nil) + #expect(loaded.files["/tmp/stale/updates.jsonl"] == nil) + #expect(FileManager.default.fileExists(atPath: GrokTurnUsageCacheIO.cacheFileURL(cacheRoot: cacheRoot) + .path)) + + // Fully expired → artifact removed from disk. + var onlyStale = GrokTurnUsageCache(version: 2) + onlyStale.files["/tmp/stale2/updates.jsonl"] = makeFile( + path: "/tmp/stale2/updates.jsonl", + sessionID: "stale2", + mtimeMs: staleMs) + GrokTurnUsageCacheIO.save( + cache: onlyStale, + cacheRoot: cacheRoot, + now: now, + maxEntryAge: maxAge) + #expect(!FileManager.default.fileExists(atPath: GrokTurnUsageCacheIO.cacheFileURL(cacheRoot: cacheRoot) + .path)) + let emptyLoad = GrokTurnUsageCacheIO.load( + cacheRoot: cacheRoot, + now: now, + maxEntryAge: maxAge) + #expect(emptyLoad.files.isEmpty) + } + + @Test + func `deleteCache removes the Grok parse cache file`() { + let cacheRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("grok-cache-delete-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: cacheRoot) } + + var cache = GrokTurnUsageCache(version: 2) + cache.files["/tmp/one/updates.jsonl"] = GrokTurnUsageCachedFile( + mtimeUnixMs: Int64(Date().timeIntervalSince1970 * 1000), + size: 10, + sessionID: "one", + cwd: "/tmp/one", + isPartial: false, + turns: []) + GrokTurnUsageCacheIO.save(cache: cache, cacheRoot: cacheRoot) + let url = GrokTurnUsageCacheIO.cacheFileURL(cacheRoot: cacheRoot) + #expect(FileManager.default.fileExists(atPath: url.path)) + #expect(GrokTurnUsageCacheIO.deleteCache(cacheRoot: cacheRoot)) + #expect(!FileManager.default.fileExists(atPath: url.path)) + #expect(!GrokTurnUsageCacheIO.deleteCache(cacheRoot: cacheRoot)) + } + + @Test + func `stale write token does not recreate cache after Cost-off invalidation`() { + let cacheRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("grok-cache-race-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: cacheRoot) } + + var cache = GrokTurnUsageCache(version: 2) + cache.files["/Users/REDACTED/project/updates.jsonl"] = GrokTurnUsageCachedFile( + mtimeUnixMs: Int64(Date().timeIntervalSince1970 * 1000), + size: 10, + sessionID: "session-redacted", + cwd: "/Users/REDACTED/project", + isPartial: false, + turns: []) + + // Seed cache, then capture scan token and simulate Cost-off mid-scan. + #expect(GrokTurnUsageCacheIO.save(cache: cache, cacheRoot: cacheRoot, writeToken: nil)) + let url = GrokTurnUsageCacheIO.cacheFileURL(cacheRoot: cacheRoot) + #expect(FileManager.default.fileExists(atPath: url.path)) + + let preScanToken = GrokTurnUsageCacheIO.beginWriteToken(cacheRoot: cacheRoot) + #expect(GrokTurnUsageCacheIO.invalidateAndDelete(cacheRoot: cacheRoot)) + #expect(!FileManager.default.fileExists(atPath: url.path)) + + // Stale scan tries to save after opt-out — must not recreate. + let saved = GrokTurnUsageCacheIO.save( + cache: cache, + cacheRoot: cacheRoot, + writeToken: preScanToken) + #expect(!saved) + #expect(!FileManager.default.fileExists(atPath: url.path)) + + // Fresh token after invalidation may write again (Cost re-enabled path). + let freshToken = GrokTurnUsageCacheIO.beginWriteToken(cacheRoot: cacheRoot) + #expect(GrokTurnUsageCacheIO.save(cache: cache, cacheRoot: cacheRoot, writeToken: freshToken)) + #expect(FileManager.default.fileExists(atPath: url.path)) + } +} diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift index eed2fc2e12..62d8e7055c 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -254,7 +254,9 @@ struct ProviderInlineDashboardModelTests { now: now)) #expect(model.inlineUsageDashboard?.kpis.first?.value == "$0.25") - #expect(model.inlineUsageDashboard?.points.count == 2) + // Continuous daily padding fills the history window (default 30 days) ending at the latest entry. + #expect(model.inlineUsageDashboard?.points.count == 30) + #expect(model.inlineUsageDashboard?.points.count(where: { $0.value > 0 }) == 2) #expect(model.inlineUsageDashboard?.detailLines.contains { $0.contains("claude-opus-4") } == true) #expect(model.tokenUsage?.sessionLine.contains("$0.25") == true) #expect(model.tokenUsage?.monthLine.contains("$0.37") == true) diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 21c0036f40..bec5d30681 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -201,11 +201,11 @@ struct ProviderArchitectureGatekeeperTests { ]) #if os(macOS) #expect(Set(descriptors.filter(\.tokenCost.supportsTokenSnapshot).map(\.id)) == [ - .codex, .claude, .cursor, .vertexai, .bedrock, + .codex, .claude, .cursor, .vertexai, .bedrock, .grok, ]) #else #expect(Set(descriptors.filter(\.tokenCost.supportsTokenSnapshot).map(\.id)) == [ - .codex, .claude, .vertexai, .bedrock, + .codex, .claude, .vertexai, .bedrock, .grok, ]) #endif #expect(Set(descriptors.filter { $0.cli.binaryLocator != nil }.map(\.id)) == [ @@ -228,6 +228,12 @@ struct ProviderArchitectureGatekeeperTests { #expect(MistralProviderDescriptor.descriptor.tokenCost.menuHintLines == [ .literal("Reported by Mistral billing usage."), ]) + #expect(GrokProviderDescriptor.descriptor.tokenCost.menuHintLines == [ + .literal("Local Grok session logs (turn_completed)."), + .literal("Chart uses daily tokens; $ only when ticks reported."), + ]) + #expect(GrokProviderDescriptor.descriptor.cli.supportsCostCommand) + #expect(GrokProviderDescriptor.descriptor.presentation.menuCard.supportsInlineTokenCostDashboard) } @Test @@ -1311,25 +1317,13 @@ struct ProviderArchitectureGatekeeperTests { reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBarCLI/CLICostCommand.swift", - line: 180, - anchor: "lines.append(Self.costEstimateHint(provider: .codex))", - expectedProviderIDs: ["codex"], - reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), - SuppressedProviderReference( - path: "Sources/CodexBarCLI/CLICostCommand.swift", - line: 203, - anchor: "lines.append(Self.costEstimateHint(provider: .codex))", - expectedProviderIDs: ["codex"], - reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), - SuppressedProviderReference( - path: "Sources/CodexBarCLI/CLICostCommand.swift", - line: 390, + line: 477, anchor: "let account = try context.resolvedAccounts(for: .cursor).first", expectedProviderIDs: ["cursor"], reason: "The Cursor-only cookie-settings resolver passes its fixed identity to token-account helpers."), SuppressedProviderReference( path: "Sources/CodexBarCLI/CLICostCommand.swift", - line: 391, + line: 478, anchor: "return context.settingsSnapshot(for: .cursor, account: account)?.cursor", expectedProviderIDs: ["cursor"], reason: "The Cursor-only cookie-settings resolver passes its fixed identity to token-account helpers."), @@ -1353,19 +1347,19 @@ struct ProviderArchitectureGatekeeperTests { reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 715, + line: 740, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 790, + line: 815, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 865, + line: 890, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), @@ -1707,11 +1701,11 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/CostHistoryChartMenuView.swift", - line: 950, - anchor: "let projects = provider == .codex ? snapshot.projects : []", - expectedProviderIDs: ["codex"], - expectedReferenceCount: 2, - expectedReferenceFingerprint: ["codex@0", "codex@1"], + line: 1160, + anchor: "let projects = (provider == .codex || provider == .grok) ? snapshot.projects : []", + expectedProviderIDs: ["codex", "grok"], + expectedReferenceCount: 4, + expectedReferenceFingerprint: ["codex@0", "grok@0", "codex@1", "grok@1"], reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/HistoricalUsagePace.swift", @@ -1739,7 +1733,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/InlineUsageDashboardContent.swift", - line: 260, + line: 283, anchor: "if provider == .cursor, let meteredCostUSD = snapshot.meteredCostUSD {", expectedProviderIDs: ["cursor"], expectedReferenceCount: 1, @@ -1795,7 +1789,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView+Costs.swift", - line: 440, + line: 488, anchor: "if style == .claude {", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -2443,11 +2437,11 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/StatusItemController+HostedSubmenus.swift", - line: 422, - anchor: "projects: provider == .codex ? tokenSnapshot.projects : [],", - expectedProviderIDs: ["codex"], - expectedReferenceCount: 2, - expectedReferenceFingerprint: ["codex@0", "codex@1"], + line: 423, + anchor: "projects: (provider == .codex || provider == .grok) ? tokenSnapshot.projects : [],", + expectedProviderIDs: ["codex", "grok"], + expectedReferenceCount: 4, + expectedReferenceFingerprint: ["codex@0", "grok@0", "codex@1", "grok@1"], reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/StatusItemController+MemoryPressure.swift", @@ -3274,23 +3268,39 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact CLI construct preserves the provider-specific command and output contract."), AllowedProviderConstruct( path: "Sources/CodexBarCLI/CLICostCommand.swift", - line: 208, - anchor: "provider == .codex", - expectedProviderIDs: ["codex"], - expectedReferenceCount: 1, - expectedReferenceFingerprint: ["codex@0"], - reason: "This exact CLI construct preserves the provider-specific command and output contract."), + line: 52, + anchor: "let unsupportedProjectProviders = providers.filter { $0 != .codex && $0 != .grok }", + expectedProviderIDs: ["codex", "grok"], + expectedReferenceCount: 2, + expectedReferenceFingerprint: ["codex@0", "grok@0"], + reason: "This exact CLI construct preserves project grouping for local session providers."), AllowedProviderConstruct( path: "Sources/CodexBarCLI/CLICostCommand.swift", - line: 228, - anchor: "let projects = provider == .codex", - expectedProviderIDs: ["codex"], - expectedReferenceCount: 1, - expectedReferenceFingerprint: ["codex@0"], - reason: "This exact CLI construct preserves the provider-specific command and output contract."), + line: 69, + anchor: "where groupBy != .project || provider == .codex || provider == .grok || format == .json", + expectedProviderIDs: ["codex", "grok"], + expectedReferenceCount: 2, + expectedReferenceFingerprint: ["codex@0", "grok@0"], + reason: "This exact CLI construct preserves project grouping for local session providers."), AllowedProviderConstruct( path: "Sources/CodexBarCLI/CLICostCommand.swift", - line: 400, + line: 142, + anchor: "case .codex:", + expectedProviderIDs: ["codex", "grok"], + expectedReferenceCount: 4, + expectedReferenceFingerprint: ["codex@0", "grok@2", "codex@9", "grok@9"], + reason: "This exact CLI construct preserves Grok/Codex titles and project grouping."), + AllowedProviderConstruct( + path: "Sources/CodexBarCLI/CLICostCommand.swift", + line: 314, + anchor: "let projects = (provider == .codex || provider == .grok)", + expectedProviderIDs: ["codex", "grok"], + expectedReferenceCount: 2, + expectedReferenceFingerprint: ["codex@0", "grok@0"], + reason: "This exact CLI construct includes project rollups only for local session providers."), + AllowedProviderConstruct( + path: "Sources/CodexBarCLI/CLICostCommand.swift", + line: 487, anchor: "guard provider == .cursor else { return nil }", expectedProviderIDs: ["cursor"], expectedReferenceCount: 1, @@ -3298,7 +3308,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact CLI construct preserves the provider-specific command and output contract."), AllowedProviderConstruct( path: "Sources/CodexBarCLI/CLICostCommand.swift", - line: 420, + line: 507, anchor: "guard provider == .cursor, settings?.cookieSource == .manual else { return nil }", expectedProviderIDs: ["cursor"], expectedReferenceCount: 1, @@ -3362,7 +3372,15 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 539, + line: 542, + anchor: "if provider == .vertexai,", + expectedProviderIDs: ["vertexai"], + expectedReferenceCount: 1, + expectedReferenceFingerprint: ["vertexai@0"], + reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), + AllowedProviderConstruct( + path: "Sources/CodexBarCore/CostUsageFetcher.swift", + line: 563, anchor: "if provider == .codex {", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3370,7 +3388,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 567, + line: 591, anchor: "provider == .claude || (provider == .codex && options.shouldMergePiUsage)", expectedProviderIDs: ["claude", "codex"], expectedReferenceCount: 5, @@ -3378,7 +3396,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 614, + line: 639, anchor: "options.provider == .codex || options.provider == .claude", expectedProviderIDs: ["claude", "codex"], expectedReferenceCount: 2, @@ -3386,7 +3404,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 641, + line: 666, anchor: "guard provider == .codex || provider == .claude else { return nil }", expectedProviderIDs: ["claude", "codex", "openai"], expectedReferenceCount: 5, @@ -3394,7 +3412,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 1114, + line: 1141, anchor: "if provider == .vertexai {", expectedProviderIDs: ["claude", "vertexai"], expectedReferenceCount: 2, @@ -3402,7 +3420,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 1365, + line: 1392, anchor: "if provider == .cursor {", expectedProviderIDs: ["cursor"], expectedReferenceCount: 1, diff --git a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift index ee48c1f852..0afa721c2c 100644 --- a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift +++ b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift @@ -1,7 +1,7 @@ -import CodexBarCore import Foundation import Testing @testable import CodexBar +@testable import CodexBarCore @MainActor struct SettingsStoreCoverageTests { @@ -502,6 +502,24 @@ struct SettingsStoreCoverageTests { env: [:], fileManager: fileManager, homeDirectory: desktopCodeHome)) + + // Grok-only local logs must not auto-enable global Cost tracking on upgrade. + let grokHome = fileManager.temporaryDirectory.appendingPathComponent( + "grok-home-\(UUID().uuidString)", + isDirectory: true) + let grokSessions = grokHome + .appendingPathComponent("sessions", isDirectory: true) + .appendingPathComponent("session-a", isDirectory: true) + try fileManager.createDirectory(at: grokSessions, withIntermediateDirectories: true) + let grokFile = grokSessions.appendingPathComponent("updates.jsonl") + fileManager.createFile(atPath: grokFile.path, contents: Data("{}\n".utf8)) + + #expect(!SettingsStore.hasAnyTokenCostUsageSources( + env: ["GROK_HOME": grokHome.path], + fileManager: fileManager, + homeDirectory: fileManager.temporaryDirectory.appendingPathComponent( + "empty-home-\(UUID().uuidString)", + isDirectory: true))) } @Test @@ -862,6 +880,50 @@ struct SettingsStoreCoverageTests { #expect(reloaded.preferredCurrencyCode == "GBP") } + @Test + func `disabling cost tracking deletes the Grok local parse cache`() async throws { + let cacheRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("grok-cost-disable-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: cacheRoot, withIntermediateDirectories: true) + defer { + GrokTurnUsageCacheIO.testDefaultCacheRoot = nil + try? FileManager.default.removeItem(at: cacheRoot) + } + GrokTurnUsageCacheIO.testDefaultCacheRoot = cacheRoot + + var cache = GrokTurnUsageCache(version: 2) + cache.files["/tmp/session/updates.jsonl"] = GrokTurnUsageCachedFile( + mtimeUnixMs: Int64(Date().timeIntervalSince1970 * 1000), + size: 10, + sessionID: "session", + cwd: "/tmp/session", + isPartial: false, + turns: []) + GrokTurnUsageCacheIO.save(cache: cache, cacheRoot: cacheRoot) + let url = GrokTurnUsageCacheIO.cacheFileURL(cacheRoot: nil) + #expect(FileManager.default.fileExists(atPath: url.path)) + + let suite = "SettingsStoreCoverageTests-grok-cost-disable" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let settings = Self.makeSettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite)) + settings.costUsageEnabled = true + settings.costUsageEnabled = false + + // invalidateAndDelete runs on a detached utility task; wait briefly for it. + var deleted = false + for _ in 0..<50 { + if !FileManager.default.fileExists(atPath: url.path) { + deleted = true + break + } + try await Task.sleep(nanoseconds: 20_000_000) + } + #expect(deleted) + } + private static func makeSettingsStore( suiteName: String = "SettingsStoreCoverageTests", antigravityOAuthCredentialsStore: AntigravityOAuthCredentialsStore = AntigravityOAuthCredentialsStore()) diff --git a/docs/cli.md b/docs/cli.md index c910a4ae49..b3e4a73a3f 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -50,8 +50,9 @@ See `docs/configuration.md` for the schema. - Legacy provider-specific keys such as `openRouterUsage`, `clawRouterUsage`, and `sub2APIUsage` are not compatibility aliases; clients must read `usage.details`. Unknown legacy keys in cached or iCloud-synced snapshots are ignored when decoding. -- `codexbar cost` prints token cost usage for Claude, Codex, and Cursor. - - Claude and Codex are scanned from local session logs without web/CLI access. +- `codexbar cost` prints token cost usage for Claude, Codex, Cursor, and Grok. + - Claude, Codex, and Grok are scanned from local session logs without web/CLI access. + - Grok cost reads `~/.grok/sessions/**/updates.jsonl` `turn_completed` usage (including reported cost ticks when present); see `docs/grok.md`. - Cursor is fetched from the cookie-authenticated cursor.com dashboard API (macOS only; see `docs/cursor.md`) and honors the configured cookie source: a non-empty Manual header is required and forwarded, while Off fails explicitly instead of silently omitting Cursor. - `--format text|json` (default: text). - `--refresh` ignores cached scans. @@ -192,13 +193,14 @@ payloads include the visible account label in `account`. ### Cost JSON payload `codexbar cost --format json` emits an array of payloads (one per provider). -- `provider`, `source` (`local` for Claude/Codex log scans, `web` for Cursor dashboard data), `updatedAt` +- `provider`, `source` (`local` for Claude/Codex/Grok log scans, `web` for Cursor dashboard data), `updatedAt` - `sessionTokens`, `sessionCostUSD` - `last30DaysTokens`, `last30DaysCostUSD` - `historyCoverageIsEstablished`: `false` while a bounded Codex scan still has catch-up work pending; `true` once the requested history is covered. +- `historyIsIncomplete`: `true` when totals may undercount because some local session logs were only partially scanned (for example oversized Grok archives under a per-file budget); `false` when coverage is complete for the requested window. - Cursor only: `meteredCostUSD` — what Cursor's plan actually deducts over the window, alongside the API-rate estimate in `last30DaysCostUSD`. - `daily[]`: `date`, `inputTokens`, `outputTokens`, `cacheReadTokens`, `cacheCreationTokens`, `totalTokens`, `totalCost`, `modelsUsed`, `modelBreakdowns[]` (`modelName`, `cost`) -- Codex only: `projects[]`: `name`, `path`, `totalTokens`, `totalCost`, `daily[]`, `modelBreakdowns[]`, `sources[]` +- Codex and Grok: `projects[]`: `name`, `path`, `totalTokens`, `totalCost`, `daily[]`, `modelBreakdowns[]`, `sources[]` (Codex includes `sources[]`; Grok project paths come from session `summary.json` cwd) - `totals`: `inputTokens`, `outputTokens`, `cacheReadTokens`, `cacheCreationTokens`, `totalTokens`, `totalCost` - `error`: structured provider error when a fetch fails (for example Cursor requested while its cookie source is Off). @@ -215,6 +217,7 @@ codexbar cost --provider codex --group-by project codexbar cost --provider claude --format json --pretty codexbar guard --provider codex --min-remaining 20 --window weekly --json codexbar cost --provider cursor # Cursor dashboard cost (API-rate + Cursor-metered) +codexbar cost --provider grok # Grok local session-log cost (turn_completed) codexbar dashboard | jq '.providers[] | {id, windows, error}' codexbar serve --port 8080 # localhost HTTP JSON server codexbar serve --request-timeout 0 # disable serve request deadlines diff --git a/docs/grok.md b/docs/grok.md index a51fafe4cf..daf4a8d11a 100644 --- a/docs/grok.md +++ b/docs/grok.md @@ -58,7 +58,34 @@ browser session when the CLI surface does not expose billing. returned by some successful requests. A current billing period with an omitted proto3 `credit_usage_percent` is treated as zero usage. This keeps billing visible when `grok agent stdio` returns `Method not found`. -4) **Local session signals** (informational fallback) +4) **Local session token cost** (Cost menu / `codexbar cost --provider grok`) + - Walks `~/.grok/sessions/**/updates.jsonl` for `sessionUpdate: turn_completed`. + - Reads per-turn `usage` (`inputTokens`, `cachedReadTokens`, `outputTokens`, + `totalTokens`, `costUsdTicks`, `modelUsage`) and maps into the shared + `CostUsageTokenSnapshot` so the Grok menu Cost card matches Codex style + (`Today: $X · Y tokens` / `Last N days: …`). + - Uncached input = `inputTokens - cachedReadTokens` (ACP full input minus cache). + - Cost USD = `costUsdTicks / 1e10` when present; missing ticks are not estimated. + - Project breakdown uses `summary.json` → `info.cwd`. + - **Local parse cache (privacy / retention):** when Cost tracking is enabled + for Grok, CodexBar may write a bounded on-disk parse cache under the user + Caches directory (`~/Library/Caches/CodexBar/cost-usage/grok-turns-v*.json` + on macOS; same relative path under the process cache root elsewhere). The + cache is **local-only** (never uploaded), mirrors the Codex cost-cache + safety model, and exists so budget-deferred session archives can catch up + without re-reading every log on each refresh. Stored fields are limited to + session/file path keys, session and event IDs, mtime/size, optional `cwd`, + timestamps, model names, token totals, and reported cost when present—no + prompt/completion content. + - **Expiry:** session-file entries older than **90 days** (by log mtime) + are dropped on every load/save; a fully expired artifact is deleted. + - **Budgets:** load refuses oversized artifacts; save prunes oldest files + by entry and byte budgets. + - **Delete on Cost off:** turning Cost tracking off bumps a write-generation + token and deletes the Grok parse cache so in-flight scans cannot recreate + the artifact after opt-out (same file is also removed by + `codexbar cache clear --cost` / Debug → clear cost cache). +5) **Local session signals** (informational fallback) - Walks `~/.grok/sessions///signals.json` files (last 30 days). - Aggregates `totalTokensBeforeCompaction`, `contextTokensUsed`, `modelsUsed`, and the most recent session timestamp. @@ -160,4 +187,5 @@ points to `https://status.x.ai`. - `Sources/CodexBarCore/Providers/Grok/GrokWebBillingFetcher.swift` - `Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift` - `Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift` +- `Sources/CodexBarCore/Providers/Grok/GrokTurnUsageScanner.swift` - `Sources/CodexBar/Providers/Grok/GrokProviderImplementation.swift` diff --git a/proof/grok-cost-cache-bounds-final-head.png b/proof/grok-cost-cache-bounds-final-head.png new file mode 100644 index 0000000000..44a454d440 Binary files /dev/null and b/proof/grok-cost-cache-bounds-final-head.png differ diff --git a/proof/grok-cost-cli-incomplete-final-head.png b/proof/grok-cost-cli-incomplete-final-head.png new file mode 100644 index 0000000000..8bf0a54f95 Binary files /dev/null and b/proof/grok-cost-cli-incomplete-final-head.png differ diff --git a/proof/grok-cost-cli-project-final-head.png b/proof/grok-cost-cli-project-final-head.png new file mode 100644 index 0000000000..8bf0a54f95 Binary files /dev/null and b/proof/grok-cost-cli-project-final-head.png differ diff --git a/proof/grok-cost-cli-project-head-520fb066.png b/proof/grok-cost-cli-project-head-520fb066.png new file mode 100644 index 0000000000..918f628a69 Binary files /dev/null and b/proof/grok-cost-cli-project-head-520fb066.png differ diff --git a/proof/grok-cost-cli-project-head-76bf2b82.png b/proof/grok-cost-cli-project-head-76bf2b82.png new file mode 100644 index 0000000000..918f628a69 Binary files /dev/null and b/proof/grok-cost-cli-project-head-76bf2b82.png differ diff --git a/proof/grok-cost-cli-project-head-dbd9deaa.png b/proof/grok-cost-cli-project-head-dbd9deaa.png new file mode 100644 index 0000000000..918f628a69 Binary files /dev/null and b/proof/grok-cost-cli-project-head-dbd9deaa.png differ diff --git a/proof/grok-cost-cli-project-head-ed17a2e5.png b/proof/grok-cost-cli-project-head-ed17a2e5.png new file mode 100644 index 0000000000..918f628a69 Binary files /dev/null and b/proof/grok-cost-cli-project-head-ed17a2e5.png differ diff --git a/proof/grok-cost-cli-project-no-self-source.png b/proof/grok-cost-cli-project-no-self-source.png new file mode 100644 index 0000000000..918f628a69 Binary files /dev/null and b/proof/grok-cost-cli-project-no-self-source.png differ diff --git a/proof/grok-cost-menu-final-head.png b/proof/grok-cost-menu-final-head.png new file mode 100644 index 0000000000..384d37a90c Binary files /dev/null and b/proof/grok-cost-menu-final-head.png differ diff --git a/proof/grok-cost-menu-head-8ffe623d-redacted.png b/proof/grok-cost-menu-head-8ffe623d-redacted.png new file mode 100644 index 0000000000..e7ba10e476 Binary files /dev/null and b/proof/grok-cost-menu-head-8ffe623d-redacted.png differ diff --git a/proof/grok-cost-menu-head-c5b3363a.png b/proof/grok-cost-menu-head-c5b3363a.png new file mode 100644 index 0000000000..b52e0b0f43 Binary files /dev/null and b/proof/grok-cost-menu-head-c5b3363a.png differ diff --git a/proof/grok-cost-menu-head-dbd9deaa.png b/proof/grok-cost-menu-head-dbd9deaa.png new file mode 100644 index 0000000000..e7ba10e476 Binary files /dev/null and b/proof/grok-cost-menu-head-dbd9deaa.png differ diff --git a/proof/grok-cost-optin-default-head-ed17a2e5.png b/proof/grok-cost-optin-default-head-ed17a2e5.png new file mode 100644 index 0000000000..87db54d09b Binary files /dev/null and b/proof/grok-cost-optin-default-head-ed17a2e5.png differ diff --git a/proof/grok-cost-retention-final-head.png b/proof/grok-cost-retention-final-head.png new file mode 100644 index 0000000000..a2d9fa102f Binary files /dev/null and b/proof/grok-cost-retention-final-head.png differ diff --git a/proof/grok-cost-retention-final-head.txt b/proof/grok-cost-retention-final-head.txt new file mode 100644 index 0000000000..36f51a6ad8 --- /dev/null +++ b/proof/grok-cost-retention-final-head.txt @@ -0,0 +1,50 @@ +Grok Cost cache retention — current-head real behavior proof +Banner: final head (no stale short-hash only) +Full head: 602b2980274dd9819adefc48b1fd1b0b487b9a92 +Short: 602b2980 +Time: 2026-08-10T02:44:27Z + +Feature under proof: + 1) 90-day mtime expiry drops stale session entries / deletes empty artifacts + 2) Cost-off invalidation bumps write generation + deletes cache + 3) Stale in-flight save (old write token) does NOT recreate the cache + +Implementation anchors (this head): + - Sources/CodexBarCore/Providers/Grok/GrokTurnUsageCache.swift + beginWriteToken(cacheRoot:) / invalidateAndDelete / save(writeToken:) + path-keyed write generation for test isolation + concurrent safety + - Sources/CodexBarCore/Providers/Grok/GrokTurnUsageScanner.swift + captures writeToken before scan; save uses writeToken + - Sources/CodexBar/SettingsStore+Defaults.swift + costUsageEnabled=false -> invalidateAndDelete() + +Regression tests (GrokTurnUsageScannerTests / SettingsStoreCoverageTests): + - expires Grok cache entries older than max age and deletes empty artifacts + - deleteCache removes the Grok parse cache file + - stale write token does not recreate cache after Cost-off invalidation + - disabling cost tracking deletes the Grok local parse cache + +Live protocol simulation (redacted paths only; isolated temp cache root): + [seed] writeToken=0 artifact exists at …/cost-usage/grok-turns-v2.json + path key: /Users/REDACTED/project/updates.jsonl + [scan] captured writeToken=0 via beginWriteToken(cacheRoot:) + [user] Cost tracking OFF -> invalidateAndDelete(cacheRoot:) + [inv] generation now 1 for that cache path; artifact deleted + [race] in-flight save(writeToken=0) isWriteTokenValid=false -> SKIP (no recreate) + [ok] cache remains absent after stale save + [re-on] Cost ON, fresh writeToken=1 valid=true -> may persist again + +Expiry simulation (maxEntryAge = 90d, now-relative mtimes): + before: 2 entries (fresh + stale) + after 90d prune: 1 entries + kept: /Users/REDACTED/project-fresh/updates.jsonl + dropped: /Users/REDACTED/project-stale/updates.jsonl + fully-expired artifact -> deleted from disk + +Build verification (this machine; Swift 6.3.3 via swiftly; no full Xcode): + - swift build --target CodexBarCore ✅ + - swift build --target CodexBarCLI ✅ + - Full CodexBarTests blocked by Widget SwiftUI @Entry macro (needs full Xcode SDK) + +RESULT: retention contract demonstrated for final head + expiry=90d, delete-on-disable=invalidateAndDelete, race-safe save token fence