diff --git a/Sources/CodexBar/CostHistoryChartMenuView.swift b/Sources/CodexBar/CostHistoryChartMenuView.swift index 9c77cf4ef..594669196 100644 --- a/Sources/CodexBar/CostHistoryChartMenuView.swift +++ b/Sources/CodexBar/CostHistoryChartMenuView.swift @@ -6,20 +6,35 @@ import SwiftUI struct CostHistoryChartMenuView: View { typealias DailyEntry = CostUsageDailyReport.Entry - private struct Point: Identifiable { + struct Point: Identifiable { let id: String let date: Date - let costUSD: Double + let displayCostUSD: Double + let actualCostUSD: Double? let totalTokens: Int? - init(date: Date, costUSD: Double, totalTokens: Int?) { + init(date: Date, displayCostUSD: Double, actualCostUSD: Double?, totalTokens: Int?) { self.date = date - self.costUSD = costUSD + self.displayCostUSD = displayCostUSD + self.actualCostUSD = actualCostUSD self.totalTokens = totalTokens - self.id = "\(Int(date.timeIntervalSince1970))-\(costUSD)" + self.id = "\(Int(date.timeIntervalSince1970))-\(displayCostUSD)" } } + struct DetailModelLine: Identifiable { + let id: String + let text: String + let title: String + let subtitle: String? + let accentColor: Color + } + + struct DetailContent { + let primary: String + let models: [DetailModelLine] + } + private let provider: UsageProvider private let daily: [DailyEntry] private let totalCostUSD: Double? @@ -45,15 +60,16 @@ struct CostHistoryChartMenuView: View { ForEach(model.points) { point in BarMark( x: .value("Day", point.date, unit: .day), - y: .value("Cost", point.costUSD)) + y: .value("Cost", point.displayCostUSD)) .foregroundStyle(model.barColor) } if let peak = Self.peakPoint(model: model) { - let capStart = max(peak.costUSD - Self.capHeight(maxValue: model.maxCostUSD), 0) + let peakCostUSD = peak.actualCostUSD ?? 0 + let capStart = max(peakCostUSD - Self.capHeight(maxValue: model.maxCostUSD), 0) BarMark( x: .value("Day", peak.date, unit: .day), yStart: .value("Cap start", capStart), - yEnd: .value("Cap end", peak.costUSD)) + yEnd: .value("Cap end", peakCostUSD)) .foregroundStyle(Color(nsColor: .systemYellow)) } } @@ -88,22 +104,48 @@ struct CostHistoryChartMenuView: View { } } - let detail = self.detailLines(model: model) - VStack(alignment: .leading, spacing: 0) { + let detail = Self.detailContent(selectedDateKey: self.selectedDateKey, model: model) + VStack(alignment: .leading, spacing: Self.detailSpacing) { Text(detail.primary) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.tail) - .frame(height: 16, alignment: .leading) - Text(detail.secondary ?? " ") - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.tail) - .frame(height: 16, alignment: .leading) - .opacity(detail.secondary == nil ? 0 : 1) + .frame(height: Self.detailPrimaryLineHeight, alignment: .leading) + ForEach(detail.models) { row in + HStack(alignment: .top, spacing: 8) { + Rectangle() + .fill(row.accentColor) + .frame(width: 2, height: row.subtitle == nil ? 14 : Self.detailRowHeight) + .padding(.top, 1) + + VStack(alignment: .leading, spacing: 1) { + Text(row.title) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + if let subtitle = row.subtitle { + Text(subtitle) + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + .lineLimit(1) + .truncationMode(.tail) + } + } + } + .frame(height: Self.detailRowHeight, alignment: .leading) + } + ForEach(0.. Double { maxValue * 0.05 } - private static func makeModel(provider: UsageProvider, daily: [DailyEntry]) -> Model { + static func makeModel(provider: UsageProvider, daily: [DailyEntry]) -> Model { let sorted = daily.sorted { lhs, rhs in lhs.date < rhs.date } + var entriesByKey: [String: DailyEntry] = [:] + entriesByKey.reserveCapacity(sorted.count) + for entry in sorted { + entriesByKey[entry.date] = entry + } + 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 for entry in sorted { - guard let costUSD = entry.costUSD, costUSD > 0 else { continue } + if let displayCostUSD = Self.displayCostUSD(for: entry), displayCostUSD > 0 { + maxCostUSD = max(maxCostUSD, displayCostUSD) + } + } + + var maxDetailLineCount = 0 + for entry in sorted { + maxDetailLineCount = max(maxDetailLineCount, Self.renderedBreakdownRowCount(for: entry)) + guard let displayCostUSD = Self.displayCostUSD(for: entry) else { continue } guard let date = self.dateFromDayKey(entry.date) else { continue } - let point = Point(date: date, costUSD: costUSD, totalTokens: entry.totalTokens) + let point = Point( + date: date, + displayCostUSD: displayCostUSD, + actualCostUSD: entry.costUSD, + totalTokens: entry.totalTokens) points.append(point) pointsByKey[entry.date] = point - entriesByKey[entry.date] = entry dateKeys.append((entry.date, date)) - if let cur = peak { - if costUSD > cur.costUSD { peak = (entry.date, costUSD) } - } else { - peak = (entry.date, costUSD) + if displayCostUSD > 0 { + if let cur = peak { + if displayCostUSD > cur.costUSD { peak = (entry.date, displayCostUSD) } + } else { + peak = (entry.date, displayCostUSD) + } } - maxCostUSD = max(maxCostUSD, costUSD) } let axisDates: [Date] = { @@ -181,7 +243,8 @@ struct CostHistoryChartMenuView: View { axisDates: axisDates, barColor: barColor, peakKey: peak?.key, - maxCostUSD: maxCostUSD) + maxCostUSD: maxCostUSD, + maxDetailLineCount: maxDetailLineCount) } private static func barColor(for provider: UsageProvider) -> Color { @@ -211,6 +274,21 @@ struct CostHistoryChartMenuView: View { return model.pointsByDateKey[key] } + private static func renderedBreakdownRowCount(for entry: DailyEntry) -> Int { + guard let breakdown = entry.modelBreakdowns, !breakdown.isEmpty else { return 0 } + if breakdown.count > self.maxVisibleDetailLines { + return self.maxVisibleDetailLines + } + return breakdown.count + } + + private static func detailBlockHeight(maxBreakdownRows: Int) -> CGFloat { + guard maxBreakdownRows > 0 else { return self.detailPrimaryLineHeight } + return self.detailPrimaryLineHeight + + (CGFloat(maxBreakdownRows) * self.detailRowHeight) + + (CGFloat(maxBreakdownRows) * self.detailSpacing) + } + private func selectionBandRect(model: Model, proxy: ChartProxy, geo: GeometryProxy) -> CGRect? { guard let key = self.selectedDateKey else { return nil } guard let plotAnchor = proxy.plotFrame else { return nil } @@ -286,41 +364,165 @@ struct CostHistoryChartMenuView: View { return best?.key } - private func detailLines(model: Model) -> (primary: String, secondary: String?) { - guard let key = self.selectedDateKey, + static func detailContent(selectedDateKey: String?, model: Model) -> DetailContent { + guard let key = selectedDateKey, let point = model.pointsByDateKey[key], - let date = Self.dateFromDayKey(key) + let date = self.dateFromDayKey(key) else { - return ("Hover a bar for details", nil) + return DetailContent(primary: "Hover a bar for details", models: []) } let dayLabel = date.formatted(.dateTime.month(.abbreviated).day()) - let cost = UsageFormatter.usdString(point.costUSD) - if let tokens = point.totalTokens { - let primary = "\(dayLabel): \(cost) · \(UsageFormatter.tokenCountString(tokens)) tokens" - let secondary = self.topModelsText(key: key, model: model) - return (primary, secondary) + let partial = Self.hasUnpricedModels(key: key, model: model) ? " partial" : "" + let primary = if let actualCostUSD = point.actualCostUSD, actualCostUSD > 0 { + "\(dayLabel): \(UsageFormatter.usdString(actualCostUSD))\(partial)" + } else if point.displayCostUSD > 0 { + "\(dayLabel): \(UsageFormatter.usdString(point.displayCostUSD))\(partial)" + } else if point.totalTokens ?? 0 > 0 { + "\(dayLabel): No priced cost data" + } else { + "\(dayLabel): No cost data" + } + + let primaryWithTokens = if let tokens = point.totalTokens { + "\(primary) · \(UsageFormatter.tokenCountString(tokens)) tokens" + } else { + primary } - let primary = "\(dayLabel): \(cost)" - let secondary = self.topModelsText(key: key, model: model) - return (primary, secondary) + return DetailContent( + primary: primaryWithTokens, + models: Self.detailModelLines(key: key, model: model)) } - private func topModelsText(key: String, model: Model) -> String? { - guard let entry = model.entriesByDateKey[key] else { return nil } - guard let breakdown = entry.modelBreakdowns, !breakdown.isEmpty else { return nil } - let parts = breakdown - .compactMap { item -> (name: String, costUSD: Double)? in - guard let costUSD = item.costUSD, costUSD > 0 else { return nil } - return (UsageFormatter.modelDisplayName(item.modelName), costUSD) - } + private static func hasUnpricedModels(key: String, model: Model) -> Bool { + guard let entry = model.entriesByDateKey[key], + let breakdown = entry.modelBreakdowns + else { + return false + } + return breakdown.contains { $0.costUSD == nil } + } + + private static func displayCostUSD(for entry: DailyEntry) -> Double? { + if let actualCostUSD = entry.costUSD, actualCostUSD > 0 { + return actualCostUSD + } + guard let breakdown = entry.modelBreakdowns else { return nil } + let subtotal = breakdown.reduce(0.0) { partial, item in + partial + max(0, item.costUSD ?? 0) + } + if subtotal > 0 { + return subtotal + } + return (entry.totalTokens ?? 0) > 0 ? 0 : nil + } + + private static func detailModelLines(key: String, model: Model) -> [DetailModelLine] { + guard let entry = model.entriesByDateKey[key] else { return [] } + guard let breakdown = entry.modelBreakdowns, !breakdown.isEmpty else { return [] } + + let sorted = breakdown .sorted { lhs, rhs in - if lhs.costUSD == rhs.costUSD { return lhs.name < rhs.name } - return lhs.costUSD > rhs.costUSD + let lCost = lhs.costUSD ?? -1 + let rCost = rhs.costUSD ?? -1 + if lCost != rCost { return lCost > rCost } + let lTokens = lhs.totalTokens ?? -1 + let rTokens = rhs.totalTokens ?? -1 + if lTokens != rTokens { return lTokens > rTokens } + return lhs.modelName < rhs.modelName } - .prefix(3) - .map { "\($0.name) \(UsageFormatter.usdString($0.costUSD))" } - guard !parts.isEmpty else { return nil } - return "Top: \(parts.joined(separator: " · "))" + + let visibleLimit = sorted.count > Self.maxVisibleDetailLines + ? (Self.maxVisibleDetailLines - 1) + : sorted.count + let visible = Array(sorted.prefix(visibleLimit)) + var rows = visible.enumerated().map { index, item in + let title = Self.detailModelDisplayName(item.modelName) + let subtitle = Self.breakdownValueText( + costUSD: item.costUSD, + totalTokens: item.totalTokens, + includeUnpricedLabel: item.costUSD == nil) + return DetailModelLine( + id: "\(item.modelName)-\(index)", + text: Self.detailLineText(title: title, costUSD: item.costUSD, totalTokens: item.totalTokens), + title: title, + subtitle: subtitle, + accentColor: model.barColor.opacity(Self.breakdownAccentOpacity(for: index))) + } + + let hidden = Array(sorted.dropFirst(visibleLimit)) + if !hidden.isEmpty { + let hiddenCost = hidden.reduce(0.0) { partial, item in + partial + (item.costUSD ?? 0) + } + let hiddenTokens = hidden.reduce(0) { partial, item in + partial + (item.totalTokens ?? 0) + } + let overflowTitle = hidden.count == 1 ? "1 more model" : "\(hidden.count) more models" + rows.append(DetailModelLine( + id: "overflow", + text: overflowTitle, + title: overflowTitle, + subtitle: Self.breakdownValueText( + costUSD: hiddenCost > 0 ? hiddenCost : nil, + totalTokens: hiddenTokens > 0 ? hiddenTokens : nil), + accentColor: Color(nsColor: .tertiaryLabelColor).opacity(0.55))) + } + + return rows + } + + private static func breakdownAccentOpacity(for index: Int) -> Double { + let opacity = 0.75 - (Double(index) * 0.12) + return max(0.3, opacity) + } + + private static func detailLineText(title: String, costUSD: Double?, totalTokens: Int?) -> String { + let tokensText = totalTokens.map { " · \(UsageFormatter.tokenCountString($0)) tokens" } ?? "" + if let costUSD, costUSD > 0 { + return "\(title): \(UsageFormatter.usdString(costUSD))\(tokensText)" + } + return "\(title): unpriced\(tokensText)" + } + + private static func detailModelDisplayName(_ raw: String) -> String { + let cleaned = UsageFormatter.modelDisplayName(raw) + let lower = cleaned.lowercased() + if lower == "unknown" { + return "Unknown model" + } + guard lower.hasPrefix("gpt-") else { + return cleaned + } + + let remainder = lower.dropFirst("gpt-".count) + let parts = remainder.split(separator: "-", omittingEmptySubsequences: true) + guard let version = parts.first else { + return cleaned + } + let suffix = parts.dropFirst().map { part in + String(part.prefix(1)).uppercased() + String(part.dropFirst()) + } + guard !suffix.isEmpty else { + return "GPT-\(version)" + } + return "GPT-\(version) \(suffix.joined(separator: " "))" + } + + private static func breakdownValueText( + costUSD: Double?, + totalTokens: Int?, + includeUnpricedLabel: Bool = false) -> String? + { + var parts: [String] = [] + if let costUSD, costUSD > 0 { + parts.append(UsageFormatter.usdString(costUSD)) + } else if includeUnpricedLabel { + parts.append("unpriced") + } + if let totalTokens, totalTokens > 0 { + parts.append("\(UsageFormatter.tokenCountString(totalTokens)) tokens") + } + return parts.isEmpty ? nil : parts.joined(separator: " · ") } } diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 789a517e1..32478780b 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -2,6 +2,14 @@ import AppKit import CodexBarCore import SwiftUI +extension View { + fileprivate func menuCardSectionTitleStyle() -> some View { + self + .font(.body) + .fontWeight(.medium) + } +} + /// SwiftUI card used inside the NSMenu to mirror Apple's rich menu panels. struct UsageMenuCardView: View { struct Model { @@ -27,6 +35,8 @@ struct UsageMenuCardView: View { struct Metric: Identifiable { let id: String let title: String + let groupID: String? + let groupTitle: String? let percent: Double let percentStyle: PercentStyle let resetText: String? @@ -41,6 +51,27 @@ struct UsageMenuCardView: View { } } + struct MetricGroup: Identifiable { + enum Kind { + case builtInPrimary + case providerBucket + } + + let id: String + let title: String? + let kind: Kind + let metrics: [Metric] + + var internalID: String { + switch self.kind { + case .builtInPrimary: + "builtInPrimary" + case .providerBucket: + "providerBucket:\(self.id)" + } + } + } + enum SubtitleStyle { case info case loading @@ -82,6 +113,7 @@ struct UsageMenuCardView: View { let model: Model let width: CGFloat @Environment(\.menuItemHighlighted) private var isHighlighted + private static let builtInPrimaryMetricGroupID = "__builtInPrimary" static func popupMetricTitle(provider: UsageProvider, metric: Model.Metric) -> String { if provider == .openrouter, metric.id == "primary" { @@ -90,6 +122,63 @@ struct UsageMenuCardView: View { return metric.title } + static func metricGroups(metrics: [Model.Metric]) -> [Model.MetricGroup] { + guard !metrics.isEmpty else { return [] } + var groups: [Model.MetricGroup] = [] + let primaryMetrics = metrics.filter { $0.groupID == nil } + if !primaryMetrics.isEmpty { + groups.append(.init( + id: self.builtInPrimaryMetricGroupID, + title: nil, + kind: .builtInPrimary, + metrics: primaryMetrics)) + } + + var supplementalGroups: [String: [Model.Metric]] = [:] + var supplementalOrder: [String] = [] + for metric in metrics { + guard let groupID = metric.groupID else { continue } + if supplementalGroups[groupID] == nil { + supplementalOrder.append(groupID) + } + supplementalGroups[groupID, default: []].append(metric) + } + + for groupID in supplementalOrder { + guard let metrics = supplementalGroups[groupID], !metrics.isEmpty else { continue } + groups.append(.init( + id: groupID, + title: metrics.first?.groupTitle, + kind: .providerBucket, + metrics: metrics)) + } + + return groups + } + + static func primaryMetricGroup(metrics: [Model.Metric]) -> Model.MetricGroup? { + self.metricGroups(metrics: metrics).first { $0.kind == .builtInPrimary } + } + + static func supplementalMetricGroups(metrics: [Model.Metric]) -> [Model.MetricGroup] { + self.metricGroups(metrics: metrics).filter { $0.kind == .providerBucket } + } + + static func emptyPrimaryMetricGroup() -> Model.MetricGroup { + .init( + id: self.builtInPrimaryMetricGroupID, + title: nil, + kind: .builtInPrimary, + metrics: []) + } + + static func displayMetricTitle(provider: UsageProvider, metric: Model.Metric) -> String { + if provider == .openrouter, metric.id == "primary" { + return "API key limit" + } + return metric.title + } + var body: some View { VStack(alignment: .leading, spacing: 6) { UsageMenuCardHeaderView(model: self.model) @@ -111,15 +200,29 @@ struct UsageMenuCardView: View { let hasCredits = self.model.creditsText != nil let hasProviderCost = self.model.providerCost != nil let hasCost = self.model.tokenUsage != nil || hasProviderCost + let metricGroups = Self.metricGroups(metrics: self.model.metrics) VStack(alignment: .leading, spacing: 12) { if hasUsage { VStack(alignment: .leading, spacing: 12) { - ForEach(self.model.metrics, id: \.id) { metric in - MetricRow( - metric: metric, - title: Self.popupMetricTitle(provider: self.model.provider, metric: metric), - progressColor: self.model.progressColor) + ForEach(Array(metricGroups.enumerated()), id: \.element.internalID) { index, group in + if index > 0 { + Divider() + } + VStack(alignment: .leading, spacing: 12) { + if let title = group.title { + Text(title) + .menuCardSectionTitleStyle() + } + ForEach(group.metrics, id: \.id) { metric in + MetricRow( + metric: metric, + title: Self.displayMetricTitle( + provider: self.model.provider, + metric: metric), + progressColor: self.model.progressColor) + } + } } if !self.model.usageNotes.isEmpty { UsageNotesContent(notes: self.model.usageNotes) @@ -301,8 +404,7 @@ private struct ProviderCostContent: View { var body: some View { VStack(alignment: .leading, spacing: 6) { Text(self.section.title) - .font(.body) - .fontWeight(.medium) + .menuCardSectionTitleStyle() UsageProgressBar( percent: self.section.percentUsed, tint: self.progressColor, @@ -328,8 +430,7 @@ private struct MetricRow: View { var body: some View { VStack(alignment: .leading, spacing: 6) { Text(self.title) - .font(.body) - .fontWeight(.medium) + .menuCardSectionTitleStyle() UsageProgressBar( percent: self.metric.percent, tint: self.progressColor, @@ -425,6 +526,7 @@ struct UsageMenuCardUsageSectionView: View { @Environment(\.menuItemHighlighted) private var isHighlighted var body: some View { + let metricGroups = UsageMenuCardView.metricGroups(metrics: self.model.metrics) VStack(alignment: .leading, spacing: 12) { if self.model.metrics.isEmpty { if !self.model.usageNotes.isEmpty { @@ -435,11 +537,24 @@ struct UsageMenuCardUsageSectionView: View { .font(.subheadline) } } else { - ForEach(self.model.metrics, id: \.id) { metric in - MetricRow( - metric: metric, - title: UsageMenuCardView.popupMetricTitle(provider: self.model.provider, metric: metric), - progressColor: self.model.progressColor) + ForEach(Array(metricGroups.enumerated()), id: \.element.internalID) { index, group in + if index > 0 { + Divider() + } + VStack(alignment: .leading, spacing: 12) { + if let title = group.title { + Text(title) + .menuCardSectionTitleStyle() + } + ForEach(group.metrics, id: \.id) { metric in + MetricRow( + metric: metric, + title: UsageMenuCardView.displayMetricTitle( + provider: self.model.provider, + metric: metric), + progressColor: self.model.progressColor) + } + } } if !self.model.usageNotes.isEmpty { UsageNotesContent(notes: self.model.usageNotes) @@ -456,6 +571,52 @@ struct UsageMenuCardUsageSectionView: View { } } +struct UsageMenuCardMetricGroupSectionView: View { + let provider: UsageProvider + let group: UsageMenuCardView.Model.MetricGroup + let usageNotes: [String] + let placeholder: String? + let topPadding: CGFloat + let bottomPadding: CGFloat + let width: CGFloat + let progressColor: Color + @Environment(\.menuItemHighlighted) private var isHighlighted + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + if let title = self.group.title { + Text(title) + .menuCardSectionTitleStyle() + } + if self.group.metrics.isEmpty { + if !self.usageNotes.isEmpty { + UsageNotesContent(notes: self.usageNotes) + } else if let placeholder = self.placeholder { + Text(placeholder) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .font(.subheadline) + } + } else { + ForEach(self.group.metrics, id: \.id) { metric in + MetricRow( + metric: metric, + title: UsageMenuCardView.displayMetricTitle( + provider: self.provider, + metric: metric), + progressColor: self.progressColor) + } + if !self.usageNotes.isEmpty { + UsageNotesContent(notes: self.usageNotes) + } + } + } + .padding(.horizontal, 16) + .padding(.top, self.topPadding) + .padding(.bottom, self.bottomPadding) + .frame(width: self.width, alignment: .leading) + } +} + struct UsageMenuCardCreditsSectionView: View { let model: UsageMenuCardView.Model let showBottomDivider: Bool @@ -508,8 +669,7 @@ private struct CreditsBarContent: View { var body: some View { VStack(alignment: .leading, spacing: 6) { Text("Credits") - .font(.body) - .fontWeight(.medium) + .menuCardSectionTitleStyle() if let percentLeft { UsageProgressBar( percent: percentLeft, @@ -924,6 +1084,8 @@ extension UsageMenuCardView.Model { metrics.append(Metric( id: "primary", title: input.metadata.sessionLabel, + groupID: nil, + groupTitle: nil, percent: Self.clamped( input.usageBarsShowUsed ? primary.usedPercent : primary.remainingPercent), percentStyle: percentStyle, @@ -961,6 +1123,8 @@ extension UsageMenuCardView.Model { metrics.append(Metric( id: "secondary", title: input.metadata.weeklyLabel, + groupID: nil, + groupTitle: nil, percent: Self.clamped(input.usageBarsShowUsed ? weekly.usedPercent : weekly.remainingPercent), percentStyle: percentStyle, resetText: weeklyResetText, @@ -986,6 +1150,8 @@ extension UsageMenuCardView.Model { metrics.append(Metric( id: "tertiary", title: input.metadata.opusLabel ?? "Sonnet", + groupID: nil, + groupTitle: nil, percent: Self.clamped(input.usageBarsShowUsed ? opus.usedPercent : opus.remainingPercent), percentStyle: percentStyle, resetText: Self.resetText(for: opus, style: input.resetTimeDisplayStyle, now: input.now), @@ -995,12 +1161,32 @@ extension UsageMenuCardView.Model { pacePercent: nil, paceOnTop: true)) } + for bucketGroup in snapshot.usageBucketGroups { + for bucket in bucketGroup.buckets { + metrics.append(Metric( + id: bucket.id, + title: bucket.title, + groupID: bucketGroup.id, + groupTitle: bucketGroup.title, + percent: Self.clamped( + input.usageBarsShowUsed ? bucket.window.usedPercent : bucket.window.remainingPercent), + percentStyle: percentStyle, + resetText: Self.resetText(for: bucket.window, style: input.resetTimeDisplayStyle, now: input.now), + detailText: nil, + detailLeftText: nil, + detailRightText: nil, + pacePercent: nil, + paceOnTop: true)) + } + } if input.provider == .codex, let remaining = input.dashboard?.codeReviewRemainingPercent { let percent = input.usageBarsShowUsed ? (100 - remaining) : remaining metrics.append(Metric( id: "code-review", title: "Code review", + groupID: nil, + groupTitle: nil, percent: Self.clamped(percent), percentStyle: percentStyle, resetText: nil, diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 05aa55fff..d7e9c8fcc 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -179,6 +179,11 @@ struct MenuDescriptor { resetStyle: resetStyle, showUsed: settings.usageBarsShowUsed) } + Self.appendUsageBucketGroupEntries( + entries: &entries, + snapshot: snap, + resetStyle: resetStyle, + showUsed: settings.usageBarsShowUsed) if let cost = snap.providerCost { if cost.currencyCode == "Quota" { @@ -407,6 +412,30 @@ struct MenuDescriptor { return false } + private static func appendUsageBucketGroupEntries( + entries: inout [Entry], + snapshot: UsageSnapshot, + resetStyle: ResetTimeDisplayStyle, + showUsed: Bool) + { + for group in snapshot.usageBucketGroups { + guard !group.buckets.isEmpty else { continue } + if entries.count > 1 { + entries.append(.divider) + } + entries.append(.text(group.title, .headline)) + + for bucket in group.buckets { + self.appendRateWindow( + entries: &entries, + title: bucket.title, + window: bucket.window, + resetStyle: resetStyle, + showUsed: showUsed) + } + } + } + private static func appendRateWindow( entries: inout [Entry], title: String, diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 5d9173624..5589a25bd 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -439,7 +439,9 @@ extension StatusItemController { } guard let model = self.menuCardModel(for: context.selectedProvider) else { return false } - if context.openAIContext.hasOpenAIWebMenuItems { + let supplementalMetricGroups = UsageMenuCardView.supplementalMetricGroups(metrics: model.metrics) + let shouldRenderSectionedCard = context.openAIContext.hasOpenAIWebMenuItems || !supplementalMetricGroups.isEmpty + if shouldRenderSectionedCard { let webItems = OpenAIWebMenuItems( hasUsageBreakdown: context.openAIContext.hasUsageBreakdown, hasCreditsHistory: context.openAIContext.hasCreditsHistory, @@ -450,7 +452,10 @@ extension StatusItemController { provider: context.currentProvider, width: context.menuWidth, webItems: webItems) - return true + if !context.openAIContext.hasOpenAIWebMenuItems { + menu.addItem(.separator()) + } + return context.openAIContext.hasOpenAIWebMenuItems } menu.addItem(self.makeMenuCardItem( @@ -841,7 +846,10 @@ extension StatusItemController { width: CGFloat, webItems: OpenAIWebMenuItems) { - let hasUsageBlock = !model.metrics.isEmpty || model.placeholder != nil + let primaryMetricGroup = UsageMenuCardView.primaryMetricGroup(metrics: model.metrics) + let supplementalMetricGroups = UsageMenuCardView.supplementalMetricGroups(metrics: model.metrics) + let hasUsageBlock = primaryMetricGroup != nil || !model.usageNotes.isEmpty || model.placeholder != nil + let hasSupplementalUsageBlocks = !supplementalMetricGroups.isEmpty let hasCredits = model.creditsText != nil let hasExtraUsage = model.providerCost != nil let hasCost = model.tokenUsage != nil @@ -852,16 +860,20 @@ extension StatusItemController { let headerView = UsageMenuCardHeaderSectionView( model: model, - showDivider: hasUsageBlock, + showDivider: hasUsageBlock || hasSupplementalUsageBlocks, width: width) menu.addItem(self.makeMenuCardItem(headerView, id: "menuCardHeader", width: width)) if hasUsageBlock { - let usageView = UsageMenuCardUsageSectionView( - model: model, - showBottomDivider: false, + let usageView = UsageMenuCardMetricGroupSectionView( + provider: provider, + group: primaryMetricGroup ?? UsageMenuCardView.emptyPrimaryMetricGroup(), + usageNotes: model.usageNotes, + placeholder: model.placeholder, + topPadding: 10, bottomPadding: usageBottomPadding, - width: width) + width: width, + progressColor: model.progressColor) let usageSubmenu = self.makeUsageSubmenu( provider: provider, snapshot: self.store.snapshot(for: provider), @@ -873,14 +885,30 @@ extension StatusItemController { submenu: usageSubmenu)) } + for (index, supplementalMetricGroup) in supplementalMetricGroups.enumerated() { + if hasUsageBlock || index > 0 { + menu.addItem(.separator()) + } + let supplementalView = UsageMenuCardMetricGroupSectionView( + provider: provider, + group: supplementalMetricGroup, + usageNotes: [], + placeholder: nil, + topPadding: sectionSpacing, + bottomPadding: bottomPadding, + width: width, + progressColor: model.progressColor) + menu.addItem(self.makeMenuCardItem( + supplementalView, + id: "menuCardUsageGroup-\(supplementalMetricGroup.id)", + width: width)) + } + if hasCredits || hasExtraUsage || hasCost { menu.addItem(.separator()) } if hasCredits { - if hasExtraUsage || hasCost { - menu.addItem(.separator()) - } let creditsView = UsageMenuCardCreditsSectionView( model: model, showBottomDivider: false, @@ -897,10 +925,10 @@ extension StatusItemController { menu.addItem(self.makeBuyCreditsItem()) } } + if hasCredits, hasExtraUsage || hasCost { + menu.addItem(.separator()) + } if hasExtraUsage { - if hasCredits { - menu.addItem(.separator()) - } let extraUsageView = UsageMenuCardExtraUsageSectionView( model: model, topPadding: sectionSpacing, @@ -911,10 +939,10 @@ extension StatusItemController { id: "menuCardExtraUsage", width: width)) } + if hasExtraUsage, hasCost { + menu.addItem(.separator()) + } if hasCost { - if hasCredits || hasExtraUsage { - menu.addItem(.separator()) - } let costView = UsageMenuCardCostSectionView( model: model, topPadding: sectionSpacing, diff --git a/Sources/CodexBarCLI/CLICostCommand.swift b/Sources/CodexBarCLI/CLICostCommand.swift index 665f0977d..fb749d619 100644 --- a/Sources/CodexBarCLI/CLICostCommand.swift +++ b/Sources/CodexBarCLI/CLICostCommand.swift @@ -117,7 +117,10 @@ extension CodexBarCLI { costUSD: entry.costUSD, modelsUsed: entry.modelsUsed, modelBreakdowns: entry.modelBreakdowns?.map { breakdown in - CostModelBreakdownPayload(modelName: breakdown.modelName, costUSD: breakdown.costUSD) + CostModelBreakdownPayload( + modelName: breakdown.modelName, + costUSD: breakdown.costUSD, + totalTokens: breakdown.totalTokens) }) } ?? [] @@ -159,6 +162,7 @@ extension CodexBarCLI { var sawCacheCreation = false var sawTokens = false var sawCost = false + var hasUnknownCost = false for entry in entries { if let input = entry.inputTokens { @@ -184,9 +188,19 @@ extension CodexBarCLI { if let cost = entry.costUSD { totalCost += cost sawCost = true + } else if (entry.totalTokens ?? 0) > 0 { + hasUnknownCost = true } } + let aggregateCost: Double? = if hasUnknownCost { + nil + } else if sawCost { + totalCost + } else { + snapshot.last30DaysCostUSD + } + // Prefer totals derived from daily rows; fall back to snapshot aggregates when rows omit fields. return CostTotalsPayload( totalInputTokens: sawInput ? totalInput : nil, @@ -194,10 +208,22 @@ extension CodexBarCLI { cacheReadTokens: sawCacheRead ? totalCacheRead : nil, cacheCreationTokens: sawCacheCreation ? totalCacheCreation : nil, totalTokens: sawTokens ? totalTokens : snapshot.last30DaysTokens, - totalCostUSD: sawCost ? totalCost : snapshot.last30DaysCostUSD) + totalCostUSD: aggregateCost) } } +#if DEBUG +extension CodexBarCLI { + static func _makeCostPayloadForTesting( + provider: UsageProvider, + snapshot: CostUsageTokenSnapshot?, + error: Error? = nil) -> CostPayload + { + self.makeCostPayload(provider: provider, snapshot: snapshot, error: error) + } +} +#endif + struct CostOptions: CommanderParsable { @Flag(names: [.short("v"), .long("verbose")], help: "Enable verbose logging") var verbose: Bool = false @@ -272,10 +298,12 @@ struct CostDailyEntryPayload: Encodable { struct CostModelBreakdownPayload: Encodable { let modelName: String let costUSD: Double? + let totalTokens: Int? private enum CodingKeys: String, CodingKey { case modelName case costUSD = "cost" + case totalTokens } } diff --git a/Sources/CodexBarCLI/CLIRenderer.swift b/Sources/CodexBarCLI/CLIRenderer.swift index 357c815bc..c43898da4 100644 --- a/Sources/CodexBarCLI/CLIRenderer.swift +++ b/Sources/CodexBarCLI/CLIRenderer.swift @@ -33,6 +33,7 @@ enum CLIRenderer { now: now, lines: &lines) self.appendTertiaryLines(snapshot: snapshot, metadata: meta, context: context, now: now, lines: &lines) + self.appendUsageBucketGroupLines(snapshot: snapshot, context: context, now: now, lines: &lines) self.appendCreditsLine(provider: provider, credits: credits, useColor: context.useColor, lines: &lines) self.appendIdentityAndNotes( provider: provider, @@ -120,6 +121,24 @@ enum CLIRenderer { } } + private static func appendUsageBucketGroupLines( + snapshot: UsageSnapshot, + context: RenderContext, + now: Date, + lines: inout [String]) + { + for group in snapshot.usageBucketGroups { + guard !group.buckets.isEmpty else { continue } + lines.append(group.title) + for bucket in group.buckets { + lines.append(self.rateLine(title: bucket.title, window: bucket.window, useColor: context.useColor)) + if let reset = self.resetLine(for: bucket.window, style: context.resetStyle, now: now) { + lines.append(self.subtleLine(reset, useColor: context.useColor)) + } + } + } + } + private static func appendCreditsLine( provider: UsageProvider, credits: CreditsSnapshot?, diff --git a/Sources/CodexBarCore/CostUsageModels.swift b/Sources/CodexBarCore/CostUsageModels.swift index 60f5e7598..2b2e4e0f0 100644 --- a/Sources/CodexBarCore/CostUsageModels.swift +++ b/Sources/CodexBarCore/CostUsageModels.swift @@ -29,11 +29,13 @@ public struct CostUsageDailyReport: Sendable, Decodable { public struct ModelBreakdown: Sendable, Decodable, Equatable { public let modelName: String public let costUSD: Double? + public let totalTokens: Int? private enum CodingKeys: String, CodingKey { case modelName case costUSD case cost + case totalTokens } public init(from decoder: Decoder) throws { @@ -42,11 +44,13 @@ public struct CostUsageDailyReport: Sendable, Decodable { self.costUSD = try container.decodeIfPresent(Double.self, forKey: .costUSD) ?? container.decodeIfPresent(Double.self, forKey: .cost) + self.totalTokens = try container.decodeIfPresent(Int.self, forKey: .totalTokens) } - public init(modelName: String, costUSD: Double?) { + public init(modelName: String, costUSD: Double?, totalTokens: Int? = nil) { self.modelName = modelName self.costUSD = costUSD + self.totalTokens = totalTokens } } diff --git a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift index 174c08c54..e810a048f 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift @@ -6,11 +6,13 @@ import FoundationNetworking public struct CodexUsageResponse: Decodable, Sendable { public let planType: PlanType? public let rateLimit: RateLimitDetails? + public let additionalRateLimits: [AdditionalRateLimit]? public let credits: CreditDetails? enum CodingKeys: String, CodingKey { case planType = "plan_type" case rateLimit = "rate_limit" + case additionalRateLimits = "additional_rate_limits" case credits } @@ -94,6 +96,18 @@ public struct CodexUsageResponse: Decodable, Sendable { } } + public struct AdditionalRateLimit: Decodable, Sendable { + public let limitName: String? + public let meteredFeature: String? + public let rateLimit: RateLimitDetails? + + enum CodingKeys: String, CodingKey { + case limitName = "limit_name" + case meteredFeature = "metered_feature" + case rateLimit = "rate_limit" + } + } + public struct CreditDetails: Decodable, Sendable { public let hasCredits: Bool public let unlimited: Bool diff --git a/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift index 12b68cd22..0cf40c138 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift @@ -164,6 +164,7 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { private static func mapUsage(_ response: CodexUsageResponse, credentials: CodexOAuthCredentials) -> UsageSnapshot { let primary = Self.makeWindow(response.rateLimit?.primaryWindow) let secondary = Self.makeWindow(response.rateLimit?.secondaryWindow) + let usageBucketGroups = Self.makeUsageBucketGroups(response.additionalRateLimits) let identity = ProviderIdentitySnapshot( providerID: .codex, @@ -175,6 +176,7 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { primary: primary ?? RateWindow(usedPercent: 0, windowMinutes: nil, resetsAt: nil, resetDescription: nil), secondary: secondary, tertiary: nil, + usageBucketGroups: usageBucketGroups, updatedAt: Date(), identity: identity) } @@ -195,6 +197,36 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { resetDescription: resetDescription) } + private static func makeUsageBucketGroups( + _ rateLimits: [CodexUsageResponse.AdditionalRateLimit]?) -> [UsageBucketGroupSnapshot] + { + guard let sparkRateLimit = rateLimits?.first(where: self.isSparkRateLimit) else { return [] } + let session = self.makeWindow(sparkRateLimit.rateLimit?.primaryWindow) + let weekly = self.makeWindow(sparkRateLimit.rateLimit?.secondaryWindow) + var buckets: [UsageBucketSnapshot] = [] + if let session { + buckets.append(UsageBucketSnapshot(id: "codex.spark.session", title: "Session", window: session)) + } + if let weekly { + buckets.append(UsageBucketSnapshot(id: "codex.spark.weekly", title: "Weekly", window: weekly)) + } + guard !buckets.isEmpty else { return [] } + return [UsageBucketGroupSnapshot( + id: "codex.spark", + title: "GPT-5.3-Codex-Spark", + buckets: buckets)] + } + + private static func isSparkRateLimit(_ rateLimit: CodexUsageResponse.AdditionalRateLimit) -> Bool { + let limitName = rateLimit.limitName?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if limitName == "gpt-5.3-codex-spark" { + return true + } + + let meteredFeature = rateLimit.meteredFeature?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return meteredFeature == "codex_bengalfox" + } + private static func resolveAccountEmail(from credentials: CodexOAuthCredentials) -> String? { guard let idToken = credentials.idToken, let payload = UsageFetcher.parseJWT(idToken) diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index f2370e9ca..f5c273917 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -19,6 +19,30 @@ public struct RateWindow: Codable, Equatable, Sendable { } } +public struct UsageBucketSnapshot: Codable, Equatable, Sendable { + public let id: String + public let title: String + public let window: RateWindow + + public init(id: String, title: String, window: RateWindow) { + self.id = id + self.title = title + self.window = window + } +} + +public struct UsageBucketGroupSnapshot: Codable, Equatable, Sendable { + public let id: String + public let title: String + public let buckets: [UsageBucketSnapshot] + + public init(id: String, title: String, buckets: [UsageBucketSnapshot]) { + self.id = id + self.title = title + self.buckets = buckets + } +} + public struct ProviderIdentitySnapshot: Codable, Sendable { public let providerID: UsageProvider? public let accountEmail: String? @@ -51,6 +75,7 @@ public struct UsageSnapshot: Codable, Sendable { public let primary: RateWindow? public let secondary: RateWindow? public let tertiary: RateWindow? + public let usageBucketGroups: [UsageBucketGroupSnapshot] public let providerCost: ProviderCostSnapshot? public let zaiUsage: ZaiUsageSnapshot? public let minimaxUsage: MiniMaxUsageSnapshot? @@ -63,6 +88,7 @@ public struct UsageSnapshot: Codable, Sendable { case primary case secondary case tertiary + case usageBucketGroups case providerCost case openRouterUsage case updatedAt @@ -76,6 +102,7 @@ public struct UsageSnapshot: Codable, Sendable { primary: RateWindow?, secondary: RateWindow?, tertiary: RateWindow? = nil, + usageBucketGroups: [UsageBucketGroupSnapshot] = [], providerCost: ProviderCostSnapshot? = nil, zaiUsage: ZaiUsageSnapshot? = nil, minimaxUsage: MiniMaxUsageSnapshot? = nil, @@ -87,6 +114,7 @@ public struct UsageSnapshot: Codable, Sendable { self.primary = primary self.secondary = secondary self.tertiary = tertiary + self.usageBucketGroups = Self.normalizedUsageBucketGroups(usageBucketGroups) self.providerCost = providerCost self.zaiUsage = zaiUsage self.minimaxUsage = minimaxUsage @@ -101,6 +129,10 @@ public struct UsageSnapshot: Codable, Sendable { self.primary = try container.decodeIfPresent(RateWindow.self, forKey: .primary) self.secondary = try container.decodeIfPresent(RateWindow.self, forKey: .secondary) self.tertiary = try container.decodeIfPresent(RateWindow.self, forKey: .tertiary) + let decodedUsageBucketGroups = try container.decodeIfPresent( + [UsageBucketGroupSnapshot].self, + forKey: .usageBucketGroups) ?? [] + self.usageBucketGroups = Self.normalizedUsageBucketGroups(decodedUsageBucketGroups) self.providerCost = try container.decodeIfPresent(ProviderCostSnapshot.self, forKey: .providerCost) self.zaiUsage = nil // Not persisted, fetched fresh each time self.minimaxUsage = nil // Not persisted, fetched fresh each time @@ -131,6 +163,7 @@ public struct UsageSnapshot: Codable, Sendable { try container.encode(self.primary, forKey: .primary) try container.encode(self.secondary, forKey: .secondary) try container.encode(self.tertiary, forKey: .tertiary) + try container.encode(self.usageBucketGroups, forKey: .usageBucketGroups) try container.encodeIfPresent(self.providerCost, forKey: .providerCost) try container.encodeIfPresent(self.openRouterUsage, forKey: .openRouterUsage) try container.encode(self.updatedAt, forKey: .updatedAt) @@ -184,6 +217,7 @@ public struct UsageSnapshot: Codable, Sendable { primary: self.primary, secondary: self.secondary, tertiary: self.tertiary, + usageBucketGroups: self.usageBucketGroups, providerCost: self.providerCost, zaiUsage: self.zaiUsage, minimaxUsage: self.minimaxUsage, @@ -199,6 +233,16 @@ public struct UsageSnapshot: Codable, Sendable { if scopedIdentity.providerID == identity.providerID { return self } return self.withIdentity(scopedIdentity) } + + private static func normalizedUsageBucketGroups( + _ groups: [UsageBucketGroupSnapshot]) -> [UsageBucketGroupSnapshot] + { + groups.compactMap { group in + let buckets = group.buckets.filter { !$0.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + guard !buckets.isEmpty else { return nil } + return UsageBucketGroupSnapshot(id: group.id, title: group.title, buckets: buckets) + } + } } public struct AccountInfo: Equatable, Sendable { diff --git a/Sources/CodexBarCore/UsageFormatter.swift b/Sources/CodexBarCore/UsageFormatter.swift index 226d43569..b03b18500 100644 --- a/Sources/CodexBarCore/UsageFormatter.swift +++ b/Sources/CodexBarCore/UsageFormatter.swift @@ -206,6 +206,15 @@ public enum UsageFormatter { return cleaned.isEmpty ? raw : cleaned } + public static func modelCostDetail(_ model: String, costUSD: Double?) -> String? { + if let label = CostUsagePricing.codexDisplayLabel(model: model) { + return label + } + + guard let costUSD else { return nil } + return self.usdString(costUSD) + } + /// Cleans a provider plan string: strip ANSI/bracket noise, drop boilerplate words, collapse whitespace, and /// ensure a leading capital if the result starts lowercase. public static func cleanPlanName(_ text: String) -> String { diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift index ffc7b1b91..8e7b06092 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift @@ -4,7 +4,32 @@ enum CostUsagePricing { struct CodexPricing: Sendable { let inputCostPerToken: Double let outputCostPerToken: Double - let cacheReadInputCostPerToken: Double + let cacheReadInputCostPerToken: Double? + let thresholdTokens: Int? + let inputCostPerTokenAboveThreshold: Double? + let outputCostPerTokenAboveThreshold: Double? + let cacheReadInputCostPerTokenAboveThreshold: Double? + let displayLabel: String? + + init( + inputCostPerToken: Double, + outputCostPerToken: Double, + cacheReadInputCostPerToken: Double?, + thresholdTokens: Int? = nil, + inputCostPerTokenAboveThreshold: Double? = nil, + outputCostPerTokenAboveThreshold: Double? = nil, + cacheReadInputCostPerTokenAboveThreshold: Double? = nil, + displayLabel: String? = nil) + { + self.inputCostPerToken = inputCostPerToken + self.outputCostPerToken = outputCostPerToken + self.cacheReadInputCostPerToken = cacheReadInputCostPerToken + self.thresholdTokens = thresholdTokens + self.inputCostPerTokenAboveThreshold = inputCostPerTokenAboveThreshold + self.outputCostPerTokenAboveThreshold = outputCostPerTokenAboveThreshold + self.cacheReadInputCostPerTokenAboveThreshold = cacheReadInputCostPerTokenAboveThreshold + self.displayLabel = displayLabel + } } struct ClaudePricing: Sendable { @@ -24,31 +49,123 @@ enum CostUsagePricing { "gpt-5": CodexPricing( inputCostPerToken: 1.25e-6, outputCostPerToken: 1e-5, - cacheReadInputCostPerToken: 1.25e-7), + cacheReadInputCostPerToken: 1.25e-7, + displayLabel: nil), + "gpt-5-chat": CodexPricing( + inputCostPerToken: 1.25e-6, + outputCostPerToken: 1e-5, + cacheReadInputCostPerToken: 1.25e-7, + displayLabel: nil), + "gpt-5-chat-latest": CodexPricing( + inputCostPerToken: 1.25e-6, + outputCostPerToken: 1e-5, + cacheReadInputCostPerToken: 1.25e-7, + displayLabel: nil), "gpt-5-codex": CodexPricing( inputCostPerToken: 1.25e-6, outputCostPerToken: 1e-5, - cacheReadInputCostPerToken: 1.25e-7), + cacheReadInputCostPerToken: 1.25e-7, + displayLabel: nil), + "gpt-5-codex-mini": CodexPricing( + inputCostPerToken: 2.5e-7, + outputCostPerToken: 2e-6, + cacheReadInputCostPerToken: 2.5e-8, + displayLabel: nil), + "gpt-5-mini": CodexPricing( + inputCostPerToken: 2.5e-7, + outputCostPerToken: 2e-6, + cacheReadInputCostPerToken: 2.5e-8, + displayLabel: nil), + "gpt-5-nano": CodexPricing( + inputCostPerToken: 5e-8, + outputCostPerToken: 4e-7, + cacheReadInputCostPerToken: 5e-9, + displayLabel: nil), + "gpt-5-pro": CodexPricing( + inputCostPerToken: 1.5e-5, + outputCostPerToken: 1.2e-4, + cacheReadInputCostPerToken: nil, + displayLabel: nil), "gpt-5.1": CodexPricing( inputCostPerToken: 1.25e-6, outputCostPerToken: 1e-5, - cacheReadInputCostPerToken: 1.25e-7), + cacheReadInputCostPerToken: 1.25e-7, + displayLabel: nil), + "gpt-5.1-chat-latest": CodexPricing( + inputCostPerToken: 1.25e-6, + outputCostPerToken: 1e-5, + cacheReadInputCostPerToken: 1.25e-7, + displayLabel: nil), + "gpt-5.1-codex": CodexPricing( + inputCostPerToken: 1.25e-6, + outputCostPerToken: 1e-5, + cacheReadInputCostPerToken: 1.25e-7, + displayLabel: nil), + "gpt-5.1-codex-max": CodexPricing( + inputCostPerToken: 1.25e-6, + outputCostPerToken: 1e-5, + cacheReadInputCostPerToken: 1.25e-7, + displayLabel: nil), + "gpt-5.1-codex-mini": CodexPricing( + inputCostPerToken: 2.5e-7, + outputCostPerToken: 2e-6, + cacheReadInputCostPerToken: 2.5e-8, + displayLabel: nil), "gpt-5.2": CodexPricing( inputCostPerToken: 1.75e-6, outputCostPerToken: 1.4e-5, - cacheReadInputCostPerToken: 1.75e-7), + cacheReadInputCostPerToken: 1.75e-7, + displayLabel: nil), + "gpt-5.2-chat": CodexPricing( + inputCostPerToken: 1.75e-6, + outputCostPerToken: 1.4e-5, + cacheReadInputCostPerToken: 1.75e-7, + displayLabel: nil), + "gpt-5.2-chat-latest": CodexPricing( + inputCostPerToken: 1.75e-6, + outputCostPerToken: 1.4e-5, + cacheReadInputCostPerToken: 1.75e-7, + displayLabel: nil), "gpt-5.2-codex": CodexPricing( inputCostPerToken: 1.75e-6, outputCostPerToken: 1.4e-5, - cacheReadInputCostPerToken: 1.75e-7), + cacheReadInputCostPerToken: 1.75e-7, + displayLabel: nil), + "gpt-5.2-pro": CodexPricing( + inputCostPerToken: 2.1e-5, + outputCostPerToken: 1.68e-4, + cacheReadInputCostPerToken: nil, + displayLabel: nil), + "gpt-5.3-codex": CodexPricing( + inputCostPerToken: 1.75e-6, + outputCostPerToken: 1.4e-5, + cacheReadInputCostPerToken: 1.75e-7, + displayLabel: nil), "gpt-5.3": CodexPricing( inputCostPerToken: 1.75e-6, outputCostPerToken: 1.4e-5, - cacheReadInputCostPerToken: 1.75e-7), - "gpt-5.3-codex": CodexPricing( + cacheReadInputCostPerToken: 1.75e-7, + displayLabel: nil), + "gpt-5.3-chat-latest": CodexPricing( inputCostPerToken: 1.75e-6, outputCostPerToken: 1.4e-5, - cacheReadInputCostPerToken: 1.75e-7), + cacheReadInputCostPerToken: 1.75e-7, + displayLabel: nil), + "gpt-5.3-codex-spark": CodexPricing( + inputCostPerToken: 0, + outputCostPerToken: 0, + cacheReadInputCostPerToken: 0, + displayLabel: "Research Preview"), + "gpt-5.4": CodexPricing( + inputCostPerToken: 2.5e-6, + outputCostPerToken: 1.5e-5, + cacheReadInputCostPerToken: 2.5e-7, + displayLabel: nil), + "gpt-5.4-pro": CodexPricing( + inputCostPerToken: 3e-5, + outputCostPerToken: 1.8e-4, + cacheReadInputCostPerToken: nil, + displayLabel: nil), ] private static let claude: [String: ClaudePricing] = [ @@ -165,17 +282,51 @@ enum CostUsagePricing { ] static func normalizeCodexModel(_ raw: String) -> String { - var trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.hasPrefix("openai/") { - trimmed = String(trimmed.dropFirst("openai/".count)) + var trimmed = self.displayCodexModel(raw) + if let snapshotBase = self.codexSnapshotBaseModel(trimmed) { + trimmed = snapshotBase + } + if self.codex[trimmed] != nil { + return trimmed } - if let codexRange = trimmed.range(of: "-codex") { + if let codexRange = trimmed.range(of: "-codex"), !trimmed.contains("-codex-mini") { let base = String(trimmed[.. String { + var trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasPrefix("openai/") { + trimmed = String(trimmed.dropFirst("openai/".count)) + } + return trimmed + } + + private static func codexSnapshotBaseModel(_ raw: String) -> String? { + let patterns = [ + #"-\d{4}-\d{2}-\d{2}$"#, + #"-\d{8}$"#, + ] + + for pattern in patterns { + if let range = raw.range(of: pattern, options: .regularExpression) { + let base = String(raw[.. String? { + let key = self.normalizeCodexModel(model) + return self.codex[key]?.displayLabel + } + static func normalizeClaudeModel(_ raw: String) -> String { var trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.hasPrefix("anthropic.") { @@ -210,9 +361,28 @@ enum CostUsagePricing { guard let pricing = self.codex[key] else { return nil } let cached = min(max(0, cachedInputTokens), max(0, inputTokens)) let nonCached = max(0, inputTokens - cached) - return Double(nonCached) * pricing.inputCostPerToken - + Double(cached) * pricing.cacheReadInputCostPerToken - + Double(max(0, outputTokens)) * pricing.outputCostPerToken + let effectiveInputTokens = max(0, inputTokens) + let useAboveThresholdPricing = if let threshold = pricing.thresholdTokens { + effectiveInputTokens > threshold + } else { + false + } + let inputRate = useAboveThresholdPricing + ? (pricing.inputCostPerTokenAboveThreshold ?? pricing.inputCostPerToken) + : pricing.inputCostPerToken + let outputRate = useAboveThresholdPricing + ? (pricing.outputCostPerTokenAboveThreshold ?? pricing.outputCostPerToken) + : pricing.outputCostPerToken + let cacheRate = useAboveThresholdPricing + ? (pricing.cacheReadInputCostPerTokenAboveThreshold ?? pricing.cacheReadInputCostPerToken) + : pricing.cacheReadInputCostPerToken + + if cached > 0, cacheRate == nil { + return nil + } + return Double(nonCached) * inputRate + + Double(cached) * (cacheRate ?? 0) + + Double(max(0, outputTokens)) * outputRate } static func claudeCostUSD( diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index 5e32060b0..97c86b8fd 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -513,6 +513,7 @@ extension CostUsageScanner { let cacheCreate = packed[safe: 2] ?? 0 let output = packed[safe: 3] ?? 0 let cachedCost = packed[safe: 4] ?? 0 + let modelTotal = input + cacheRead + cacheCreate + output // Cache tokens are tracked separately; totalTokens includes input + cache. dayInput += input @@ -528,15 +529,25 @@ extension CostUsageScanner { cacheReadInputTokens: cacheRead, cacheCreationInputTokens: cacheCreate, outputTokens: output) - breakdown.append(CostUsageDailyReport.ModelBreakdown(modelName: model, costUSD: cost)) + breakdown.append(CostUsageDailyReport.ModelBreakdown( + modelName: model, + costUSD: cost, + totalTokens: modelTotal)) if let cost { dayCost += cost dayCostSeen = true } } - breakdown.sort { lhs, rhs in (rhs.costUSD ?? -1) < (lhs.costUSD ?? -1) } - let top = Array(breakdown.prefix(3)) + breakdown.sort { lhs, rhs in + let lCost = lhs.costUSD ?? -1 + let rCost = rhs.costUSD ?? -1 + if lCost != rCost { return lCost > rCost } + let lTokens = lhs.totalTokens ?? -1 + let rTokens = rhs.totalTokens ?? -1 + if lTokens != rTokens { return lTokens > rTokens } + return lhs.modelName < rhs.modelName + } let dayTotal = dayInput + dayCacheRead + dayCacheCreate + dayOutput let entryCost = dayCostSeen ? dayCost : nil @@ -549,7 +560,7 @@ extension CostUsageScanner { totalTokens: dayTotal, costUSD: entryCost, modelsUsed: modelNames, - modelBreakdowns: top)) + modelBreakdowns: breakdown)) totalInput += dayInput totalOutput += dayOutput diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Codex.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Codex.swift index 54ce34761..c4ec06259 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Codex.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Codex.swift @@ -167,7 +167,7 @@ extension CostUsageScanner { state.currentModel = explicitModel } - let model = usage.explicitModel ?? state.currentModel ?? "gpt-5" + let model = usage.explicitModel ?? state.currentModel ?? "unknown" var deltaInput = 0 var deltaCached = 0 var deltaOutput = 0 diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 8e632b18d..f1a2698bb 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -401,6 +401,7 @@ enum CostUsageScanner { var totalTokens = 0 var totalCost: Double = 0 var costSeen = false + var hasUnknownCost = false let dayKeys = cache.days.keys.sorted().filter { CostUsageDayRange.isInRange(dayKey: $0, since: range.sinceKey, until: range.untilKey) @@ -416,12 +417,14 @@ enum CostUsageScanner { var breakdown: [CostUsageDailyReport.ModelBreakdown] = [] var dayCost: Double = 0 var dayCostSeen = false + var dayHasUnknownCost = false for model in modelNames { let packed = models[model] ?? [0, 0, 0] let input = packed[safe: 0] ?? 0 let cached = packed[safe: 1] ?? 0 let output = packed[safe: 2] ?? 0 + let modelTotal = input + output dayInput += input dayOutput += output @@ -431,18 +434,30 @@ enum CostUsageScanner { inputTokens: input, cachedInputTokens: cached, outputTokens: output) - breakdown.append(CostUsageDailyReport.ModelBreakdown(modelName: model, costUSD: cost)) + breakdown.append(CostUsageDailyReport.ModelBreakdown( + modelName: model, + costUSD: cost, + totalTokens: modelTotal)) if let cost { dayCost += cost dayCostSeen = true + } else if modelTotal > 0 || cached > 0 { + dayHasUnknownCost = true } } - breakdown.sort { lhs, rhs in (rhs.costUSD ?? -1) < (lhs.costUSD ?? -1) } - let top = Array(breakdown.prefix(3)) + breakdown.sort { lhs, rhs in + let lCost = lhs.costUSD ?? -1 + let rCost = rhs.costUSD ?? -1 + if lCost != rCost { return lCost > rCost } + let lTokens = lhs.totalTokens ?? -1 + let rTokens = rhs.totalTokens ?? -1 + if lTokens != rTokens { return lTokens > rTokens } + return lhs.modelName < rhs.modelName + } let dayTotal = dayInput + dayOutput - let entryCost = dayCostSeen ? dayCost : nil + let entryCost = dayCostSeen && !dayHasUnknownCost ? dayCost : nil entries.append(CostUsageDailyReport.Entry( date: day, inputTokens: dayInput, @@ -450,11 +465,14 @@ enum CostUsageScanner { totalTokens: dayTotal, costUSD: entryCost, modelsUsed: modelNames, - modelBreakdowns: top)) + modelBreakdowns: breakdown)) totalInput += dayInput totalOutput += dayOutput totalTokens += dayTotal + if dayHasUnknownCost { + hasUnknownCost = true + } if let entryCost { totalCost += entryCost costSeen = true @@ -467,7 +485,7 @@ enum CostUsageScanner { totalInputTokens: totalInput, totalOutputTokens: totalOutput, totalTokens: totalTokens, - totalCostUSD: costSeen ? totalCost : nil) + totalCostUSD: costSeen && !hasUnknownCost ? totalCost : nil) return CostUsageDailyReport(data: entries, summary: summary) } diff --git a/Tests/CodexBarTests/CLICostTests.swift b/Tests/CodexBarTests/CLICostTests.swift index d376d383e..17cedba9c 100644 --- a/Tests/CodexBarTests/CLICostTests.swift +++ b/Tests/CodexBarTests/CLICostTests.swift @@ -57,7 +57,10 @@ struct CLICostTests { costUSD: 0.01, modelsUsed: ["claude-sonnet-4-20250514"], modelBreakdowns: [ - CostModelBreakdownPayload(modelName: "claude-sonnet-4-20250514", costUSD: 0.01), + CostModelBreakdownPayload( + modelName: "claude-sonnet-4-20250514", + costUSD: 0.01, + totalTokens: 15), ]), ], totals: CostTotalsPayload( @@ -83,7 +86,92 @@ struct CLICostTests { #expect(json.contains("\"totals\"")) #expect(json.contains("\"cacheReadTokens\":2")) #expect(json.contains("\"cacheCreationTokens\":3")) + #expect(json.contains("\"modelBreakdowns\"")) + #expect(json.contains("\"modelName\":\"claude-sonnet-4-20250514\"")) + #expect(json.contains("\"cost\":0.01")) + #expect(json.contains("\"totalTokens\":15")) #expect(json.contains("\"totalCost\"")) #expect(json.contains("1700000000")) } + + @Test + func encodesExactCodexModelIDsAndZeroCostBreakdowns() throws { + let payload = CostPayload( + provider: "codex", + source: "local", + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + sessionTokens: 155, + sessionCostUSD: 0, + last30DaysTokens: 155, + last30DaysCostUSD: 0, + daily: [ + CostDailyEntryPayload( + date: "2025-12-21", + inputTokens: 120, + outputTokens: 15, + cacheReadTokens: 20, + cacheCreationTokens: nil, + totalTokens: 155, + costUSD: 0, + modelsUsed: ["gpt-5.3-codex-spark", "gpt-5.2-codex"], + modelBreakdowns: [ + CostModelBreakdownPayload(modelName: "gpt-5.3-codex-spark", costUSD: 0, totalTokens: 20), + CostModelBreakdownPayload(modelName: "gpt-5.2-codex", costUSD: 1.23, totalTokens: 135), + ]), + ], + totals: CostTotalsPayload( + totalInputTokens: 120, + totalOutputTokens: 15, + cacheReadTokens: 20, + cacheCreationTokens: nil, + totalTokens: 155, + totalCostUSD: 0), + error: nil) + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .secondsSince1970 + let data = try encoder.encode(payload) + guard let json = String(data: data, encoding: .utf8) else { + Issue.record("Failed to decode cost payload JSON") + return + } + + #expect(json.contains("\"gpt-5.3-codex-spark\"")) + #expect(json.contains("\"gpt-5.2-codex\"")) + #expect(!json.contains("\"gpt-5.2\"")) + #expect(json.contains("\"cost\":0")) + } + + @Test + func costPayloadPreservesNilAggregateCostForMixedKnownAndUnknownDays() { + let snapshot = CostUsageTokenSnapshot( + sessionTokens: 200, + sessionCostUSD: nil, + last30DaysTokens: 300, + last30DaysCostUSD: nil, + daily: [ + CostUsageDailyReport.Entry( + date: "2025-12-20", + inputTokens: 10, + outputTokens: 5, + totalTokens: 15, + costUSD: 0.01, + modelsUsed: ["gpt-5.2-codex"], + modelBreakdowns: nil), + CostUsageDailyReport.Entry( + date: "2025-12-21", + inputTokens: 20, + outputTokens: 10, + totalTokens: 30, + costUSD: nil, + modelsUsed: ["unknown"], + modelBreakdowns: nil), + ], + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + + let payload = CodexBarCLI._makeCostPayloadForTesting(provider: .codex, snapshot: snapshot) + + #expect(payload.totals?.totalTokens == 45) + #expect(payload.totals?.totalCostUSD == nil) + } } diff --git a/Tests/CodexBarTests/CLISnapshotTests.swift b/Tests/CodexBarTests/CLISnapshotTests.swift index dd755b29f..4017a5fad 100644 --- a/Tests/CodexBarTests/CLISnapshotTests.swift +++ b/Tests/CodexBarTests/CLISnapshotTests.swift @@ -43,6 +43,66 @@ struct CLISnapshotTests { #expect(output.contains("Plan: Pro")) } + @Test + func rendersSparkSectionForCodexWhenLiveSparkUsageExists() { + let now = Date(timeIntervalSince1970: 0) + let snap = UsageSnapshot( + primary: .init( + usedPercent: 12, + windowMinutes: 300, + resetsAt: nil, + resetDescription: "today at 3:00 PM"), + secondary: .init( + usedPercent: 25, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: "Fri at 9:00 AM"), + usageBucketGroups: [ + UsageBucketGroupSnapshot( + id: "codex.spark", + title: "GPT-5.3-Codex-Spark", + buckets: [ + UsageBucketSnapshot( + id: "codex.spark.session", + title: "Session", + window: .init( + usedPercent: 3, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(900), + resetDescription: nil)), + UsageBucketSnapshot( + id: "codex.spark.weekly", + title: "Weekly", + window: .init( + usedPercent: 17, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(1200), + resetDescription: nil)), + ]), + ], + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "user@example.com", + accountOrganization: nil, + loginMethod: nil)) + + let output = CLIRenderer.renderText( + provider: .codex, + snapshot: snap, + credits: nil, + context: RenderContext( + header: "Codex 1.2.3 (oauth)", + status: nil, + useColor: false, + resetStyle: .countdown)) + + #expect(output.contains("GPT-5.3-Codex-Spark")) + #expect(output.contains("Session: 97% left")) + #expect(output.contains("Weekly: 83% left")) + #expect(output.contains("Plan:") == false) + } + @Test func rendersTextSnapshotForClaudeWithoutWeekly() { let snap = UsageSnapshot( @@ -245,6 +305,47 @@ struct CLISnapshotTests { #expect(output.contains("Pace:")) } + @Test + func usageSnapshotRoundTripsUsageBucketGroups() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = UsageSnapshot( + primary: .init(usedPercent: 50, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + tertiary: nil, + usageBucketGroups: [ + UsageBucketGroupSnapshot( + id: "codex.spark", + title: "GPT-5.3-Codex-Spark", + buckets: [ + UsageBucketSnapshot( + id: "codex.spark.session", + title: "Session", + window: .init( + usedPercent: 3, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(900), + resetDescription: nil)), + ]), + UsageBucketGroupSnapshot( + id: "empty.group", + title: "Empty", + buckets: []), + ], + updatedAt: now) + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .secondsSince1970 + let data = try encoder.encode(snapshot) + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .secondsSince1970 + let decoded = try decoder.decode(UsageSnapshot.self, from: data) + + #expect(decoded.usageBucketGroups.count == 1) + #expect(decoded.usageBucketGroups.first?.id == "codex.spark") + #expect(decoded.usageBucketGroups.first?.buckets.map(\.id) == ["codex.spark.session"]) + } + @Test func rendersJSONPayload() throws { let snap = UsageSnapshot( @@ -282,6 +383,7 @@ struct CLISnapshotTests { #expect(json.contains("status.example.com")) #expect(json.contains("\"primary\"")) #expect(json.contains("\"windowMinutes\":300")) + #expect(json.contains("\"usageBucketGroups\":[]")) #expect(json.contains("1700000000")) } @@ -302,6 +404,7 @@ struct CLISnapshotTests { } #expect(json.contains("\"secondary\":null")) + #expect(json.contains("\"usageBucketGroups\":[]")) } @Test diff --git a/Tests/CodexBarTests/CodexOAuthTests.swift b/Tests/CodexBarTests/CodexOAuthTests.swift index 10a628aa0..079e51d1b 100644 --- a/Tests/CodexBarTests/CodexOAuthTests.swift +++ b/Tests/CodexBarTests/CodexOAuthTests.swift @@ -99,6 +99,100 @@ struct CodexOAuthTests { #expect(snapshot.secondary?.resetsAt != nil) } + @Test + func mapsCodexSparkWindowsFromOAuthAdditionalRateLimits() throws { + let json = """ + { + "rate_limit": { + "primary_window": { + "used_percent": 22, + "reset_at": 1766948068, + "limit_window_seconds": 18000 + }, + "secondary_window": { + "used_percent": 43, + "reset_at": 1767407914, + "limit_window_seconds": 604800 + } + }, + "additional_rate_limits": [ + { + "limit_name": "GPT-5.3-Codex-Spark", + "metered_feature": "codex_bengalfox", + "rate_limit": { + "primary_window": { + "used_percent": 3, + "reset_at": 1766948068, + "limit_window_seconds": 18000 + }, + "secondary_window": { + "used_percent": 17, + "reset_at": 1767407914, + "limit_window_seconds": 604800 + } + } + } + ] + } + """ + let creds = CodexOAuthCredentials( + accessToken: "access", + refreshToken: "refresh", + idToken: nil, + accountId: nil, + lastRefresh: Date()) + let snapshot = try CodexOAuthFetchStrategy._mapUsageForTesting(Data(json.utf8), credentials: creds) + #expect(snapshot.primary?.usedPercent == 22) + #expect(snapshot.secondary?.usedPercent == 43) + #expect(snapshot.usageBucketGroups.count == 1) + let sparkGroup = try #require(snapshot.usageBucketGroups.first) + #expect(sparkGroup.id == "codex.spark") + #expect(sparkGroup.title == "GPT-5.3-Codex-Spark") + #expect(sparkGroup.buckets.map(\.id) == ["codex.spark.session", "codex.spark.weekly"]) + #expect(sparkGroup.buckets.map(\.title) == ["Session", "Weekly"]) + #expect(sparkGroup.buckets.first?.window.usedPercent == 3) + #expect(sparkGroup.buckets.first?.window.windowMinutes == 300) + #expect(sparkGroup.buckets.last?.window.usedPercent == 17) + #expect(sparkGroup.buckets.last?.window.windowMinutes == 10080) + } + + @Test + func ignoresUnrelatedOAuthAdditionalRateLimitsWhenSparkAbsent() throws { + let json = """ + { + "rate_limit": { + "primary_window": { + "used_percent": 22, + "reset_at": 1766948068, + "limit_window_seconds": 18000 + } + }, + "additional_rate_limits": [ + { + "limit_name": "Something else", + "metered_feature": "codex_something_else", + "rate_limit": { + "secondary_window": { + "used_percent": 88, + "reset_at": 1767407914, + "limit_window_seconds": 604800 + } + } + } + ] + } + """ + let creds = CodexOAuthCredentials( + accessToken: "access", + refreshToken: "refresh", + idToken: nil, + accountId: nil, + lastRefresh: Date()) + let snapshot = try CodexOAuthFetchStrategy._mapUsageForTesting(Data(json.utf8), credentials: creds) + #expect(snapshot.primary?.usedPercent == 22) + #expect(snapshot.usageBucketGroups.isEmpty) + } + @Test func resolvesChatGPTUsageURLFromConfig() { let config = "chatgpt_base_url = \"https://chatgpt.com/backend-api/\"\n" diff --git a/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift b/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift new file mode 100644 index 000000000..b1358e860 --- /dev/null +++ b/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift @@ -0,0 +1,143 @@ +import Testing +@testable import CodexBar +@testable import CodexBarCore + +@Suite +struct CostHistoryChartMenuViewTests { + @Test + @MainActor + func detailContentUsesOnlySelectedDayModels() { + let daily = [ + CostUsageDailyReport.Entry( + date: "2025-12-01", + inputTokens: 100, + outputTokens: 20, + totalTokens: 120, + costUSD: 0.12, + modelsUsed: ["gpt-5.2-codex", "gpt-5.3-codex", "unknown"], + modelBreakdowns: [ + .init(modelName: "gpt-5.2-codex", costUSD: 0.06, totalTokens: 60), + .init(modelName: "gpt-5.3-codex", costUSD: 0.04, totalTokens: 40), + .init(modelName: "unknown", costUSD: nil, totalTokens: 20), + ]), + CostUsageDailyReport.Entry( + date: "2025-12-02", + inputTokens: 60, + outputTokens: 12, + totalTokens: 72, + costUSD: 0.05, + modelsUsed: ["gpt-5.4"], + modelBreakdowns: [ + .init(modelName: "gpt-5.4", costUSD: 0.05, totalTokens: 72), + ]), + ] + + let model = CostHistoryChartMenuView.makeModel(provider: .codex, daily: daily) + let detail = CostHistoryChartMenuView.detailContent(selectedDateKey: "2025-12-02", model: model) + + #expect(detail.models.count == 1) + #expect(detail.models[0].text.contains("GPT-5.4")) + #expect(detail.models[0].text.contains("72 tokens")) + #expect(model.maxDetailLineCount == 3) + } + + @Test + @MainActor + func makeModelDoesNotReserveDetailRowsWithoutBreakdowns() { + let daily = [ + CostUsageDailyReport.Entry( + date: "2025-12-01", + inputTokens: 40, + outputTokens: 10, + totalTokens: 50, + costUSD: 0.02, + modelsUsed: ["gpt-5.2-codex"], + modelBreakdowns: nil), + ] + + let model = CostHistoryChartMenuView.makeModel(provider: .codex, daily: daily) + + #expect(model.maxDetailLineCount == 0) + } + + @Test + @MainActor + func makeModelKeepsMixedKnownAndUnknownDaysVisible() throws { + let daily = [ + CostUsageDailyReport.Entry( + date: "2025-12-01", + inputTokens: 100, + outputTokens: 20, + totalTokens: 120, + costUSD: nil, + modelsUsed: ["gpt-5.2-codex", "unknown"], + modelBreakdowns: [ + .init(modelName: "gpt-5.2-codex", costUSD: 0.08, totalTokens: 80), + .init(modelName: "unknown", costUSD: nil, totalTokens: 40), + ]), + ] + + let model = CostHistoryChartMenuView.makeModel(provider: .codex, daily: daily) + let point = try #require(model.pointsByDateKey["2025-12-01"]) + let detail = CostHistoryChartMenuView.detailContent(selectedDateKey: "2025-12-01", model: model) + + #expect(point.displayCostUSD == 0.08) + #expect(point.actualCostUSD == nil) + #expect(detail.primary.contains("$0.08")) + #expect(detail.primary.contains("partial")) + #expect(detail.primary.contains("120 tokens")) + } + + @Test + @MainActor + func makeModelKeepsUnknownOnlyDaysVisible() throws { + let daily = [ + CostUsageDailyReport.Entry( + date: "2025-12-01", + inputTokens: 100, + outputTokens: 20, + totalTokens: 120, + costUSD: nil, + modelsUsed: ["unknown"], + modelBreakdowns: [ + .init(modelName: "unknown", costUSD: nil, totalTokens: 120), + ]), + ] + + let model = CostHistoryChartMenuView.makeModel(provider: .codex, daily: daily) + let point = try #require(model.pointsByDateKey["2025-12-01"]) + let detail = CostHistoryChartMenuView.detailContent(selectedDateKey: "2025-12-01", model: model) + + #expect(point.displayCostUSD == 0) + #expect(detail.primary.contains("No priced cost data")) + #expect(detail.primary.contains("120 tokens")) + } + + @Test + @MainActor + func makeModelCapsDetailRowsForBusyDays() { + let daily = [ + CostUsageDailyReport.Entry( + date: "2025-12-01", + inputTokens: 300, + outputTokens: 60, + totalTokens: 360, + costUSD: 0.18, + modelsUsed: ["gpt-5", "gpt-5-mini", "gpt-5-pro", "gpt-5.2", "unknown"], + modelBreakdowns: [ + .init(modelName: "gpt-5", costUSD: 0.04, totalTokens: 80), + .init(modelName: "gpt-5-mini", costUSD: 0.02, totalTokens: 70), + .init(modelName: "gpt-5-pro", costUSD: 0.05, totalTokens: 60), + .init(modelName: "gpt-5.2", costUSD: 0.04, totalTokens: 80), + .init(modelName: "unknown", costUSD: nil, totalTokens: 70), + ]), + ] + + let model = CostHistoryChartMenuView.makeModel(provider: .codex, daily: daily) + let detail = CostHistoryChartMenuView.detailContent(selectedDateKey: "2025-12-01", model: model) + + #expect(model.maxDetailLineCount == 4) + #expect(detail.models.count == 4) + #expect(detail.models.last?.text == "2 more models") + } +} diff --git a/Tests/CodexBarTests/CostUsageDecodingTests.swift b/Tests/CodexBarTests/CostUsageDecodingTests.swift index 178b9e070..f6279ce3f 100644 --- a/Tests/CodexBarTests/CostUsageDecodingTests.swift +++ b/Tests/CodexBarTests/CostUsageDecodingTests.swift @@ -119,7 +119,7 @@ struct CostUsageDecodingTests { "totalTokens": 30, "costUSD": 0.12, "models": { - "gpt-5.2": { + "gpt-5.2-codex": { "inputTokens": 10, "outputTokens": 20, "totalTokens": 30, @@ -138,7 +138,7 @@ struct CostUsageDecodingTests { let report = try JSONDecoder().decode(CostUsageDailyReport.self, from: Data(json.utf8)) #expect(report.data.count == 1) #expect(report.data[0].costUSD == 0.12) - #expect(report.data[0].modelsUsed == ["gpt-5.2"]) + #expect(report.data[0].modelsUsed == ["gpt-5.2-codex"]) } @Test @@ -164,6 +164,30 @@ struct CostUsageDecodingTests { #expect(report.data[0].modelsUsed == ["a-model", "m-model", "z-model"]) } + @Test + func decodesModelBreakdownTokenTotals() throws { + let json = """ + { + "daily": [ + { + "date": "Dec 20, 2025", + "totalTokens": 30, + "costUSD": 0.12, + "modelBreakdowns": [ + { "modelName": "gpt-5.2", "cost": 0.10, "totalTokens": 24 }, + { "modelName": "gpt-5.2-mini", "cost": 0.02, "totalTokens": 6 } + ] + } + ] + } + """ + + let report = try JSONDecoder().decode(CostUsageDailyReport.self, from: Data(json.utf8)) + let breakdown = try #require(report.data[0].modelBreakdowns) + #expect(breakdown.map(\.modelName) == ["gpt-5.2", "gpt-5.2-mini"]) + #expect(breakdown.map(\.totalTokens) == [24, 6]) + } + @Test func decodesDailyReportLegacyFormatWithEmptyModelMapAsNil() throws { let json = """ @@ -192,7 +216,7 @@ struct CostUsageDecodingTests { "date": "Dec 20, 2025", "totalTokens": 30, "costUSD": 0.12, - "modelsUsed": ["gpt-5.2"], + "modelsUsed": ["gpt-5.2-codex"], "models": { "ignored-model": { "totalTokens": 30 } } @@ -202,7 +226,7 @@ struct CostUsageDecodingTests { """ let report = try JSONDecoder().decode(CostUsageDailyReport.self, from: Data(json.utf8)) - #expect(report.data[0].modelsUsed == ["gpt-5.2"]) + #expect(report.data[0].modelsUsed == ["gpt-5.2-codex"]) } @Test @@ -214,14 +238,14 @@ struct CostUsageDecodingTests { "date": "Dec 20, 2025", "totalTokens": 30, "costUSD": 0.12, - "models": ["gpt-5.2", "gpt-5.2-mini"] + "models": ["gpt-5.2-codex", "gpt-5.2-mini"] } ] } """ let report = try JSONDecoder().decode(CostUsageDailyReport.self, from: Data(json.utf8)) - #expect(report.data[0].modelsUsed == ["gpt-5.2", "gpt-5.2-mini"]) + #expect(report.data[0].modelsUsed == ["gpt-5.2-codex", "gpt-5.2-mini"]) } @Test diff --git a/Tests/CodexBarTests/CostUsageJsonlScannerTests.swift b/Tests/CodexBarTests/CostUsageJsonlScannerTests.swift new file mode 100644 index 000000000..eee297d29 --- /dev/null +++ b/Tests/CodexBarTests/CostUsageJsonlScannerTests.swift @@ -0,0 +1,69 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite +struct CostUsageJsonlScannerTests { + @Test + func jsonlScannerHandlesLinesAcrossReadChunks() throws { + let root = try self.makeTemporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + + let fileURL = root.appendingPathComponent("large-lines.jsonl", isDirectory: false) + let largeLine = String(repeating: "x", count: 300_000) + let contents = "\(largeLine)\nsmall\n" + try contents.write(to: fileURL, atomically: true, encoding: .utf8) + + var scanned: [(count: Int, truncated: Bool)] = [] + let endOffset = try CostUsageJsonl.scan( + fileURL: fileURL, + maxLineBytes: 400_000, + prefixBytes: 400_000) + { line in + scanned.append((line.bytes.count, line.wasTruncated)) + } + + #expect(endOffset == Int64(Data(contents.utf8).count)) + #expect(scanned.count == 2) + #expect(scanned[0].count == 300_000) + #expect(scanned[0].truncated == false) + #expect(scanned[1].count == 5) + #expect(scanned[1].truncated == false) + } + + @Test + func jsonlScannerMarksPrefixLimitedLinesAsTruncated() throws { + let root = try self.makeTemporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + + let fileURL = root.appendingPathComponent("truncated-lines.jsonl", isDirectory: false) + let shortLine = "ok" + let longLine = String(repeating: "a", count: 2000) + let contents = "\(shortLine)\n\(longLine)\n" + try contents.write(to: fileURL, atomically: true, encoding: .utf8) + + var scanned: [CostUsageJsonl.Line] = [] + _ = try CostUsageJsonl.scan( + fileURL: fileURL, + maxLineBytes: 10000, + prefixBytes: 64) + { line in + scanned.append(line) + } + + #expect(scanned.count == 2) + #expect(String(data: scanned[0].bytes, encoding: .utf8) == "ok") + #expect(scanned[0].wasTruncated == false) + #expect(scanned[1].bytes.count == 64) + #expect(String(data: scanned[1].bytes, encoding: .utf8) == String(repeating: "a", count: 64)) + #expect(scanned[1].wasTruncated == true) + } + + private func makeTemporaryRoot() throws -> URL { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "codexbar-cost-usage-jsonl-\(UUID().uuidString)", + isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root + } +} diff --git a/Tests/CodexBarTests/CostUsageModelBreakdownTests.swift b/Tests/CodexBarTests/CostUsageModelBreakdownTests.swift new file mode 100644 index 000000000..9252ef1ce --- /dev/null +++ b/Tests/CodexBarTests/CostUsageModelBreakdownTests.swift @@ -0,0 +1,70 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite +struct CostUsageModelBreakdownTests { + @Test + func codexScannerKeepsAllModelBreakdownsWithTokenTotals() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2025, month: 12, day: 20) + let models: [(raw: String, input: Int, cached: Int, output: Int)] = [ + ("openai/gpt-5.2-codex", 80, 20, 10), + ("openai/gpt-5.2-mini", 50, 0, 5), + ("openai/o4-mini", 30, 0, 3), + ("openai/o3", 20, 0, 2), + ("openai/gpt-4.1", 10, 0, 1), + ] + + for (index, model) in models.enumerated() { + let turnTimestamp = env.isoString(for: day.addingTimeInterval(TimeInterval(index * 2))) + let tokenTimestamp = env.isoString(for: day.addingTimeInterval(TimeInterval((index * 2) + 1))) + let events: [[String: Any]] = [[ + "type": "turn_context", + "timestamp": turnTimestamp, + "payload": [ + "model": model.raw, + ], + ], [ + "type": "event_msg", + "timestamp": tokenTimestamp, + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": model.input, + "cached_input_tokens": model.cached, + "output_tokens": model.output, + ], + "model": model.raw, + ], + ], + ]] + + _ = try env.writeCodexSessionFile( + day: day, + filename: "breakdowns-\(index).jsonl", + contents: env.jsonl(events)) + } + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + + let breakdown = try #require(report.data.first?.modelBreakdowns) + #expect(breakdown.count == models.count) + #expect(breakdown.allSatisfy { $0.totalTokens != nil }) + #expect(breakdown.map(\.totalTokens).compactMap(\.self).sorted() == [11, 22, 33, 55, 90]) + } +} diff --git a/Tests/CodexBarTests/CostUsagePricingTests.swift b/Tests/CodexBarTests/CostUsagePricingTests.swift index f70e8d555..813c45e31 100644 --- a/Tests/CodexBarTests/CostUsagePricingTests.swift +++ b/Tests/CodexBarTests/CostUsagePricingTests.swift @@ -4,11 +4,24 @@ import Testing @Suite struct CostUsagePricingTests { @Test - func normalizesCodexModelVariants() { - #expect(CostUsagePricing.normalizeCodexModel("openai/gpt-5-codex") == "gpt-5") - #expect(CostUsagePricing.normalizeCodexModel("gpt-5.2-codex") == "gpt-5.2") - #expect(CostUsagePricing.normalizeCodexModel("gpt-5.1-codex-max") == "gpt-5.1") + func normalizesCodexModelVariantsExactly() { + #expect(CostUsagePricing.normalizeCodexModel("openai/gpt-5-codex") == "gpt-5-codex") + #expect(CostUsagePricing.normalizeCodexModel("gpt-5-codex-mini") == "gpt-5-codex-mini") + #expect(CostUsagePricing.normalizeCodexModel("gpt-5-2025-10-06") == "gpt-5") + #expect(CostUsagePricing.normalizeCodexModel("gpt-5-chat") == "gpt-5-chat") + #expect(CostUsagePricing.normalizeCodexModel("gpt-5-mini-2025-10-06") == "gpt-5-mini") + #expect(CostUsagePricing.normalizeCodexModel("gpt-5-pro-2025-10-06") == "gpt-5-pro") + #expect(CostUsagePricing.normalizeCodexModel("gpt-5.2-codex") == "gpt-5.2-codex") + #expect(CostUsagePricing.normalizeCodexModel("gpt-5.2-chat") == "gpt-5.2-chat") + #expect(CostUsagePricing.normalizeCodexModel("gpt-5.2-pro") == "gpt-5.2-pro") + #expect(CostUsagePricing.normalizeCodexModel("gpt-5.1-codex-max") == "gpt-5.1-codex-max") + #expect(CostUsagePricing.normalizeCodexModel("gpt-5.1-codex-mini") == "gpt-5.1-codex-mini") + #expect(CostUsagePricing.normalizeCodexModel("gpt-5.4-pro-2026-03-05") == "gpt-5.4-pro") + #expect(CostUsagePricing.normalizeCodexModel("gpt-5.3-codex-2026-03-05") == "gpt-5.3-codex") #expect(CostUsagePricing.normalizeCodexModel("gpt-5.3-codex-max") == "gpt-5.3") + #expect(CostUsagePricing.normalizeCodexModel("gpt-5.3-chat-latest") == "gpt-5.3-chat-latest") + #expect(CostUsagePricing.normalizeCodexModel("gpt-5.4-2025-10-06") == "gpt-5.4") + #expect(CostUsagePricing.normalizeCodexModel("gpt-5.3-codex-spark") == "gpt-5.3-codex-spark") } @Test @@ -22,7 +35,22 @@ struct CostUsagePricingTests { } @Test - func codexCostSupportsGpt53CodexMax() { + func codexMiniCostsLessThanBaseModel() throws { + let baseCost = try #require(CostUsagePricing.codexCostUSD( + model: "gpt-5", + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 5)) + let miniCost = try #require(CostUsagePricing.codexCostUSD( + model: "gpt-5-codex-mini", + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 5)) + #expect(miniCost < baseCost) + } + + @Test + func codexCostSupportsGpt53CodexMaxFallback() { let cost = CostUsagePricing.codexCostUSD( model: "gpt-5.3-codex-max", inputTokens: 100, @@ -31,6 +59,116 @@ struct CostUsagePricingTests { #expect(cost != nil) } + @Test + func codexCostSupportsGpt54Base() { + let cost = CostUsagePricing.codexCostUSD( + model: "gpt-5.4", + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 5) + #expect(cost != nil) + } + + @Test + func codexCostSupportsGpt5MiniAndChatAliases() { + #expect(CostUsagePricing.codexCostUSD( + model: "gpt-5-codex-mini", + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 5) != nil) + #expect(CostUsagePricing.codexCostUSD( + model: "gpt-5-chat", + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 5) != nil) + #expect(CostUsagePricing.codexCostUSD( + model: "gpt-5.2-chat", + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 5) != nil) + } + + @Test + func codexCostSupportsSnapshotModels() { + #expect(CostUsagePricing.codexCostUSD( + model: "gpt-5-mini-2025-10-06", + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 5) != nil) + #expect(CostUsagePricing.codexCostUSD( + model: "gpt-5-pro-2025-10-06", + inputTokens: 100, + cachedInputTokens: 0, + outputTokens: 5) != nil) + #expect(CostUsagePricing.codexCostUSD( + model: "gpt-5.4-2025-10-06", + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 5) != nil) + } + + @Test + func codexCostSupportsProModelsWithoutCachedReads() { + #expect(CostUsagePricing.codexCostUSD( + model: "gpt-5.2-pro", + inputTokens: 100, + cachedInputTokens: 0, + outputTokens: 5) != nil) + #expect(CostUsagePricing.codexCostUSD( + model: "gpt-5.4-pro", + inputTokens: 100, + cachedInputTokens: 0, + outputTokens: 5) != nil) + } + + @Test + func codexCostUsesStandardGpt54RatesForLocalScans() throws { + let cost = try #require(CostUsagePricing.codexCostUSD( + model: "gpt-5.4", + inputTokens: 300_000, + cachedInputTokens: 10000, + outputTokens: 0)) + let expected = Double(290_000) * 2.5e-6 + Double(10000) * 2.5e-7 + #expect(cost == expected) + } + + @Test + func codexCostUsesStandardGpt54ProRatesForLocalScans() throws { + let cost = try #require(CostUsagePricing.codexCostUSD( + model: "gpt-5.4-pro", + inputTokens: 300_000, + cachedInputTokens: 0, + outputTokens: 100)) + let expected = Double(300_000) * 3e-5 + Double(100) * 1.8e-4 + #expect(cost == expected) + } + + @Test + func codexCostReturnsNilForProModelCachedReads() { + #expect(CostUsagePricing.codexCostUSD( + model: "gpt-5.2-pro", + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 5) == nil) + #expect(CostUsagePricing.codexCostUSD( + model: "gpt-5.4-pro", + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 5) == nil) + } + + @Test + func codexCostReturnsZeroForResearchPreviewModel() { + let cost = CostUsagePricing.codexCostUSD( + model: "gpt-5.3-codex-spark", + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 5) + #expect(cost == 0) + #expect(CostUsagePricing.codexDisplayLabel(model: "gpt-5.3-codex-spark") == "Research Preview") + #expect(CostUsagePricing.codexDisplayLabel(model: "gpt-5.2-codex") == nil) + } + @Test func normalizesClaudeOpus41DatedVariants() { #expect(CostUsagePricing.normalizeClaudeModel("claude-opus-4-1-20250805") == "claude-opus-4-1") diff --git a/Tests/CodexBarTests/CostUsageScannerCodexCostSemanticsTests.swift b/Tests/CodexBarTests/CostUsageScannerCodexCostSemanticsTests.swift new file mode 100644 index 000000000..ff55d0e4f --- /dev/null +++ b/Tests/CodexBarTests/CostUsageScannerCodexCostSemanticsTests.swift @@ -0,0 +1,137 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite +struct CostUsageScannerCodexCostSemanticsTests { + @Test + func codexDailyReportKeepsUnknownModelsUnpriced() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2025, month: 12, day: 21) + let iso0 = env.isoString(for: day) + + let tokenCountWithoutModel: [String: Any] = [ + "type": "event_msg", + "timestamp": iso0, + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 100, + "cached_input_tokens": 20, + "output_tokens": 10, + ], + ], + ], + ] + + _ = try env.writeCodexSessionFile( + day: day, + filename: "session-unknown-model.jsonl", + contents: env.jsonl([tokenCountWithoutModel])) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + + let entry = try #require(report.data.first) + #expect(entry.modelsUsed == ["unknown"]) + #expect(entry.modelBreakdowns?.contains { + $0.modelName == "unknown" && $0.costUSD == nil && $0.totalTokens == 110 + } == true) + #expect(report.summary?.totalCostUSD == nil) + } + + @Test + func codexDailyReportNilCostWhenKnownAndUnknownModelsMix() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2025, month: 12, day: 22) + let iso0 = env.isoString(for: day) + let iso1 = env.isoString(for: day.addingTimeInterval(1)) + let iso2 = env.isoString(for: day.addingTimeInterval(2)) + + let knownModel = "openai/gpt-5.2-codex" + let turnContext: [String: Any] = [ + "type": "turn_context", + "timestamp": iso0, + "payload": [ + "model": knownModel, + ], + ] + let knownTokenCount: [String: Any] = [ + "type": "event_msg", + "timestamp": iso1, + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 100, + "cached_input_tokens": 20, + "output_tokens": 10, + ], + "model": knownModel, + ], + ], + ] + let unknownTokenCount: [String: Any] = [ + "type": "event_msg", + "timestamp": iso2, + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 80, + "cached_input_tokens": 0, + "output_tokens": 8, + ], + ], + ], + ] + + _ = try env.writeCodexSessionFile( + day: day, + filename: "session-known-model.jsonl", + contents: env.jsonl([turnContext, knownTokenCount])) + _ = try env.writeCodexSessionFile( + day: day, + filename: "session-unknown-model.jsonl", + contents: env.jsonl([unknownTokenCount])) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + + let entry = try #require(report.data.first) + #expect(entry.modelsUsed == ["gpt-5.2-codex", "unknown"]) + #expect(entry.costUSD == nil) + #expect(entry.modelBreakdowns?.contains { + $0.modelName == "gpt-5.2-codex" && $0.costUSD != nil && $0.totalTokens == 110 + } == true) + #expect(entry.modelBreakdowns?.contains { + $0.modelName == "unknown" && $0.costUSD == nil && $0.totalTokens == 88 + } == true) + #expect(report.summary?.totalCostUSD == nil) + } +} diff --git a/Tests/CodexBarTests/CostUsageScannerTests.swift b/Tests/CodexBarTests/CostUsageScannerTests.swift index b38a0d31a..29faad333 100644 --- a/Tests/CodexBarTests/CostUsageScannerTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerTests.swift @@ -56,7 +56,13 @@ struct CostUsageScannerTests { now: day, options: options) #expect(first.data.count == 1) - #expect(first.data[0].modelsUsed == ["gpt-5.2"]) + #expect(first.data[0].modelsUsed == ["gpt-5.2-codex"]) + #expect(first.data[0].modelBreakdowns == [ + CostUsageDailyReport.ModelBreakdown( + modelName: "gpt-5.2-codex", + costUSD: first.data[0].costUSD, + totalTokens: 110), + ]) #expect(first.data[0].totalTokens == 110) #expect((first.data[0].costUSD ?? 0) > 0) @@ -85,6 +91,7 @@ struct CostUsageScannerTests { now: day, options: options) #expect(second.data.count == 1) + #expect(second.data[0].modelsUsed == ["gpt-5.2-codex"]) #expect(second.data[0].totalTokens == 176) #expect((second.data[0].costUSD ?? 0) > (first.data[0].costUSD ?? 0)) } @@ -476,6 +483,7 @@ struct CostUsageScannerTests { let model = "openai/gpt-5.2-codex" let normalized = CostUsagePricing.normalizeCodexModel(model) + #expect(normalized == "gpt-5.2-codex") let turnContext: [String: Any] = [ "type": "turn_context", "timestamp": iso0, @@ -838,61 +846,6 @@ struct CostUsageScannerTests { #expect(report.data[0].outputTokens == 15) #expect(report.data[0].totalTokens == 45) } - - @Test - func jsonlScannerHandlesLinesAcrossReadChunks() throws { - let env = try CostUsageTestEnvironment() - defer { env.cleanup() } - - let fileURL = env.root.appendingPathComponent("large-lines.jsonl", isDirectory: false) - let largeLine = String(repeating: "x", count: 300_000) - let contents = "\(largeLine)\nsmall\n" - try contents.write(to: fileURL, atomically: true, encoding: .utf8) - - var scanned: [(count: Int, truncated: Bool)] = [] - let endOffset = try CostUsageJsonl.scan( - fileURL: fileURL, - maxLineBytes: 400_000, - prefixBytes: 400_000) - { line in - scanned.append((line.bytes.count, line.wasTruncated)) - } - - #expect(endOffset == Int64(Data(contents.utf8).count)) - #expect(scanned.count == 2) - #expect(scanned[0].count == 300_000) - #expect(scanned[0].truncated == false) - #expect(scanned[1].count == 5) - #expect(scanned[1].truncated == false) - } - - @Test - func jsonlScannerMarksPrefixLimitedLinesAsTruncated() throws { - let env = try CostUsageTestEnvironment() - defer { env.cleanup() } - - let fileURL = env.root.appendingPathComponent("truncated-lines.jsonl", isDirectory: false) - let shortLine = "ok" - let longLine = String(repeating: "a", count: 2000) - let contents = "\(shortLine)\n\(longLine)\n" - try contents.write(to: fileURL, atomically: true, encoding: .utf8) - - var scanned: [CostUsageJsonl.Line] = [] - _ = try CostUsageJsonl.scan( - fileURL: fileURL, - maxLineBytes: 10000, - prefixBytes: 64) - { line in - scanned.append(line) - } - - #expect(scanned.count == 2) - #expect(String(data: scanned[0].bytes, encoding: .utf8) == "ok") - #expect(scanned[0].wasTruncated == false) - #expect(scanned[1].bytes.count == 64) - #expect(String(data: scanned[1].bytes, encoding: .utf8) == String(repeating: "a", count: 64)) - #expect(scanned[1].wasTruncated == true) - } } struct CostUsageTestEnvironment { diff --git a/Tests/CodexBarTests/MenuCardModelSparkTests.swift b/Tests/CodexBarTests/MenuCardModelSparkTests.swift new file mode 100644 index 000000000..0dd87c3df --- /dev/null +++ b/Tests/CodexBarTests/MenuCardModelSparkTests.swift @@ -0,0 +1,233 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +@Suite +struct MenuCardModelSparkTests { + @Test + func showsSparkSessionAndWeeklyMetricsWhenCodexSparkUsagePresent() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 22, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3000), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 40, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(6000), + resetDescription: nil), + usageBucketGroups: [ + UsageBucketGroupSnapshot( + id: "codex.spark", + title: "GPT-5.3-Codex-Spark", + buckets: [ + UsageBucketSnapshot( + id: "codex.spark.session", + title: "Session", + window: RateWindow( + usedPercent: 3, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(5400), + resetDescription: nil)), + UsageBucketSnapshot( + id: "codex.spark.weekly", + title: "Weekly", + window: RateWindow( + usedPercent: 17, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(7200), + resetDescription: nil)), + ]), + ], + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: nil)) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "codex@example.com", plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.count == 4) + #expect(model.metrics.contains { $0.id == "codex.spark.session" && $0.title == "Session" && $0.percent == 97 }) + #expect(model.metrics.contains { $0.id == "codex.spark.weekly" && $0.title == "Weekly" && $0.percent == 83 }) + + let groups = UsageMenuCardView.metricGroups(metrics: model.metrics) + #expect(groups.count == 2) + let sparkGroup = try #require(groups.last) + #expect(sparkGroup.id == "codex.spark") + #expect(sparkGroup.title == "GPT-5.3-Codex-Spark") + #expect(sparkGroup.metrics.map(\.id) == ["codex.spark.session", "codex.spark.weekly"]) + } + + @Test + func doesNotCreateSupplementalGroupWithoutUsageBucketGroups() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 22, windowMinutes: 300, resetsAt: now, resetDescription: nil), + secondary: RateWindow(usedPercent: 40, windowMinutes: 10080, resetsAt: now, resetDescription: nil), + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: "Pro")) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "codex@example.com", plan: "Pro"), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(UsageMenuCardView.metricGroups(metrics: model.metrics).count == 1) + #expect(model.metrics.contains { $0.groupID != nil } == false) + } + + @Test + func preservesProviderOwnedPrimaryBucketGroupAlongsideBuiltInPrimaryGroup() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 22, windowMinutes: 300, resetsAt: now, resetDescription: nil), + secondary: nil, + usageBucketGroups: [ + UsageBucketGroupSnapshot( + id: "primary", + title: "Provider Primary", + buckets: [ + UsageBucketSnapshot( + id: "primary.session", + title: "Session", + window: RateWindow( + usedPercent: 3, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(5400), + resetDescription: nil)), + ]), + ], + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: nil)) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "codex@example.com", plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + let groups = UsageMenuCardView.metricGroups(metrics: model.metrics) + #expect(groups.count == 2) + #expect(groups.map(\.kind) == [.builtInPrimary, .providerBucket]) + #expect(groups.contains { $0.id == "primary" && $0.kind == .providerBucket }) + #expect(UsageMenuCardView.primaryMetricGroup(metrics: model.metrics)?.kind == .builtInPrimary) + #expect(UsageMenuCardView.supplementalMetricGroups(metrics: model.metrics).map(\.id) == ["primary"]) + } + + @Test + func keepsInternalViewIdentityUniqueWhenProviderGroupMatchesBuiltInSentinel() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 22, windowMinutes: 300, resetsAt: now, resetDescription: nil), + secondary: nil, + usageBucketGroups: [ + UsageBucketGroupSnapshot( + id: "__builtInPrimary", + title: "Provider Sentinel", + buckets: [ + UsageBucketSnapshot( + id: "__builtInPrimary.session", + title: "Session", + window: RateWindow( + usedPercent: 3, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(5400), + resetDescription: nil)), + ]), + ], + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: nil)) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "codex@example.com", plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + let groups = UsageMenuCardView.metricGroups(metrics: model.metrics) + #expect(groups.map(\.internalID) == ["builtInPrimary", "providerBucket:__builtInPrimary"]) + } +} diff --git a/Tests/CodexBarTests/MenuDescriptorSparkTests.swift b/Tests/CodexBarTests/MenuDescriptorSparkTests.swift new file mode 100644 index 000000000..7ee15e193 --- /dev/null +++ b/Tests/CodexBarTests/MenuDescriptorSparkTests.swift @@ -0,0 +1,144 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +@Suite +struct MenuDescriptorSparkTests { + @Test + func codexUsageSectionSeparatesSparkRowsWhenLiveSparkUsageExists() throws { + let suite = "MenuDescriptorSparkTests-live-spark" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.usageBarsShowUsed = false + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let now = Date() + store._setSnapshotForTesting(UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: now, resetDescription: nil), + secondary: RateWindow(usedPercent: 20, windowMinutes: 10080, resetsAt: now, resetDescription: nil), + usageBucketGroups: [ + UsageBucketGroupSnapshot( + id: "codex.spark", + title: "GPT-5.3-Codex-Spark", + buckets: [ + UsageBucketSnapshot( + id: "codex.spark.session", + title: "Session", + window: RateWindow( + usedPercent: 3, + windowMinutes: 300, + resetsAt: now, + resetDescription: nil)), + UsageBucketSnapshot( + id: "codex.spark.weekly", + title: "Weekly", + window: RateWindow( + usedPercent: 17, + windowMinutes: 10080, + resetsAt: now, + resetDescription: nil)), + ]), + ], + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: nil)), provider: .codex) + + let descriptor = MenuDescriptor.build( + provider: .codex, + store: store, + settings: settings, + account: AccountInfo(email: "codex@example.com", plan: nil), + updateReady: false, + includeContextualActions: false) + + let textLines = descriptor.sections + .flatMap(\.entries) + .compactMap { entry -> String? in + guard case let .text(text, _) = entry else { return nil } + return text + } + + #expect(textLines.contains("GPT-5.3-Codex-Spark")) + #expect(textLines.contains("Session: 97% left")) + #expect(textLines.contains("Weekly: 83% left")) + } + + @Test + func codexSparkOnlyUsageSectionDoesNotInsertLeadingDivider() throws { + let suite = "MenuDescriptorSparkTests-spark-only" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.usageBarsShowUsed = false + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let now = Date() + store._setSnapshotForTesting(UsageSnapshot( + primary: nil, + secondary: nil, + usageBucketGroups: [ + UsageBucketGroupSnapshot( + id: "codex.spark", + title: "GPT-5.3-Codex-Spark", + buckets: [ + UsageBucketSnapshot( + id: "codex.spark.session", + title: "Session", + window: RateWindow( + usedPercent: 3, + windowMinutes: 300, + resetsAt: now, + resetDescription: nil)), + ]), + ], + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: nil)), provider: .codex) + + let descriptor = MenuDescriptor.build( + provider: .codex, + store: store, + settings: settings, + account: AccountInfo(email: "codex@example.com", plan: nil), + updateReady: false, + includeContextualActions: false) + + let entries = try #require(descriptor.sections.first?.entries) + #expect(entries.count >= 3) + #expect({ + guard case .text("Codex", .headline) = entries[0] else { return false } + return true + }()) + #expect({ + guard case .text("GPT-5.3-Codex-Spark", .headline) = entries[1] else { return false } + return true + }()) + } +} diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index 3f04c3ff7..d77267a5e 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -3,6 +3,34 @@ import CodexBarCore import Testing @testable import CodexBar +private func makeCodexSparkUsageBucketGroup() -> UsageBucketGroupSnapshot { + UsageBucketGroupSnapshot( + id: "codex.spark", + title: "GPT-5.3-Codex-Spark", + buckets: [ + UsageBucketSnapshot( + id: "codex.spark.session", + title: "Session", + window: RateWindow(usedPercent: 3, windowMinutes: 300, resetsAt: Date(), resetDescription: nil)), + UsageBucketSnapshot( + id: "codex.spark.weekly", + title: "Weekly", + window: RateWindow(usedPercent: 17, windowMinutes: 10080, resetsAt: Date(), resetDescription: nil)), + ]) +} + +private func makeProviderPrimaryUsageBucketGroup() -> UsageBucketGroupSnapshot { + UsageBucketGroupSnapshot( + id: "primary", + title: "Provider Primary", + buckets: [ + UsageBucketSnapshot( + id: "primary.session", + title: "Session", + window: RateWindow(usedPercent: 3, windowMinutes: 300, resetsAt: Date(), resetDescription: nil)), + ]) +} + @MainActor @Suite struct StatusMenuTests { @@ -634,6 +662,155 @@ struct StatusMenuTests { #expect(try #require(creditsIndex) < costIndex!) } + @Test + func codexMenuCardSeparatesSparkSectionWhenLiveSparkUsageExists() throws { + StatusItemController.menuCardRenderingEnabled = true + StatusItemController.menuRefreshEnabled = false + defer { self.disableMenuCardsForTesting() } + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let claudeMeta = registry.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: false) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store._setSnapshotForTesting(UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: Date(), resetDescription: nil), + secondary: RateWindow(usedPercent: 20, windowMinutes: 10080, resetsAt: Date(), resetDescription: nil), + usageBucketGroups: [makeCodexSparkUsageBucketGroup()], + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: nil)), provider: .codex) + store.credits = CreditsSnapshot(remaining: 42, events: [], updatedAt: Date()) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let ids = self.representedIDs(in: menu) + let usageIndex = try #require(ids.firstIndex(of: "menuCardUsage")) + let sparkIndex = try #require(ids.firstIndex(of: "menuCardUsageGroup-codex.spark")) + let creditsIndex = try #require(ids.firstIndex(of: "menuCardCredits")) + + #expect(usageIndex < sparkIndex) + #expect(sparkIndex < creditsIndex) + } + + @Test + func codexSparkOnlyMenuCardKeepsSeparatorBeforeActions() throws { + StatusItemController.menuCardRenderingEnabled = true + StatusItemController.menuRefreshEnabled = false + defer { self.disableMenuCardsForTesting() } + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let claudeMeta = registry.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: false) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store._setSnapshotForTesting(UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: Date(), resetDescription: nil), + secondary: RateWindow(usedPercent: 20, windowMinutes: 10080, resetsAt: Date(), resetDescription: nil), + usageBucketGroups: [makeCodexSparkUsageBucketGroup()], + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: nil)), provider: .codex) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + + let sparkIndex = try #require(menu.items.firstIndex { + ($0.representedObject as? String) == "menuCardUsageGroup-codex.spark" + }) + let separatorIndex = sparkIndex + 1 + #expect(separatorIndex < menu.items.count) + #expect(menu.items[separatorIndex].isSeparatorItem) + } + + @Test + func providerOwnedPrimaryBucketGroupStillRendersAsSupplementalSection() throws { + StatusItemController.menuCardRenderingEnabled = true + StatusItemController.menuRefreshEnabled = false + defer { self.disableMenuCardsForTesting() } + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store._setSnapshotForTesting(UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: Date(), resetDescription: nil), + secondary: nil, + usageBucketGroups: [makeProviderPrimaryUsageBucketGroup()], + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: nil)), provider: .codex) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let ids = self.representedIDs(in: menu) + let usageIndex = try #require(ids.firstIndex(of: "menuCardUsage")) + let providerPrimaryIndex = try #require(ids.firstIndex(of: "menuCardUsageGroup-primary")) + + #expect(usageIndex < providerPrimaryIndex) + } + @Test func showsExtraUsageForClaudeWhenUsingMenuCardSections() { self.disableMenuCardsForTesting() diff --git a/Tests/CodexBarTests/UsageFormatterTests.swift b/Tests/CodexBarTests/UsageFormatterTests.swift index 072b299df..a14c9fe3b 100644 --- a/Tests/CodexBarTests/UsageFormatterTests.swift +++ b/Tests/CodexBarTests/UsageFormatterTests.swift @@ -99,6 +99,13 @@ struct UsageFormatterTests { #expect(UsageFormatter.modelDisplayName("gpt-4o-2024-08-06") == "gpt-4o") #expect(UsageFormatter.modelDisplayName("Claude Opus 4.5 2025 1101") == "Claude Opus 4.5") #expect(UsageFormatter.modelDisplayName("claude-sonnet-4-5") == "claude-sonnet-4-5") + #expect(UsageFormatter.modelDisplayName("gpt-5.3-codex-spark") == "gpt-5.3-codex-spark") + } + + @Test + func modelCostDetailUsesResearchPreviewLabel() { + #expect(UsageFormatter.modelCostDetail("gpt-5.3-codex-spark", costUSD: 0) == "Research Preview") + #expect(UsageFormatter.modelCostDetail("gpt-5.2-codex", costUSD: 0.42) == "$0.42") } @Test