diff --git a/CHANGELOG.md b/CHANGELOG.md index e624fe235..95cd71fff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed - Release: prevent manual CLI artifact builds from publishing or clobbering release assets (#1154). Thanks @jskoiz! +- Cost history: route OpenAI and Mistral API spend through the shared cost-history cards, including OpenAI request counts (#1163). Thanks @LeoLin990405! - Localization: improve Traditional Chinese wording and localize notification copy (#1158). Thanks @jack24254029! - Localization: improve Simplified Chinese visible menu, dashboard, and usage labels (#1145). Thanks @Yuxin-Qiao! diff --git a/Sources/CodexBar/CostHistoryChartMenuView.swift b/Sources/CodexBar/CostHistoryChartMenuView.swift index 475f421da..5ff7bbc28 100644 --- a/Sources/CodexBar/CostHistoryChartMenuView.swift +++ b/Sources/CodexBar/CostHistoryChartMenuView.swift @@ -11,11 +11,13 @@ struct CostHistoryChartMenuView: View { let date: Date let costUSD: Double let totalTokens: Int? + let requestCount: Int? - init(date: Date, costUSD: Double, totalTokens: Int?) { + init(date: Date, costUSD: Double, totalTokens: Int?, requestCount: Int?) { self.date = date self.costUSD = costUSD self.totalTokens = totalTokens + self.requestCount = requestCount self.id = "\(Int(date.timeIntervalSince1970))-\(costUSD)" } } @@ -36,7 +38,9 @@ struct CostHistoryChartMenuView: View { private let provider: UsageProvider private let daily: [DailyEntry] private let totalCostUSD: Double? + private let currencyCode: String private let historyDays: Int + private let windowLabel: String? private let width: CGFloat @State private var selectedDateKey: String? @@ -44,13 +48,17 @@ struct CostHistoryChartMenuView: View { provider: UsageProvider, daily: [DailyEntry], totalCostUSD: Double?, + currencyCode: String = "USD", historyDays: Int = 30, + windowLabel: String? = nil, width: CGFloat) { self.provider = provider self.daily = daily self.totalCostUSD = totalCostUSD + self.currencyCode = currencyCode self.historyDays = max(1, min(365, historyDays)) + self.windowLabel = windowLabel self.width = width } @@ -173,8 +181,8 @@ struct CostHistoryChartMenuView: View { if let total = self.totalCostUSD { Text(String( format: L("Est. total (%@): %@"), - Self.windowLabel(days: self.historyDays), - UsageFormatter.usdString(total))) + self.windowLabel ?? Self.windowLabel(days: self.historyDays), + self.costString(total))) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) @@ -252,7 +260,11 @@ struct CostHistoryChartMenuView: View { for entry in sorted { guard let costUSD = entry.costUSD, costUSD >= 0 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, + costUSD: costUSD, + totalTokens: entry.totalTokens, + requestCount: entry.requestCount) points.append(point) pointsByKey[entry.date] = point entriesByKey[entry.date] = entry @@ -426,12 +438,15 @@ struct CostHistoryChartMenuView: View { } let dayLabel = date.formatted(.dateTime.month(.abbreviated).day()) - let cost = UsageFormatter.usdString(point.costUSD) - let primary = if let tokens = point.totalTokens { - String(format: L("%@: %@ · %@ tokens"), dayLabel, cost, UsageFormatter.tokenCountString(tokens)) - } else { - "\(dayLabel): \(cost)" + let cost = self.costString(point.costUSD) + var parts = [cost] + if let tokens = point.totalTokens { + parts.append("\(UsageFormatter.tokenCountString(tokens)) tokens") + } + if let requests = point.requestCount { + parts.append("\(UsageFormatter.tokenCountString(requests)) requests") } + let primary = "\(dayLabel): \(parts.joined(separator: " · "))" return DetailContent(primary: primary, rows: self.breakdownRows(key: key, model: model)) } @@ -472,20 +487,21 @@ struct CostHistoryChartMenuView: View { UsageFormatter.modelCostDetail( item.modelName, costUSD: item.costUSD, - totalTokens: item.totalTokens) + totalTokens: item.totalTokens, + currencyCode: self.currencyCode) } private func modelBreakdownModeSubtitle(_ item: CostUsageDailyReport.ModelBreakdown) -> String? { var parts: [String] = [] if let standardCost = item.standardCostUSD { - var standardPart = "Std \(UsageFormatter.usdString(standardCost))" + var standardPart = "Std \(self.costString(standardCost))" if let standardTokens = item.standardTokens { standardPart += " · \(UsageFormatter.tokenCountString(standardTokens))" } parts.append(standardPart) } if let priorityCost = item.priorityCostUSD { - var priorityPart = "Fast \(UsageFormatter.usdString(priorityCost))" + var priorityPart = "Fast \(self.costString(priorityCost))" if let priorityTokens = item.priorityTokens { priorityPart += " · \(UsageFormatter.tokenCountString(priorityTokens))" } @@ -495,6 +511,10 @@ struct CostHistoryChartMenuView: View { return parts.joined(separator: " / ") } + private func costString(_ value: Double) -> String { + UsageFormatter.currencyString(value, currencyCode: self.currencyCode) + } + private static func breakdownAccentOpacity(for index: Int) -> Double { let opacity = 0.75 - (Double(index) * 0.12) return max(0.3, opacity) diff --git a/Sources/CodexBar/InlineUsageDashboardContent.swift b/Sources/CodexBar/InlineUsageDashboardContent.swift index fd0b70df0..c05a884de 100644 --- a/Sources/CodexBar/InlineUsageDashboardContent.swift +++ b/Sources/CodexBar/InlineUsageDashboardContent.swift @@ -84,8 +84,11 @@ extension UsageMenuCardView.Model { } static func inlineUsageDashboard(input: Input) -> InlineUsageDashboardModel? { - if let usage = input.snapshot?.openAIAPIUsage { - return self.openAIAPIInlineDashboard(usage) + if self.usesProviderCostHistoryAsPrimaryDashboard(input.provider), + let tokenSnapshot = primaryCostHistorySnapshot(input: input), + !tokenSnapshot.daily.isEmpty + { + return self.costHistoryInlineDashboard(provider: input.provider, snapshot: tokenSnapshot) } if input.provider == .claude, let usage = input.snapshot?.claudeAdminAPIUsage @@ -97,12 +100,6 @@ extension UsageMenuCardView.Model { { return Self.openRouterInlineDashboard(usage) } - if input.provider == .mistral, - let usage = input.snapshot?.mistralUsage, - !usage.daily.isEmpty - { - return Self.mistralInlineDashboard(usage) - } if input.provider == .zai, let modelUsage = input.snapshot?.zaiUsage?.modelUsage { @@ -125,39 +122,25 @@ extension UsageMenuCardView.Model { return nil } - fileprivate static func openAIAPIInlineDashboard(_ usage: OpenAIAPIUsageSnapshot) -> InlineUsageDashboardModel { - let today = usage.latestDay - let last7 = usage.last7Days - let last30 = usage.last30Days - let historyLabel = usage.historyWindowLabel - let points = usage.daily.suffix(usage.historyDays).map { - InlineUsageDashboardModel.Point( - id: $0.day, - label: Self.shortDayLabel($0.day), - value: $0.costUSD, - accessibilityValue: "\($0.day): \(UsageFormatter.usdString($0.costUSD))") - } - var details = [ - "\(historyLabel): \(UsageFormatter.tokenCountString(last30.totalTokens)) tokens · " + - "\(UsageFormatter.tokenCountString(last30.requests)) requests", - ] - if let topModel = usage.topModels.first { - details.append("Top model: \(Self.shortModelName(topModel.name))") + static func usesProviderCostHistoryAsPrimaryDashboard(_ provider: UsageProvider) -> Bool { + provider == .openai || provider == .mistral + } + + static func primaryCostHistorySnapshot(input: Input) -> CostUsageTokenSnapshot? { + switch input.provider { + case .openai: + if let projected = input.snapshot?.openAIAPIUsage?.toCostUsageTokenSnapshot() { + return projected + } + return input.snapshot == nil ? input.tokenSnapshot : nil + case .mistral: + if let projected = input.snapshot?.mistralUsage?.toCostUsageTokenSnapshot() { + return projected + } + return input.snapshot == nil ? input.tokenSnapshot : nil + default: + return input.tokenSnapshot } - return InlineUsageDashboardModel( - accessibilityLabel: "OpenAI API \(usage.historyDays) day spend trend", - valueStyle: .currencyUSD, - kpis: [ - .init(title: "Today", value: UsageFormatter.usdString(today.costUSD), emphasis: true), - .init(title: "7d spend", value: UsageFormatter.usdString(last7.costUSD), emphasis: false), - .init( - title: "\(historyLabel) spend", - value: UsageFormatter.usdString(last30.costUSD), - emphasis: false), - .init(title: "Today req", value: UsageFormatter.tokenCountString(today.requests), emphasis: false), - ], - points: points, - detailLines: details) } private static func costHistoryInlineDashboard( @@ -165,61 +148,91 @@ extension UsageMenuCardView.Model { snapshot: CostUsageTokenSnapshot) -> InlineUsageDashboardModel { let historyDays = max(1, min(365, snapshot.historyDays)) - let periodLabel = historyDays == 1 ? "today" : "\(historyDays) day" + let historyTitle = snapshot.historyLabel + ?? (historyDays == 1 + ? L("Today") + : historyDays == 30 + ? L("30d cost") + : "\(String(format: L("Last %d days"), historyDays)) \(L("Cost"))") + let tokenHistoryTitle = snapshot.historyLabel.map { "\($0) \(L("tokens"))" } + ?? (historyDays == 1 + ? L("Today tokens") + : historyDays == 30 + ? L("30d tokens") + : String(format: L("%@ tokens"), String(format: L("Last %d days"), historyDays))) + let requestHistoryTitle = snapshot.historyLabel.map { "\($0) \(L("requests"))" } + ?? (historyDays == 1 + ? L("Today requests") + : historyDays == 30 + ? L("30d requests") + : String(format: L("%@ requests"), String(format: L("Last %d days"), historyDays))) + let periodLabel = snapshot.historyLabel?.lowercased() + ?? (historyDays == 1 ? "today" : "\(historyDays) day") let points = snapshot.daily.suffix(historyDays).compactMap { entry -> InlineUsageDashboardModel.Point? in guard let cost = entry.costUSD else { return nil } return InlineUsageDashboardModel.Point( id: entry.date, label: Self.shortDayLabel(entry.date), value: cost, - accessibilityValue: "\(entry.date): \(UsageFormatter.usdString(cost))") + accessibilityValue: "\(entry.date): \(Self.costString(cost, currencyCode: snapshot.currencyCode))") } let latest = snapshot.daily.max { lhs, rhs in lhs.date < rhs.date } var details: [String] = [] if let topModel = Self.topCostModel(from: snapshot.daily) { details.append("\(L("Top model")): \(Self.shortModelName(topModel))") } - if provider == .bedrock { - details.append("AWS Cost Explorer billing can lag.") - } else if provider == .claude { - details.append(UsageFormatter.costEstimateHint(provider: provider)) + if let requestCount = snapshot.last30DaysRequests { + details.append("\(requestHistoryTitle): \(UsageFormatter.tokenCountString(requestCount)) requests") + } + if let hint = Self.tokenUsageHint(provider: provider) { + details.append(hint) } else { details.append(L("cost_estimate_hint")) } let providerName = ProviderDefaults.metadata[provider]?.displayName ?? provider.rawValue return InlineUsageDashboardModel( accessibilityLabel: "\(providerName) \(periodLabel) cost trend", - valueStyle: .currencyUSD, + valueStyle: Self.costValueStyle(currencyCode: snapshot.currencyCode), kpis: [ .init( - title: provider == .bedrock ? L("Latest") : L("Today"), - value: latest?.costUSD.map(UsageFormatter.usdString) ?? "—", + title: provider == .bedrock || provider == .mistral ? L("Latest") : L("Today"), + value: latest?.costUSD.map { Self.costString($0, currencyCode: snapshot.currencyCode) } ?? "—", emphasis: true), .init( - title: historyDays == 1 - ? L("Today") - : historyDays == 30 - ? L("30d cost") - : "\(String(format: L("Last %d days"), historyDays)) \(L("Cost"))", - value: snapshot.last30DaysCostUSD.map(UsageFormatter.usdString) ?? "—", + title: historyTitle, + value: snapshot.last30DaysCostUSD + .map { Self.costString($0, currencyCode: snapshot.currencyCode) } ?? "—", emphasis: false), .init( - title: historyDays == 1 - ? L("Today tokens") - : historyDays == 30 - ? L("30d tokens") - : String(format: L("%@ tokens"), String(format: L("Last %d days"), historyDays)), + title: tokenHistoryTitle, value: snapshot.last30DaysTokens.map(UsageFormatter.tokenCountString) ?? "—", emphasis: false), - .init( - title: L("Latest tokens"), - value: latest?.totalTokens.map(UsageFormatter.tokenCountString) ?? "—", - emphasis: false), - ], + ] + Self.costHistoryTrailingKPIs(snapshot: snapshot, latest: latest), points: points, detailLines: details) } + private static func costHistoryTrailingKPIs( + snapshot: CostUsageTokenSnapshot, + latest: CostUsageDailyReport.Entry?) + -> [InlineUsageDashboardModel.KPI] + { + if let requests = snapshot.last30DaysRequests { + return [ + .init( + title: L("Requests"), + value: UsageFormatter.tokenCountString(requests), + emphasis: false), + ] + } + return [ + .init( + title: L("Latest tokens"), + value: latest?.totalTokens.map(UsageFormatter.tokenCountString) ?? "—", + emphasis: false), + ] + } + fileprivate static func claudeAdminAPIInlineDashboard(_ usage: ClaudeAdminAPIUsageSnapshot) -> InlineUsageDashboardModel { @@ -310,42 +323,6 @@ extension UsageMenuCardView.Model { detailLines: details) } - private static func mistralInlineDashboard(_ usage: MistralUsageSnapshot) -> InlineUsageDashboardModel { - let points = usage.daily.suffix(30).map { - InlineUsageDashboardModel.Point( - id: $0.day, - label: Self.shortDayLabel($0.day), - value: $0.cost, - accessibilityValue: "\($0.day): \(Self.mistralCurrencyString($0.cost, symbol: usage.currencySymbol))") - } - let latest = usage.daily.last - let totalTokens = usage.totalInputTokens + usage.totalCachedTokens + usage.totalOutputTokens - var details = ["This month: \(UsageFormatter.tokenCountString(totalTokens)) tokens"] - if let topModel = Self.topMistralModel(from: usage.daily) { - details.append("Top model: \(Self.shortModelName(topModel))") - } - return InlineUsageDashboardModel( - accessibilityLabel: "Mistral API spend trend", - valueStyle: .currency(symbol: usage.currencySymbol), - kpis: [ - .init( - title: "Latest", - value: latest.map { Self.mistralCurrencyString($0.cost, symbol: usage.currencySymbol) } ?? "—", - emphasis: true), - .init( - title: "Month", - value: Self.mistralCurrencyString(usage.totalCost, symbol: usage.currencySymbol), - emphasis: false), - .init(title: "Models", value: "\(usage.modelCount)", emphasis: false), - .init( - title: "Latest tokens", - value: latest.map { UsageFormatter.tokenCountString($0.totalTokens) } ?? "—", - emphasis: false), - ], - points: points, - detailLines: details) - } - private static func zaiInlineDashboard(modelUsage: ZaiModelUsageData, now: Date) -> InlineUsageDashboardModel? { let bars = ZaiHourlyBars.from(modelData: modelUsage, range: .last24h, now: now) guard !bars.isEmpty else { return nil } @@ -422,19 +399,6 @@ extension UsageMenuCardView.Model { detailLines: details) } - private static func topMistralModel(from entries: [MistralDailyUsageBucket]) -> String? { - var tokens: [String: Int] = [:] - for entry in entries { - for model in entry.models { - tokens[model.name, default: 0] += model.totalTokens - } - } - return tokens.max { - if $0.value == $1.value { return $0.key > $1.key } - return $0.value < $1.value - }?.key - } - private static func topZaiModel(from bars: [ZaiHourlyBar]) -> String? { var tokens: [String: Int] = [:] for bar in bars { @@ -448,10 +412,6 @@ extension UsageMenuCardView.Model { }?.key } - private static func mistralCurrencyString(_ value: Double, symbol: String) -> String { - "\(symbol)\(String(format: "%.4f", max(0, value)))" - } - private static func openRouterCurrencyString(_ value: Double) -> String { String(format: "$%.2f", value) } @@ -460,6 +420,20 @@ extension UsageMenuCardView.Model { String(format: "%.2f", max(0, value)) } + private static func costString(_ value: Double, currencyCode: String) -> String { + UsageFormatter.currencyString(value, currencyCode: currencyCode) + } + + private static func costValueStyle(currencyCode: String) -> InlineUsageDashboardModel.ValueStyle { + if currencyCode == "USD" { return .currencyUSD } + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.currencyCode = currencyCode + formatter.locale = Locale(identifier: "en_US") + let symbol = formatter.currencySymbol ?? currencyCode + return .currency(symbol: symbol) + } + private static func shortDayLabel(_ day: String) -> String { let pieces = day.split(separator: "-") guard pieces.count == 3, let rawDay = Int(pieces[2]) else { return day } @@ -493,10 +467,6 @@ struct InlineUsageDashboardContent: View { private let model: InlineUsageDashboardModel @Environment(\.menuItemHighlighted) private var isHighlighted - init(snapshot: OpenAIAPIUsageSnapshot) { - self.model = UsageMenuCardView.Model.openAIAPIInlineDashboard(snapshot) - } - init(model: InlineUsageDashboardModel) { self.model = model } diff --git a/Sources/CodexBar/MenuCardView+Costs.swift b/Sources/CodexBar/MenuCardView+Costs.swift index 2c567e389..86f907267 100644 --- a/Sources/CodexBar/MenuCardView+Costs.swift +++ b/Sources/CodexBar/MenuCardView+Costs.swift @@ -2,6 +2,13 @@ import CodexBarCore import Foundation extension UsageMenuCardView.Model { + static func tokenUsageSnapshot(input: Input) -> CostUsageTokenSnapshot? { + if usesProviderCostHistoryAsPrimaryDashboard(input.provider), input.snapshot != nil { + return primaryCostHistorySnapshot(input: input) + } + return input.tokenSnapshot + } + static func creditsLine( metadata: ProviderMetadata, credits: CreditsSnapshot?, @@ -23,15 +30,21 @@ extension UsageMenuCardView.Model { snapshot: CostUsageTokenSnapshot?, error: String?) -> TokenUsageSection? { - guard provider == .codex || provider == .claude || provider == .vertexai || provider == .bedrock else { + guard ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.supportsTokenCost else { return nil } guard enabled else { return nil } guard let snapshot else { return nil } - let sessionCost = snapshot.sessionCostUSD.map { UsageFormatter.usdString($0) } ?? "—" + let sessionCost = snapshot.sessionCostUSD.map { + UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) + } ?? "—" let sessionTokens = snapshot.sessionTokens.map { UsageFormatter.tokenCountString($0) } - let sessionLabel = provider == .bedrock ? Self.bedrockLatestBillingDayLabel(from: snapshot) : L("Today") + let sessionLabel = if provider == .bedrock || provider == .mistral { + Self.latestBillingDayLabel(from: snapshot) + } else { + L("Today") + } let sessionLine: String = { if let sessionTokens { return String(format: L("%@: %@ · %@ tokens"), sessionLabel, sessionCost, sessionTokens) @@ -39,11 +52,13 @@ extension UsageMenuCardView.Model { return "\(sessionLabel): \(sessionCost)" }() - let monthCost = snapshot.last30DaysCostUSD.map { UsageFormatter.usdString($0) } ?? "—" + let monthCost = snapshot.last30DaysCostUSD.map { + UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) + } ?? "—" let fallbackTokens = snapshot.daily.compactMap(\.totalTokens).reduce(0, +) let monthTokensValue = snapshot.last30DaysTokens ?? (fallbackTokens > 0 ? fallbackTokens : nil) let monthTokens = monthTokensValue.map { UsageFormatter.tokenCountString($0) } - let windowLabel = Self.costHistoryWindowLabel(days: snapshot.historyDays) + let windowLabel = snapshot.historyLabel ?? Self.costHistoryWindowLabel(days: snapshot.historyDays) let monthLine: String = { if let monthTokens { return String(format: L("%@: %@ · %@ tokens"), windowLabel, monthCost, monthTokens) @@ -69,6 +84,10 @@ extension UsageMenuCardView.Model { L("cost_estimate_hint") case .bedrock: L("AWS Cost Explorer billing can lag.") + case .openai: + L("Reported by OpenAI Admin API organization usage.") + case .mistral: + L("Reported by Mistral billing usage.") default: nil } @@ -78,7 +97,7 @@ extension UsageMenuCardView.Model { days == 1 ? L("Today") : String(format: L("Last %d days"), days) } - private static func bedrockLatestBillingDayLabel(from snapshot: CostUsageTokenSnapshot) -> String { + private static func latestBillingDayLabel(from snapshot: CostUsageTokenSnapshot) -> String { guard let entry = bedrockLatestBillingDay(from: snapshot.daily), let displayDate = bedrockDisplayDate(from: entry.date) else { return L("Latest billing day") } diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index fa5de8b85..84fad2030 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -783,10 +783,11 @@ extension UsageMenuCardView.Model { } else { Self.providerCostSection(provider: input.provider, cost: input.snapshot?.providerCost) } + let tokenUsageSnapshot = Self.tokenUsageSnapshot(input: input) let tokenUsage = Self.tokenUsageSection( provider: input.provider, enabled: input.tokenCostUsageEnabled, - snapshot: input.tokenSnapshot, + snapshot: tokenUsageSnapshot, error: input.tokenError) let subtitle = Self.subtitle( snapshot: input.snapshot, diff --git a/Sources/CodexBar/OpenAIAPIUsageChartMenuView.swift b/Sources/CodexBar/OpenAIAPIUsageChartMenuView.swift deleted file mode 100644 index b11d742f1..000000000 --- a/Sources/CodexBar/OpenAIAPIUsageChartMenuView.swift +++ /dev/null @@ -1,306 +0,0 @@ -import Charts -import CodexBarCore -import SwiftUI - -@MainActor -struct OpenAIAPIUsageChartMenuView: View { - private let snapshot: OpenAIAPIUsageSnapshot - private let width: CGFloat - @State private var selectedDay: String? - - init(snapshot: OpenAIAPIUsageSnapshot, width: CGFloat) { - self.snapshot = snapshot - self.width = width - } - - var body: some View { - let model = Self.makeModel(snapshot: self.snapshot) - VStack(alignment: .leading, spacing: 10) { - if model.points.isEmpty { - Text("No OpenAI API usage data.") - .font(.footnote) - .foregroundStyle(.secondary) - } else { - LazyVGrid( - columns: [GridItem(.adaptive(minimum: 88), alignment: .leading)], - alignment: .leading, - spacing: 6) - { - StatPill( - title: "\(model.historyLabel) spend", - value: UsageFormatter.usdString(model.last30.costUSD)) - StatPill( - title: "\(model.historyLabel) tokens", - value: UsageFormatter.tokenCountString(model.last30.totalTokens)) - StatPill( - title: "\(model.historyLabel) requests", - value: UsageFormatter.tokenCountString(model.last30.requests)) - } - - Chart { - ForEach(model.points) { point in - BarMark( - x: .value("Day", point.date, unit: .day), - y: .value("Spend", point.costUSD)) - .foregroundStyle(Self.spendColor) - .cornerRadius(2) - } - if let peak = model.peakSpendPoint { - PointMark( - x: .value("Peak spend day", peak.date, unit: .day), - y: .value("Spend", peak.costUSD)) - .symbolSize(30) - .foregroundStyle(Color(nsColor: .systemYellow)) - } - } - .chartYAxis(.hidden) - .chartXAxis { - AxisMarks(values: model.axisDates) { _ in - AxisGridLine().foregroundStyle(Color.clear) - AxisTick().foregroundStyle(Color.clear) - AxisValueLabel(format: .dateTime.month(.abbreviated).day()) - .font(.caption2) - .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) - } - } - .frame(height: 106) - .accessibilityLabel("OpenAI API spend chart") - .chartOverlay { proxy in - GeometryReader { geo in - ZStack(alignment: .topLeading) { - if let rect = self.selectionBandRect(model: model, proxy: proxy, geo: geo) { - Rectangle() - .fill(Self.selectionBandColor) - .frame(width: rect.width, height: rect.height) - .position(x: rect.midX, y: rect.midY) - .allowsHitTesting(false) - } - MouseLocationReader { location in - self.updateSelection(location: location, model: model, proxy: proxy, geo: geo) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .contentShape(Rectangle()) - } - } - } - - Chart { - ForEach(model.points) { point in - AreaMark( - x: .value("Day", point.date, unit: .day), - y: .value("Tokens", point.totalTokens)) - .interpolationMethod(.catmullRom) - .foregroundStyle(Self.tokenColor.opacity(0.22)) - LineMark( - x: .value("Day", point.date, unit: .day), - y: .value("Tokens", point.totalTokens)) - .interpolationMethod(.catmullRom) - .foregroundStyle(Self.tokenColor) - BarMark( - x: .value("Day", point.date, unit: .day), - y: .value("Requests", point.requests)) - .foregroundStyle(Self.requestColor.opacity(0.32)) - } - } - .chartYAxis(.hidden) - .chartXAxis(.hidden) - .frame(height: 74) - .accessibilityLabel("OpenAI API token and request chart") - - let detail = self.detail(model: model) - VStack(alignment: .leading, spacing: 3) { - Text(detail.primary) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - if let secondary = detail.secondary { - Text(secondary) - .font(.caption2) - .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) - .lineLimit(1) - } - } - - LegendRow(items: [ - (Self.spendColor, "Spend"), - (Self.tokenColor, "Tokens"), - (Self.requestColor, "Requests"), - ]) - } - } - .padding(.horizontal, 16) - .padding(.vertical, 10) - .frame(minWidth: self.width, maxWidth: .infinity, alignment: .leading) - } - - private struct Point: Identifiable { - let id: String - let day: String - let date: Date - let costUSD: Double - let requests: Int - let totalTokens: Int - } - - private struct Model { - let points: [Point] - let pointsByDay: [String: Point] - let dayDates: [(day: String, date: Date)] - let axisDates: [Date] - let peakSpendPoint: Point? - let last30: OpenAIAPIUsageSnapshot.Summary - let historyLabel: String - } - - private static let spendColor = Color(red: 0.81, green: 0.56, blue: 0.24) - private static let tokenColor = Color(red: 0.48, green: 0.41, blue: 0.86) - private static let requestColor = Color(red: 0.43, green: 0.73, blue: 0.62) - private static let selectionBandColor = Color(nsColor: .labelColor).opacity(0.1) - - private static func makeModel(snapshot: OpenAIAPIUsageSnapshot) -> Model { - let points = snapshot.daily.compactMap { day -> Point? in - guard let date = Self.dateFromDayKey(day.day) else { return nil } - return Point( - id: day.day, - day: day.day, - date: date, - costUSD: day.costUSD, - requests: day.requests, - totalTokens: day.totalTokens) - } - let pointsByDay = Dictionary(uniqueKeysWithValues: points.map { ($0.day, $0) }) - let dayDates = points.map { ($0.day, $0.date) } - let axisDates: [Date] = { - guard let first = points.first?.date, let last = points.last?.date else { return [] } - if Calendar.current.isDate(first, inSameDayAs: last) { return [first] } - return [first, last] - }() - let peak = points.max { lhs, rhs in - if lhs.costUSD == rhs.costUSD { return lhs.totalTokens < rhs.totalTokens } - return lhs.costUSD < rhs.costUSD - } - return Model( - points: points, - pointsByDay: pointsByDay, - dayDates: dayDates, - axisDates: axisDates, - peakSpendPoint: (peak?.costUSD ?? 0) > 0 ? peak : nil, - last30: snapshot.last30Days, - historyLabel: snapshot.historyWindowLabel) - } - - private func detail(model: Model) -> (primary: String, secondary: String?) { - let point = self.selectedDay.flatMap { model.pointsByDay[$0] } ?? model.points.last - guard let point else { return ("No selected day", nil) } - let primary = "\(Self.displayDate(point.date)): \(UsageFormatter.usdString(point.costUSD)) · " + - "\(UsageFormatter.tokenCountString(point.totalTokens)) tokens · " + - "\(UsageFormatter.tokenCountString(point.requests)) requests" - let bucket = self.snapshot.daily.first { $0.day == point.day } - let topModel = bucket?.models.first?.name - let topLineItem = bucket?.lineItems.first?.name - let secondary = [topModel.map { "Top model: \($0)" }, topLineItem.map { "Top spend: \($0)" }] - .compactMap(\.self) - .joined(separator: " · ") - return (primary, secondary.isEmpty ? nil : secondary) - } - - private func updateSelection(location: CGPoint?, model: Model, proxy: ChartProxy, geo: GeometryProxy) { - guard let location else { - if self.selectedDay != nil { self.selectedDay = nil } - return - } - guard !model.dayDates.isEmpty else { return } - guard let plotFrame = proxy.plotFrame else { return } - let frame = geo[plotFrame] - guard frame.contains(location) else { return } - let x = location.x - frame.origin.x - guard let date: Date = proxy.value(atX: x) else { return } - self.selectedDay = Self.nearestDay(to: date, in: model.dayDates) - } - - private func selectionBandRect(model: Model, proxy: ChartProxy, geo: GeometryProxy) -> CGRect? { - guard let selectedDay, let selected = model.dayDates.first(where: { $0.day == selectedDay }) else { - return nil - } - guard let plotFrame = proxy.plotFrame else { return nil } - let frame = geo[plotFrame] - guard let x = proxy.position(forX: selected.date) else { return nil } - let width = max(5, frame.width / CGFloat(max(model.dayDates.count, 1))) - return CGRect( - x: frame.origin.x + x - width / 2, - y: frame.origin.y, - width: width, - height: frame.height) - } - - private static func nearestDay(to date: Date, in days: [(day: String, date: Date)]) -> String? { - days.min { - abs($0.date.timeIntervalSince(date)) < abs($1.date.timeIntervalSince(date)) - }?.day - } - - private static func dateFromDayKey(_ key: String) -> Date? { - let parts = key.split(separator: "-") - guard parts.count == 3, - let year = Int(parts[0]), - let month = Int(parts[1]), - let day = Int(parts[2]) - else { return nil } - var comps = DateComponents() - comps.calendar = Calendar.current - comps.timeZone = TimeZone.current - comps.year = year - comps.month = month - comps.day = day - comps.hour = 12 - return comps.date - } - - private static func displayDate(_ date: Date) -> String { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.dateFormat = "MMM d" - return formatter.string(from: date) - } -} - -private struct StatPill: View { - let title: String - let value: String - - var body: some View { - VStack(alignment: .leading, spacing: 2) { - Text(self.title) - .font(.caption2) - .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) - .lineLimit(1) - Text(self.value) - .font(.caption) - .fontWeight(.semibold) - .lineLimit(1) - } - .padding(.vertical, 5) - .padding(.horizontal, 7) - .background(Color(nsColor: .separatorColor).opacity(0.35), in: RoundedRectangle(cornerRadius: 6)) - } -} - -private struct LegendRow: View { - let items: [(Color, String)] - - var body: some View { - HStack(spacing: 10) { - ForEach(self.items, id: \.1) { item in - HStack(spacing: 5) { - Circle() - .fill(item.0) - .frame(width: 7, height: 7) - Text(item.1) - .font(.caption2) - .foregroundStyle(.secondary) - } - } - Spacer(minLength: 0) - } - } -} diff --git a/Sources/CodexBar/PreferencesGeneralPane.swift b/Sources/CodexBar/PreferencesGeneralPane.swift index 186e6f479..c4ba6a890 100644 --- a/Sources/CodexBar/PreferencesGeneralPane.swift +++ b/Sources/CodexBar/PreferencesGeneralPane.swift @@ -204,8 +204,9 @@ struct GeneralPane: View { } if let snapshot = self.store.tokenSnapshot(for: provider) { let updated = UsageFormatter.updatedString(from: snapshot.updatedAt) - let cost = snapshot.last30DaysCostUSD.map { UsageFormatter.usdString($0) } ?? "—" - let window = snapshot.historyDays == 1 ? "today" : "\(snapshot.historyDays)d" + let cost = snapshot.last30DaysCostUSD + .map { UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) } ?? "—" + let window = snapshot.historyLabel ?? (snapshot.historyDays == 1 ? "today" : "\(snapshot.historyDays)d") return Text(String(format: L("cost_status_snapshot"), name, updated, window, cost)) .font(.footnote) .foregroundStyle(.tertiary) diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index 313e1b331..c10f9c0e1 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -586,7 +586,7 @@ struct ProvidersPane: View { dashboardError = codexProjection.userFacingErrors.dashboard tokenSnapshot = self.store.tokenSnapshot(for: provider) tokenError = self.store.tokenError(for: provider) - } else if provider == .claude || provider == .vertexai { + } else if ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.supportsTokenCost { credits = nil creditsError = nil dashboard = nil diff --git a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift index 17f16df03..65bad39dd 100644 --- a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift +++ b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift @@ -8,7 +8,6 @@ extension StatusItemController { Self.usageBreakdownChartID, Self.creditsHistoryChartID, Self.costHistoryChartID, - Self.openAIAPIUsageChartID, Self.usageHistoryChartID, Self.storageBreakdownID, Self.zaiHourlyUsageChartID, @@ -63,14 +62,6 @@ extension StatusItemController { } else { false } - case Self.openAIAPIUsageChartID: - if let providerRawValue = placeholder.toolTip, - let provider = UsageProvider(rawValue: providerRawValue) - { - self.appendOpenAIAPIUsageChartItem(to: menu, provider: provider, width: width) - } else { - false - } case Self.usageHistoryChartID: if let providerRawValue = placeholder.toolTip, let provider = UsageProvider(rawValue: providerRawValue) @@ -169,7 +160,7 @@ extension StatusItemController { provider: UsageProvider, width: CGFloat) -> Bool { - guard let tokenSnapshot = self.store.tokenSnapshot(for: provider) else { return false } + guard let tokenSnapshot = self.tokenSnapshotForCostHistorySubmenu(provider: provider) else { return false } guard !tokenSnapshot.daily.isEmpty else { return false } if !Self.menuCardRenderingEnabled { @@ -184,7 +175,9 @@ extension StatusItemController { provider: provider, daily: tokenSnapshot.daily, totalCostUSD: tokenSnapshot.last30DaysCostUSD, + currencyCode: tokenSnapshot.currencyCode, historyDays: tokenSnapshot.historyDays, + windowLabel: tokenSnapshot.historyLabel, width: width) let hosting = MenuHostingView(rootView: chartView) let controller = NSHostingController(rootView: chartView) @@ -199,40 +192,6 @@ extension StatusItemController { return true } - @discardableResult - func appendOpenAIAPIUsageChartItem( - to submenu: NSMenu, - provider: UsageProvider, - width: CGFloat) - -> Bool - { - guard provider == .openai, - let snapshot = self.store.snapshot(for: provider)?.openAIAPIUsage, - !snapshot.daily.isEmpty - else { return false } - - if !Self.menuCardRenderingEnabled { - let chartItem = NSMenuItem() - chartItem.isEnabled = true - chartItem.representedObject = Self.openAIAPIUsageChartID - submenu.addItem(chartItem) - return true - } - - let chartView = OpenAIAPIUsageChartMenuView(snapshot: snapshot, width: width) - let hosting = MenuHostingView(rootView: chartView) - let controller = NSHostingController(rootView: chartView) - let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) - hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) - - let chartItem = NSMenuItem() - chartItem.view = hosting - chartItem.isEnabled = true - chartItem.representedObject = Self.openAIAPIUsageChartID - submenu.addItem(chartItem) - return true - } - @discardableResult func appendStorageBreakdownItem( to submenu: NSMenu, diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index e0f241233..b5cbc33a6 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -33,7 +33,6 @@ extension StatusItemController { static let usageBreakdownChartID = "usageBreakdownChart" static let creditsHistoryChartID = "creditsHistoryChart" static let costHistoryChartID = "costHistoryChart" - static let openAIAPIUsageChartID = "openAIAPIUsageChart" static let usageHistoryChartID = "usageHistoryChart" static let storageBreakdownID = "storageBreakdown" @@ -1547,8 +1546,8 @@ extension StatusItemController { } func makeCostHistorySubmenu(provider: UsageProvider, width: CGFloat? = nil) -> NSMenu? { - guard [.codex, .claude, .vertexai, .bedrock].contains(provider) else { return nil } - guard self.store.tokenSnapshot(for: provider)?.daily.isEmpty == false else { return nil } + guard ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.supportsTokenCost else { return nil } + guard self.tokenSnapshotForCostHistorySubmenu(provider: provider)?.daily.isEmpty == false else { return nil } if let width { return self.makeHostedSubviewPlaceholderMenu( chartID: Self.costHistoryChartID, @@ -1558,19 +1557,23 @@ extension StatusItemController { return self.makeHostedSubviewPlaceholderMenu(chartID: Self.costHistoryChartID, provider: provider) } + func tokenSnapshotForCostHistorySubmenu(provider: UsageProvider) -> CostUsageTokenSnapshot? { + let projected = self.store.tokenSnapshot( + fromProviderSnapshot: self.store.snapshot(for: provider), + provider: provider) + if UsageStore.tokenCostRequiresProviderSnapshot(provider) { + return projected + } + return projected ?? self.store.tokenSnapshot(for: provider) + } + func makeOpenAIAPIUsageSubmenu(provider: UsageProvider, width: CGFloat? = nil) -> NSMenu? { guard self.hasOpenAIAPIUsageSubmenu(provider: provider) else { return nil } - if let width { - return self.makeHostedSubviewPlaceholderMenu( - chartID: Self.openAIAPIUsageChartID, - provider: provider, - width: width) - } - return self.makeHostedSubviewPlaceholderMenu(chartID: Self.openAIAPIUsageChartID, provider: provider) + return self.makeCostHistorySubmenu(provider: provider, width: width) } private func hasOpenAIAPIUsageSubmenu(provider: UsageProvider) -> Bool { - provider == .openai && self.store.snapshot(for: provider)?.openAIAPIUsage?.daily.isEmpty == false + provider == .openai && self.tokenSnapshotForCostHistorySubmenu(provider: provider)?.daily.isEmpty == false } func makeStorageBreakdownSubmenu(provider: UsageProvider, width: CGFloat? = nil) -> NSMenu? { diff --git a/Sources/CodexBar/StatusItemController+MenuCardModel.swift b/Sources/CodexBar/StatusItemController+MenuCardModel.swift index 048dc4495..1518d1223 100644 --- a/Sources/CodexBar/StatusItemController+MenuCardModel.swift +++ b/Sources/CodexBar/StatusItemController+MenuCardModel.swift @@ -25,6 +25,10 @@ extension StatusItemController { } else { snapshotOverride ?? self.store.snapshot(for: target) } + let projectedTokenSnapshot = self.store.tokenSnapshot(fromProviderSnapshot: snapshot, provider: target) + let storedTokenSnapshot = UsageStore.tokenCostRequiresProviderSnapshot(target) + ? nil + : self.store.tokenSnapshot(for: target) let now = Date() let codexProjection = self.store.codexConsumerProjectionIfNeeded( for: target, @@ -44,25 +48,27 @@ extension StatusItemController { dashboard = nil dashboardError = codexProjection.userFacingErrors.dashboard if surface == .liveCard { - tokenSnapshot = self.store.tokenSnapshot(for: target) + tokenSnapshot = projectedTokenSnapshot ?? storedTokenSnapshot tokenError = self.store.tokenError(for: target) } else { - tokenSnapshot = nil + tokenSnapshot = projectedTokenSnapshot tokenError = nil } - } else if target == .claude || target == .vertexai || target == .bedrock, snapshotOverride == nil { + } else if ProviderDescriptorRegistry.descriptor(for: target).tokenCost.supportsTokenCost, + snapshotOverride == nil + { credits = nil creditsError = nil dashboard = nil dashboardError = nil - tokenSnapshot = self.store.tokenSnapshot(for: target) + tokenSnapshot = projectedTokenSnapshot ?? storedTokenSnapshot tokenError = self.store.tokenError(for: target) } else { credits = nil creditsError = nil dashboard = nil dashboardError = nil - tokenSnapshot = nil + tokenSnapshot = projectedTokenSnapshot tokenError = nil } diff --git a/Sources/CodexBar/UsageStore+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index 190d4d5ea..bf73bb84e 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -26,30 +26,7 @@ extension UsageStore { let codexExpectedGuard = provider == .codex ? self.currentCodexAccountScopedRefreshGuard() : nil if !spec.isEnabled(), !allowDisabled { - self.refreshingProviders.remove(provider) - await MainActor.run { - self.snapshots.removeValue(forKey: provider) - self.lastKnownResetSnapshots.removeValue(forKey: provider) - self.errors[provider] = nil - self.lastSourceLabels.removeValue(forKey: provider) - self.lastFetchAttempts.removeValue(forKey: provider) - self.accountSnapshots.removeValue(forKey: provider) - if provider == .codex { - self.codexAccountSnapshots = [] - } - if provider == .kilo { - self.kiloScopeSnapshots = [] - } - self.tokenSnapshots.removeValue(forKey: provider) - self.tokenErrors[provider] = nil - self.failureGates[provider]?.reset() - self.tokenFailureGates[provider]?.reset() - self.statuses.removeValue(forKey: provider) - self.lastKnownSessionRemaining.removeValue(forKey: provider) - self.lastKnownSessionWindowSource.removeValue(forKey: provider) - self.quotaWarningState = self.quotaWarningState.filter { $0.key.provider != provider } - self.lastTokenFetchAt.removeValue(forKey: provider) - } + await self.clearDisabledProviderRefreshState(provider) return } @@ -133,6 +110,14 @@ extension UsageStore { self.handleSessionQuotaTransition(provider: provider, snapshot: backfilled) self.lastKnownResetSnapshots[provider] = backfilled self.snapshots[provider] = backfilled + if let tokenSnapshot = self.tokenSnapshot(fromProviderSnapshot: backfilled, provider: provider) { + self.tokenSnapshots[provider] = tokenSnapshot + self.tokenErrors[provider] = nil + self.tokenFailureGates[provider]?.recordSuccess() + } else if Self.tokenCostRequiresProviderSnapshot(provider) { + self.tokenSnapshots.removeValue(forKey: provider) + self.tokenErrors[provider] = nil + } self.lastSourceLabels[provider] = result.sourceLabel self.errors[provider] = nil self.failureGates[provider]?.recordSuccess() @@ -173,6 +158,33 @@ extension UsageStore { } } + private func clearDisabledProviderRefreshState(_ provider: UsageProvider) async { + self.refreshingProviders.remove(provider) + await MainActor.run { + self.snapshots.removeValue(forKey: provider) + self.lastKnownResetSnapshots.removeValue(forKey: provider) + self.errors[provider] = nil + self.lastSourceLabels.removeValue(forKey: provider) + self.lastFetchAttempts.removeValue(forKey: provider) + self.accountSnapshots.removeValue(forKey: provider) + if provider == .codex { + self.codexAccountSnapshots = [] + } + if provider == .kilo { + self.kiloScopeSnapshots = [] + } + self.tokenSnapshots.removeValue(forKey: provider) + self.tokenErrors[provider] = nil + self.failureGates[provider]?.reset() + self.tokenFailureGates[provider]?.reset() + self.statuses.removeValue(forKey: provider) + self.lastKnownSessionRemaining.removeValue(forKey: provider) + self.lastKnownSessionWindowSource.removeValue(forKey: provider) + self.quotaWarningState = self.quotaWarningState.filter { $0.key.provider != provider } + self.lastTokenFetchAt.removeValue(forKey: provider) + } + } + private struct ClaudeRefreshAuthState { let fingerprintToken: String let credentialsFileChanged: Bool diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index d9317b0df..13c162ed9 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -30,6 +30,30 @@ extension UsageStore { return (homePath, "codex:managed:\(homePath)") } + func tokenSnapshot( + fromProviderSnapshot snapshot: UsageSnapshot?, + provider: UsageProvider) + -> CostUsageTokenSnapshot? + { + switch provider { + case .openai: + snapshot?.openAIAPIUsage?.toCostUsageTokenSnapshot() + case .mistral: + snapshot?.mistralUsage?.toCostUsageTokenSnapshot(historyDays: self.settings.costUsageHistoryDays) + default: + nil + } + } + + nonisolated static func tokenCostRequiresProviderSnapshot(_ provider: UsageProvider) -> Bool { + switch provider { + case .mistral, .openai: + true + default: + false + } + } + nonisolated static func costUsageCacheDirectory( fileManager: FileManager = .default) -> URL { diff --git a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift index e0808f9ce..e71483ead 100644 --- a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift +++ b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift @@ -36,7 +36,8 @@ extension UsageStore { private func makeWidgetEntry(for provider: UsageProvider) -> WidgetSnapshot.ProviderEntry? { guard let snapshot = self.snapshots[provider] else { return nil } - let tokenSnapshot = self.tokenSnapshots[provider] + let tokenSnapshot = self.tokenSnapshot(fromProviderSnapshot: snapshot, provider: provider) ?? self + .tokenSnapshots[provider] let dailyUsage = tokenSnapshot?.daily.map { entry in WidgetSnapshot.DailyUsagePoint( dayKey: entry.date, @@ -44,7 +45,7 @@ extension UsageStore { costUSD: entry.costUSD) } ?? [] - let tokenUsage = Self.widgetTokenUsageSummary(from: tokenSnapshot) + let tokenUsage = Self.widgetTokenUsageSummary(from: tokenSnapshot, provider: provider) let usageRows = self.widgetUsageRows(provider: provider, snapshot: snapshot) let creditsRemaining: Double? @@ -76,16 +77,22 @@ extension UsageStore { } private nonisolated static func widgetTokenUsageSummary( - from snapshot: CostUsageTokenSnapshot?) -> WidgetSnapshot.TokenUsageSummary? + from snapshot: CostUsageTokenSnapshot?, + provider: UsageProvider) -> WidgetSnapshot.TokenUsageSummary? { guard let snapshot else { return nil } let fallbackTokens = snapshot.daily.compactMap(\.totalTokens).reduce(0, +) let monthTokensValue = snapshot.last30DaysTokens ?? (fallbackTokens > 0 ? fallbackTokens : nil) + let sessionLabel = provider == .bedrock || provider == .mistral ? "Latest billing day" : "Today" + let monthLabel = snapshot.historyLabel ?? (snapshot.historyDays == 1 ? "Today" : "\(snapshot.historyDays)d") return WidgetSnapshot.TokenUsageSummary( sessionCostUSD: snapshot.sessionCostUSD, sessionTokens: snapshot.sessionTokens, last30DaysCostUSD: snapshot.last30DaysCostUSD, - last30DaysTokens: monthTokensValue) + last30DaysTokens: monthTokensValue, + currencyCode: snapshot.currencyCode, + sessionLabel: sessionLabel, + last30DaysLabel: monthLabel) } private func widgetUsageRows( diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 0bc6f514f..d2c35695d 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1481,7 +1481,7 @@ extension UsageStore { } private func refreshTokenUsage(_ provider: UsageProvider, force: Bool) async { - guard provider == .codex || provider == .claude || provider == .vertexai || provider == .bedrock else { + guard ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.supportsTokenCost else { self.tokenSnapshots.removeValue(forKey: provider) self.tokenErrors[provider] = nil self.tokenFailureGates[provider]?.reset() @@ -1495,6 +1495,20 @@ extension UsageStore { return } + if Self.tokenCostRequiresProviderSnapshot(provider) { + if let snapshot = self.tokenSnapshot(fromProviderSnapshot: self.snapshots[provider], provider: provider) { + self.tokenSnapshots[provider] = snapshot + self.tokenErrors[provider] = nil + self.tokenFailureGates[provider]?.recordSuccess() + self.persistWidgetSnapshot(reason: "token-usage") + } else { + self.tokenSnapshots.removeValue(forKey: provider) + self.tokenErrors[provider] = nil + self.tokenFailureGates[provider]?.reset() + } + return + } + guard self.settings.costUsageEnabled else { self.tokenSnapshots.removeValue(forKey: provider) self.tokenErrors[provider] = nil @@ -1546,9 +1560,9 @@ extension UsageStore { settings: self.settings, tokenOverride: nil) : self.environmentBase - // CostUsageFetcher scans local Codex session logs from this machine. That data is + // Codex cost usage scans local session logs from this machine. That data is // intentionally presented as provider-level local telemetry rather than managed-account - // remote state, so managed Codex account selection does not retarget this fetch. + // remote state, so managed Codex account selection does not retarget that fetch. // If the UI later needs account-scoped token history, it should label and source that // separately instead of silently changing the meaning of this section. let snapshot = try await withThrowingTaskGroup(of: CostUsageTokenSnapshot.self) { group in @@ -1578,8 +1592,10 @@ extension UsageStore { return } let duration = Date().timeIntervalSince(startedAt) - let sessionCost = snapshot.sessionCostUSD.map(UsageFormatter.usdString) ?? "—" - let monthCost = snapshot.last30DaysCostUSD.map(UsageFormatter.usdString) ?? "—" + let sessionCost = snapshot.sessionCostUSD + .map { UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) } ?? "—" + let monthCost = snapshot.last30DaysCostUSD + .map { UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) } ?? "—" let durationText = String(format: "%.2f", duration) let message = "cost usage success provider=\(providerText) " + diff --git a/Sources/CodexBarCLI/CLICostCommand.swift b/Sources/CodexBarCLI/CLICostCommand.swift index 9b503e19d..7f63b2412 100644 --- a/Sources/CodexBarCLI/CLICostCommand.swift +++ b/Sources/CodexBarCLI/CLICostCommand.swift @@ -84,13 +84,16 @@ extension CodexBarCLI { let name = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName let header = Self.costHeaderLine("\(name) Cost (API-rate estimate)", useColor: useColor) - let todayCost = snapshot.sessionCostUSD.map { UsageFormatter.usdString($0) } ?? "—" + let todayCost = snapshot.sessionCostUSD + .map { UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) } ?? "—" let todayTokens = snapshot.sessionTokens.map { UsageFormatter.tokenCountString($0) } let todayLine = todayTokens.map { "Today: \(todayCost) · \($0) tokens" } ?? "Today: \(todayCost)" - let monthCost = snapshot.last30DaysCostUSD.map { UsageFormatter.usdString($0) } ?? "—" + let monthCost = snapshot.last30DaysCostUSD + .map { UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) } ?? "—" let monthTokens = snapshot.last30DaysTokens.map { UsageFormatter.tokenCountString($0) } - let historyLabel = snapshot.historyDays == 1 ? "Today" : "Last \(snapshot.historyDays) days" + let historyLabel = snapshot.historyLabel + ?? (snapshot.historyDays == 1 ? "Today" : "Last \(snapshot.historyDays) days") let monthLine = monthTokens.map { "\(historyLabel): \(monthCost) · \($0) tokens" } ?? "\(historyLabel): \(monthCost)" @@ -135,6 +138,7 @@ extension CodexBarCLI { provider: provider.rawValue, source: "local", updatedAt: snapshot?.updatedAt ?? (error == nil ? nil : Date()), + currencyCode: snapshot?.currencyCode, sessionTokens: snapshot?.sessionTokens, sessionCostUSD: snapshot?.sessionCostUSD, historyDays: snapshot?.historyDays, @@ -257,6 +261,7 @@ struct CostPayload: Encodable { let provider: String let source: String let updatedAt: Date? + let currencyCode: String? let sessionTokens: Int? let sessionCostUSD: Double? let historyDays: Int? @@ -265,6 +270,34 @@ struct CostPayload: Encodable { let daily: [CostDailyEntryPayload] let totals: CostTotalsPayload? let error: ProviderErrorPayload? + + init( + provider: String, + source: String, + updatedAt: Date?, + currencyCode: String? = nil, + sessionTokens: Int?, + sessionCostUSD: Double?, + historyDays: Int?, + last30DaysTokens: Int?, + last30DaysCostUSD: Double?, + daily: [CostDailyEntryPayload], + totals: CostTotalsPayload?, + error: ProviderErrorPayload?) + { + self.provider = provider + self.source = source + self.updatedAt = updatedAt + self.currencyCode = currencyCode + self.sessionTokens = sessionTokens + self.sessionCostUSD = sessionCostUSD + self.historyDays = historyDays + self.last30DaysTokens = last30DaysTokens + self.last30DaysCostUSD = last30DaysCostUSD + self.daily = daily + self.totals = totals + self.error = error + } } struct CostDailyEntryPayload: Encodable { diff --git a/Sources/CodexBarCore/CostUsageModels.swift b/Sources/CodexBarCore/CostUsageModels.swift index 072149362..0a891b34f 100644 --- a/Sources/CodexBarCore/CostUsageModels.swift +++ b/Sources/CodexBarCore/CostUsageModels.swift @@ -3,26 +3,40 @@ import Foundation public struct CostUsageTokenSnapshot: Sendable, Equatable { public let sessionTokens: Int? public let sessionCostUSD: Double? + public let sessionRequests: Int? public let last30DaysTokens: Int? public let last30DaysCostUSD: Double? + public let last30DaysRequests: Int? + public let currencyCode: String public let historyDays: Int + public let historyLabel: String? public let daily: [CostUsageDailyReport.Entry] public let updatedAt: Date public init( sessionTokens: Int?, sessionCostUSD: Double?, + sessionRequests: Int? = nil, last30DaysTokens: Int?, last30DaysCostUSD: Double?, + last30DaysRequests: Int? = nil, + currencyCode: String = "USD", historyDays: Int = 30, + historyLabel: String? = nil, daily: [CostUsageDailyReport.Entry], updatedAt: Date) { self.sessionTokens = sessionTokens self.sessionCostUSD = sessionCostUSD + self.sessionRequests = sessionRequests self.last30DaysTokens = last30DaysTokens self.last30DaysCostUSD = last30DaysCostUSD + self.last30DaysRequests = last30DaysRequests + self.currencyCode = currencyCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? "USD" + : currencyCode.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() self.historyDays = historyDays + self.historyLabel = historyLabel self.daily = daily self.updatedAt = updatedAt } @@ -33,6 +47,7 @@ public struct CostUsageDailyReport: Sendable, Decodable { public let modelName: String public let costUSD: Double? public let totalTokens: Int? + public let requestCount: Int? public let standardCostUSD: Double? public let priorityCostUSD: Double? public let standardTokens: Int? @@ -43,6 +58,8 @@ public struct CostUsageDailyReport: Sendable, Decodable { case costUSD case cost case totalTokens + case requestCount + case requests case standardCostUSD case priorityCostUSD case standardTokens @@ -56,6 +73,9 @@ public struct CostUsageDailyReport: Sendable, Decodable { try container.decodeIfPresent(Double.self, forKey: .costUSD) ?? container.decodeIfPresent(Double.self, forKey: .cost) self.totalTokens = try container.decodeIfPresent(Int.self, forKey: .totalTokens) + self.requestCount = + try container.decodeIfPresent(Int.self, forKey: .requestCount) + ?? container.decodeIfPresent(Int.self, forKey: .requests) self.standardCostUSD = try container.decodeIfPresent(Double.self, forKey: .standardCostUSD) self.priorityCostUSD = try container.decodeIfPresent(Double.self, forKey: .priorityCostUSD) self.standardTokens = try container.decodeIfPresent(Int.self, forKey: .standardTokens) @@ -66,6 +86,7 @@ public struct CostUsageDailyReport: Sendable, Decodable { modelName: String, costUSD: Double?, totalTokens: Int? = nil, + requestCount: Int? = nil, standardCostUSD: Double? = nil, priorityCostUSD: Double? = nil, standardTokens: Int? = nil, @@ -74,6 +95,7 @@ public struct CostUsageDailyReport: Sendable, Decodable { self.modelName = modelName self.costUSD = costUSD self.totalTokens = totalTokens + self.requestCount = requestCount self.standardCostUSD = standardCostUSD self.priorityCostUSD = priorityCostUSD self.standardTokens = standardTokens @@ -88,6 +110,7 @@ public struct CostUsageDailyReport: Sendable, Decodable { public let cacheCreationTokens: Int? public let outputTokens: Int? public let totalTokens: Int? + public let requestCount: Int? public let costUSD: Double? public let modelsUsed: [String]? public let modelBreakdowns: [ModelBreakdown]? @@ -101,6 +124,8 @@ public struct CostUsageDailyReport: Sendable, Decodable { case cacheCreationInputTokens case outputTokens case totalTokens + case requestCount + case requests case costUSD case totalCost case modelsUsed @@ -120,6 +145,9 @@ public struct CostUsageDailyReport: Sendable, Decodable { ?? container.decodeIfPresent(Int.self, forKey: .cacheCreationInputTokens) self.outputTokens = try container.decodeIfPresent(Int.self, forKey: .outputTokens) self.totalTokens = try container.decodeIfPresent(Int.self, forKey: .totalTokens) + self.requestCount = + try container.decodeIfPresent(Int.self, forKey: .requestCount) + ?? container.decodeIfPresent(Int.self, forKey: .requests) self.costUSD = try container.decodeIfPresent(Double.self, forKey: .costUSD) ?? container.decodeIfPresent(Double.self, forKey: .totalCost) @@ -134,6 +162,7 @@ public struct CostUsageDailyReport: Sendable, Decodable { cacheReadTokens: Int? = nil, cacheCreationTokens: Int? = nil, totalTokens: Int?, + requestCount: Int? = nil, costUSD: Double?, modelsUsed: [String]?, modelBreakdowns: [ModelBreakdown]?) @@ -144,6 +173,7 @@ public struct CostUsageDailyReport: Sendable, Decodable { self.cacheReadTokens = cacheReadTokens self.cacheCreationTokens = cacheCreationTokens self.totalTokens = totalTokens + self.requestCount = requestCount self.costUSD = costUSD self.modelsUsed = modelsUsed self.modelBreakdowns = modelBreakdowns diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift b/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift index 7d27f365e..1470a7688 100644 --- a/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift +++ b/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift @@ -207,4 +207,44 @@ public struct MistralUsageSnapshot: Codable, Sendable { updatedAt: self.updatedAt, identity: identity) } + + public func toCostUsageTokenSnapshot(historyDays: Int = 30) -> CostUsageTokenSnapshot { + let clampedHistoryDays = max(1, min(365, historyDays)) + let selected = self.daily + let entries = selected.map { bucket in + let modelBreakdowns = bucket.models.map { + CostUsageDailyReport.ModelBreakdown( + modelName: $0.name, + costUSD: max($0.cost, 0), + totalTokens: $0.totalTokens) + } + let modelsUsed = bucket.models.map(\.name) + return CostUsageDailyReport.Entry( + date: bucket.day, + inputTokens: bucket.inputTokens, + outputTokens: bucket.outputTokens, + cacheReadTokens: bucket.cachedTokens, + cacheCreationTokens: nil, + totalTokens: bucket.totalTokens, + costUSD: max(bucket.cost, 0), + modelsUsed: modelsUsed.isEmpty ? nil : modelsUsed, + modelBreakdowns: modelBreakdowns.isEmpty ? nil : modelBreakdowns) + } + let latest = selected.last + let totalCost = max(self.totalCost, 0) + let totalTokens = selected.isEmpty + ? self.totalInputTokens + self.totalCachedTokens + self.totalOutputTokens + : selected.reduce(0) { $0 + $1.totalTokens } + let tokens = totalTokens > 0 ? totalTokens : nil + return CostUsageTokenSnapshot( + sessionTokens: latest?.totalTokens, + sessionCostUSD: latest.map { max($0.cost, 0) }, + last30DaysTokens: tokens, + last30DaysCostUSD: totalCost, + currencyCode: self.currency, + historyDays: selected.isEmpty ? clampedHistoryDays : max(1, min(365, selected.count)), + historyLabel: "This month", + daily: entries, + updatedAt: self.updatedAt) + } } diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Mistral/MistralProviderDescriptor.swift index 914a2c5e8..b1af52984 100644 --- a/Sources/CodexBarCore/Providers/Mistral/MistralProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Mistral/MistralProviderDescriptor.swift @@ -30,8 +30,8 @@ public enum MistralProviderDescriptor { iconResourceName: "ProviderIcon-mistral", color: ProviderColor(red: 255 / 255, green: 80 / 255, blue: 15 / 255)), tokenCost: ProviderTokenCostConfig( - supportsTokenCost: false, - noDataMessage: { "Mistral cost summary is not yet supported." }), + supportsTokenCost: true, + noDataMessage: { "Mistral cost history needs a billing web session." }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .web], pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [MistralWebFetchStrategy()] })), diff --git a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIProviderDescriptor.swift b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIProviderDescriptor.swift index e9c6c8b88..eadd2539f 100644 --- a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIProviderDescriptor.swift @@ -28,7 +28,7 @@ public enum OpenAIAPIProviderDescriptor { iconResourceName: "ProviderIcon-codex", color: ProviderColor(red: 0.06, green: 0.51, blue: 0.43)), tokenCost: ProviderTokenCostConfig( - supportsTokenCost: false, + supportsTokenCost: true, noDataMessage: { "OpenAI usage needs an Admin API key for organization usage." }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .api], diff --git a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageSnapshot.swift b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageSnapshot.swift index 095214560..4bd38d1ab 100644 --- a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageSnapshot.swift @@ -204,6 +204,42 @@ public struct OpenAIAPIUsageSnapshot: Codable, Equatable, Sendable { loginMethod: "Admin API")) } + public func toCostUsageTokenSnapshot() -> CostUsageTokenSnapshot { + let daily = self.daily.map { bucket in + let modelBreakdowns = bucket.models.map { + CostUsageDailyReport.ModelBreakdown( + modelName: $0.name, + costUSD: nil, + totalTokens: $0.totalTokens, + requestCount: $0.requests) + } + let modelsUsed = bucket.models.map(\.name) + return CostUsageDailyReport.Entry( + date: bucket.day, + inputTokens: bucket.inputTokens, + outputTokens: bucket.outputTokens, + cacheReadTokens: bucket.cachedInputTokens, + cacheCreationTokens: nil, + totalTokens: bucket.totalTokens, + requestCount: bucket.requests, + costUSD: bucket.costUSD, + modelsUsed: modelsUsed.isEmpty ? nil : modelsUsed, + modelBreakdowns: modelBreakdowns.isEmpty ? nil : modelBreakdowns) + } + let latest = self.latestDay + let total = self.last30Days + return CostUsageTokenSnapshot( + sessionTokens: latest.totalTokens, + sessionCostUSD: latest.costUSD, + sessionRequests: latest.requests, + last30DaysTokens: total.totalTokens, + last30DaysCostUSD: total.costUSD, + last30DaysRequests: total.requests, + historyDays: self.historyDays, + daily: daily, + updatedAt: self.updatedAt) + } + private struct ModelAccumulator { var requests = 0 var inputTokens = 0 diff --git a/Sources/CodexBarCore/UsageFormatter.swift b/Sources/CodexBarCore/UsageFormatter.swift index 74485076e..9f0b1b641 100644 --- a/Sources/CodexBarCore/UsageFormatter.swift +++ b/Sources/CodexBarCore/UsageFormatter.swift @@ -326,11 +326,16 @@ public enum UsageFormatter { return cleaned.isEmpty ? raw : cleaned } - public static func modelCostDetail(_ model: String, costUSD: Double?, totalTokens: Int? = nil) -> String? { + public static func modelCostDetail( + _ model: String, + costUSD: Double?, + totalTokens: Int? = nil, + currencyCode: String = "USD") -> String? + { let costDetail: String? = if let label = CostUsagePricing.codexDisplayLabel(model: model) { label } else if let costUSD { - self.usdString(costUSD) + self.currencyString(costUSD, currencyCode: currencyCode) } else { nil } diff --git a/Sources/CodexBarCore/WidgetSnapshot.swift b/Sources/CodexBarCore/WidgetSnapshot.swift index d87c4dcca..17affa8b1 100644 --- a/Sources/CodexBarCore/WidgetSnapshot.swift +++ b/Sources/CodexBarCore/WidgetSnapshot.swift @@ -55,17 +55,54 @@ public struct WidgetSnapshot: Codable, Sendable { public let sessionTokens: Int? public let last30DaysCostUSD: Double? public let last30DaysTokens: Int? + public let currencyCode: String + public let sessionLabel: String + public let last30DaysLabel: String public init( sessionCostUSD: Double?, sessionTokens: Int?, last30DaysCostUSD: Double?, - last30DaysTokens: Int?) + last30DaysTokens: Int?, + currencyCode: String = "USD", + sessionLabel: String = "Today", + last30DaysLabel: String = "30d") { self.sessionCostUSD = sessionCostUSD self.sessionTokens = sessionTokens self.last30DaysCostUSD = last30DaysCostUSD self.last30DaysTokens = last30DaysTokens + self.currencyCode = currencyCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? "USD" + : currencyCode.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + self.sessionLabel = sessionLabel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? "Today" + : sessionLabel + self.last30DaysLabel = last30DaysLabel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? "30d" + : last30DaysLabel + } + + private enum CodingKeys: String, CodingKey { + case sessionCostUSD + case sessionTokens + case last30DaysCostUSD + case last30DaysTokens + case currencyCode + case sessionLabel + case last30DaysLabel + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + try self.init( + sessionCostUSD: container.decodeIfPresent(Double.self, forKey: .sessionCostUSD), + sessionTokens: container.decodeIfPresent(Int.self, forKey: .sessionTokens), + last30DaysCostUSD: container.decodeIfPresent(Double.self, forKey: .last30DaysCostUSD), + last30DaysTokens: container.decodeIfPresent(Int.self, forKey: .last30DaysTokens), + currencyCode: container.decodeIfPresent(String.self, forKey: .currencyCode) ?? "USD", + sessionLabel: container.decodeIfPresent(String.self, forKey: .sessionLabel) ?? "Today", + last30DaysLabel: container.decodeIfPresent(String.self, forKey: .last30DaysLabel) ?? "30d") } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index 17699f9e3..83d039398 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -184,13 +184,19 @@ private struct CompactMetricView: View { let value = self.entry.creditsRemaining.map(WidgetFormat.credits) ?? "—" return (value, "Credits left", nil) case .todayCost: - let value = self.entry.tokenUsage?.sessionCostUSD.map(WidgetFormat.usd) ?? "—" + let value = self.entry.tokenUsage.map { token in + token.sessionCostUSD.map { WidgetFormat.currency($0, code: token.currencyCode) } ?? "—" + } ?? "—" let detail = self.entry.tokenUsage?.sessionTokens.map(WidgetFormat.tokenCount) - return (value, "Today cost", detail) + let label = self.entry.tokenUsage.map { "\($0.sessionLabel) cost" } ?? "Today cost" + return (value, label, detail) case .last30DaysCost: - let value = self.entry.tokenUsage?.last30DaysCostUSD.map(WidgetFormat.usd) ?? "—" + let value = self.entry.tokenUsage.map { token in + token.last30DaysCostUSD.map { WidgetFormat.currency($0, code: token.currencyCode) } ?? "—" + } ?? "—" let detail = self.entry.tokenUsage?.last30DaysTokens.map(WidgetFormat.tokenCount) - return (value, "30d cost", detail) + let label = self.entry.tokenUsage.map { "\($0.last30DaysLabel) cost" } ?? "30d cost" + return (value, label, detail) } } } @@ -346,8 +352,11 @@ private struct SwitcherMediumUsageView: View { } if let token = entry.tokenUsage { ValueLine( - title: "Today", - value: WidgetFormat.costAndTokens(cost: token.sessionCostUSD, tokens: token.sessionTokens)) + title: token.sessionLabel, + value: WidgetFormat.costAndTokens( + cost: token.sessionCostUSD, + tokens: token.sessionTokens, + currencyCode: token.currencyCode)) } } } @@ -376,13 +385,17 @@ private struct SwitcherLargeUsageView: View { if let token = entry.tokenUsage { VStack(alignment: .leading, spacing: 4) { ValueLine( - title: "Today", - value: WidgetFormat.costAndTokens(cost: token.sessionCostUSD, tokens: token.sessionTokens)) + title: token.sessionLabel, + value: WidgetFormat.costAndTokens( + cost: token.sessionCostUSD, + tokens: token.sessionTokens, + currencyCode: token.currencyCode)) ValueLine( - title: "30d", + title: token.last30DaysLabel, value: WidgetFormat.costAndTokens( cost: token.last30DaysCostUSD, - tokens: token.last30DaysTokens)) + tokens: token.last30DaysTokens, + currencyCode: token.currencyCode)) } } UsageHistoryChart(points: self.entry.dailyUsage, color: WidgetColors.color(for: self.entry.provider)) @@ -431,8 +444,11 @@ private struct MediumUsageView: View { } if let token = entry.tokenUsage { ValueLine( - title: "Today", - value: WidgetFormat.costAndTokens(cost: token.sessionCostUSD, tokens: token.sessionTokens)) + title: token.sessionLabel, + value: WidgetFormat.costAndTokens( + cost: token.sessionCostUSD, + tokens: token.sessionTokens, + currencyCode: token.currencyCode)) } } .padding(12) @@ -463,13 +479,17 @@ private struct LargeUsageView: View { if let token = entry.tokenUsage { VStack(alignment: .leading, spacing: 4) { ValueLine( - title: "Today", - value: WidgetFormat.costAndTokens(cost: token.sessionCostUSD, tokens: token.sessionTokens)) + title: token.sessionLabel, + value: WidgetFormat.costAndTokens( + cost: token.sessionCostUSD, + tokens: token.sessionTokens, + currencyCode: token.currencyCode)) ValueLine( - title: "30d", + title: token.last30DaysLabel, value: WidgetFormat.costAndTokens( cost: token.last30DaysCostUSD, - tokens: token.last30DaysTokens)) + tokens: token.last30DaysTokens, + currencyCode: token.currencyCode)) } } UsageHistoryChart(points: self.entry.dailyUsage, color: WidgetColors.color(for: self.entry.provider)) @@ -516,11 +536,17 @@ private struct HistoryView: View { .frame(height: self.isLarge ? 90 : 60) if let token = entry.tokenUsage { ValueLine( - title: "Today", - value: WidgetFormat.costAndTokens(cost: token.sessionCostUSD, tokens: token.sessionTokens)) + title: token.sessionLabel, + value: WidgetFormat.costAndTokens( + cost: token.sessionCostUSD, + tokens: token.sessionTokens, + currencyCode: token.currencyCode)) ValueLine( - title: "30d", - value: WidgetFormat.costAndTokens(cost: token.last30DaysCostUSD, tokens: token.last30DaysTokens)) + title: token.last30DaysLabel, + value: WidgetFormat.costAndTokens( + cost: token.last30DaysCostUSD, + tokens: token.last30DaysTokens, + currencyCode: token.currencyCode)) } } .padding(12) @@ -726,21 +752,21 @@ enum WidgetFormat { return formatter.string(from: NSNumber(value: value)) ?? String(format: "%.2f", value) } - static func costAndTokens(cost: Double?, tokens: Int?) -> String { - let costText = cost.map(self.usd) ?? "—" + static func costAndTokens(cost: Double?, tokens: Int?, currencyCode: String = "USD") -> String { + let costText = cost.map { self.currency($0, code: currencyCode) } ?? "—" if let tokens { return "\(costText) · \(self.tokenCount(tokens))" } return costText } - static func usd(_ value: Double) -> String { + static func currency(_ value: Double, code: String) -> String { let formatter = NumberFormatter() formatter.numberStyle = .currency - formatter.currencyCode = "USD" + formatter.currencyCode = code formatter.maximumFractionDigits = 2 formatter.minimumFractionDigits = 2 - return formatter.string(from: NSNumber(value: value)) ?? String(format: "$%.2f", value) + return formatter.string(from: NSNumber(value: value)) ?? "\(code) \(String(format: "%.2f", value))" } static func tokenCount(_ value: Int) -> String { diff --git a/Tests/CodexBarTests/InlineCostHistoryDashboardLabelTests.swift b/Tests/CodexBarTests/InlineCostHistoryDashboardLabelTests.swift index 8df8e8680..2584e5dfe 100644 --- a/Tests/CodexBarTests/InlineCostHistoryDashboardLabelTests.swift +++ b/Tests/CodexBarTests/InlineCostHistoryDashboardLabelTests.swift @@ -72,4 +72,53 @@ struct InlineCostHistoryDashboardLabelTests { #expect(thirtyDays.inlineUsageDashboard?.kpis[1].title == "30d cost") #expect(thirtyDays.inlineUsageDashboard?.kpis[2].title == "30d tokens") } + + @Test + func `custom cost history KPI title keeps token label distinct`() throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let metadata = try #require(ProviderDefaults.metadata[.claude]) + let tokenSnapshot = CostUsageTokenSnapshot( + sessionTokens: 275, + sessionCostUSD: 0.25, + last30DaysTokens: 425, + last30DaysCostUSD: 0.37, + historyLabel: "This month", + daily: [ + CostUsageDailyReport.Entry( + date: "2023-11-15", + inputTokens: 200, + outputTokens: 75, + totalTokens: 275, + costUSD: 0.25, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: now), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: tokenSnapshot, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.inlineUsageDashboard?.kpis[1].title == "This month") + #expect(model.inlineUsageDashboard?.kpis[2].title == "This month tokens") + } } diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift index e1f19b3c1..dc823cea5 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -59,70 +59,6 @@ struct OverviewMenuCardVisibilityTests { } } -struct OpenAIAPIMenuCardModelTests { - @Test - func `admin usage model shows summaries and spend without fake quota bars`() throws { - let now = Date(timeIntervalSince1970: 1_700_179_200) - let metadata = try #require(ProviderDefaults.metadata[.openai]) - let apiUsage = OpenAIAPIUsageSnapshot( - daily: [ - OpenAIAPIUsageSnapshot.DailyBucket( - day: "2023-11-14", - startTime: now, - endTime: now.addingTimeInterval(86400), - costUSD: 12.5, - requests: 40, - inputTokens: 1000, - cachedInputTokens: 250, - outputTokens: 500, - totalTokens: 1500, - lineItems: [ - OpenAIAPIUsageSnapshot.LineItemBreakdown(name: "Text tokens", costUSD: 12.5), - ], - models: [ - OpenAIAPIUsageSnapshot.ModelBreakdown( - name: "gpt-5.2", - requests: 40, - inputTokens: 1000, - cachedInputTokens: 250, - outputTokens: 500, - totalTokens: 1500), - ]), - ], - updatedAt: now) - - let model = UsageMenuCardView.Model.make(.init( - provider: .openai, - metadata: metadata, - snapshot: apiUsage.toUsageSnapshot(), - credits: nil, - creditsError: nil, - dashboard: nil, - dashboardError: nil, - tokenSnapshot: nil, - tokenError: nil, - account: AccountInfo(email: nil, plan: nil), - isRefreshing: false, - lastError: nil, - usageBarsShowUsed: false, - resetTimeDisplayStyle: .countdown, - tokenCostUsageEnabled: false, - showOptionalCreditsAndExtraUsage: true, - hidePersonalInfo: false, - now: now)) - - #expect(model.metrics.isEmpty) - #expect(model.openAIAPIUsage != nil) - #expect(model.inlineUsageDashboard?.kpis.first?.value == "$12.50") - #expect(model.inlineUsageDashboard?.points.count == 1) - #expect(model.providerCost == nil) - #expect(model.usageNotes.contains { $0.contains("Today: $12.50") }) - #expect(model.usageNotes.contains("Top model: gpt-5.2")) - #expect(model.creditsText == nil) - #expect(model.planText == "Admin API") - } -} - struct ProviderInlineDashboardModelTests { @Test func `claude admin api usage gets inline dashboard`() throws { @@ -349,11 +285,69 @@ struct ProviderInlineDashboardModelTests { hidePersonalInfo: false, now: now)) - #expect(model.inlineUsageDashboard?.kpis.first?.value == "€1.5000") - #expect(model.inlineUsageDashboard?.points.first?.accessibilityValue == "2023-11-14: €1.5000") + #expect(model.inlineUsageDashboard?.kpis.first?.value == "€1.50") + #expect(model.inlineUsageDashboard?.points.first?.accessibilityValue == "2023-11-14: €1.50") #expect(model.inlineUsageDashboard?.detailLines.contains("Top model: mistral-large") == true) } + @Test + func `mistral billing usage can show cost card summary`() throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let metadata = try #require(ProviderDefaults.metadata[.mistral]) + let snapshot = MistralUsageSnapshot( + totalCost: 1.5, + currency: "EUR", + currencySymbol: "€", + totalInputTokens: 100, + totalOutputTokens: 50, + totalCachedTokens: 25, + modelCount: 1, + daily: [ + MistralDailyUsageBucket( + day: "2023-11-14", + cost: 1.5, + inputTokens: 100, + cachedTokens: 25, + outputTokens: 50, + models: [ + MistralDailyUsageBucket.ModelBreakdown( + name: "mistral-large", + cost: 1.5, + inputTokens: 100, + cachedTokens: 25, + outputTokens: 50), + ]), + ], + startDate: nil, + endDate: nil, + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .mistral, + metadata: metadata, + snapshot: snapshot.toUsageSnapshot(), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: snapshot.toCostUsageTokenSnapshot(historyDays: 30), + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(ProviderDescriptorRegistry.descriptor(for: .mistral).tokenCost.supportsTokenCost) + #expect(model.tokenUsage?.sessionLine == "Latest billing day (Nov 14): €1.50 · 175 tokens") + #expect(model.tokenUsage?.monthLine == "This month: €1.50 · 175 tokens") + #expect(model.tokenUsage?.hintLine == "Reported by Mistral billing usage.") + } + @Test func `zai hourly usage gets inline dashboard`() throws { let now = try #require(Self.zaiDate("2023-11-15 12:00")) diff --git a/Tests/CodexBarTests/MistralUsageParserTests.swift b/Tests/CodexBarTests/MistralUsageParserTests.swift index 62df8aa23..3ff1013bb 100644 --- a/Tests/CodexBarTests/MistralUsageParserTests.swift +++ b/Tests/CodexBarTests/MistralUsageParserTests.swift @@ -166,6 +166,138 @@ struct MistralUsageSnapshotConversionTests { #expect(usage.primary == nil) #expect(usage.identity?.loginMethod == "API spend: $0.0000 this month") } + + @Test + func `converts billing usage into cost token snapshot`() { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let snapshot = MistralUsageSnapshot( + totalCost: 1.75, + currency: "eur", + currencySymbol: "€", + totalInputTokens: 300, + totalOutputTokens: 150, + totalCachedTokens: 50, + modelCount: 2, + daily: [ + MistralDailyUsageBucket( + day: "2023-11-14", + cost: 1.5, + inputTokens: 100, + cachedTokens: 20, + outputTokens: 50, + models: [ + MistralDailyUsageBucket.ModelBreakdown( + name: "mistral-large", + cost: 1.5, + inputTokens: 100, + cachedTokens: 20, + outputTokens: 50), + ]), + MistralDailyUsageBucket( + day: "2023-11-15", + cost: 0.25, + inputTokens: 200, + cachedTokens: 30, + outputTokens: 100, + models: [ + MistralDailyUsageBucket.ModelBreakdown( + name: "mistral-small", + cost: 0.25, + inputTokens: 200, + cachedTokens: 30, + outputTokens: 100), + ]), + ], + startDate: nil, + endDate: nil, + updatedAt: now) + + let cost = snapshot.toCostUsageTokenSnapshot(historyDays: 1) + #expect(cost.currencyCode == "EUR") + #expect(cost.historyLabel == "This month") + #expect(cost.historyDays == 2) + #expect(cost.sessionCostUSD == 0.25) + #expect(cost.sessionTokens == 330) + #expect(cost.last30DaysCostUSD == 1.75) + #expect(cost.last30DaysTokens == 500) + #expect(cost.daily.count == 2) + #expect(cost.daily.last?.modelsUsed == ["mistral-small"]) + } + + @Test + func `clamps negative billing adjustments in cost token snapshot`() { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let snapshot = MistralUsageSnapshot( + totalCost: -2, + currency: "EUR", + currencySymbol: "€", + totalInputTokens: 100, + totalOutputTokens: 25, + totalCachedTokens: 0, + modelCount: 1, + daily: [ + MistralDailyUsageBucket( + day: "2023-11-14", + cost: -1.5, + inputTokens: 100, + cachedTokens: 0, + outputTokens: 25, + models: [ + MistralDailyUsageBucket.ModelBreakdown( + name: "mistral-large", + cost: -1.5, + inputTokens: 100, + cachedTokens: 0, + outputTokens: 25), + ]), + ], + startDate: nil, + endDate: nil, + updatedAt: now) + + let cost = snapshot.toCostUsageTokenSnapshot() + #expect(cost.sessionCostUSD == 0) + #expect(cost.last30DaysCostUSD == 0) + #expect(cost.daily.first?.costUSD == 0) + #expect(cost.daily.first?.modelBreakdowns?.first?.costUSD == 0) + } + + @Test + func `preserves net monthly cost when billing includes credits`() { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let snapshot = MistralUsageSnapshot( + totalCost: 8, + currency: "EUR", + currencySymbol: "€", + totalInputTokens: 100, + totalOutputTokens: 25, + totalCachedTokens: 0, + modelCount: 1, + daily: [ + MistralDailyUsageBucket( + day: "2023-11-14", + cost: 10, + inputTokens: 100, + cachedTokens: 0, + outputTokens: 25, + models: []), + MistralDailyUsageBucket( + day: "2023-11-15", + cost: -2, + inputTokens: 0, + cachedTokens: 0, + outputTokens: 0, + models: []), + ], + startDate: nil, + endDate: nil, + updatedAt: now) + + let cost = snapshot.toCostUsageTokenSnapshot() + #expect(cost.last30DaysCostUSD == 8) + #expect(cost.sessionCostUSD == 0) + #expect(cost.daily.map(\.costUSD) == [10, 0]) + } } struct MistralStrategyTests { @@ -262,5 +394,6 @@ struct MistralStrategyTests { #expect(descriptor.cli.name == "mistral") #expect(descriptor.fetchPlan.sourceModes == [.auto, .web]) #expect(descriptor.branding.iconResourceName == "ProviderIcon-mistral") + #expect(descriptor.tokenCost.supportsTokenCost) } } diff --git a/Tests/CodexBarTests/OpenAIAPICreditBalanceTests.swift b/Tests/CodexBarTests/OpenAIAPICreditBalanceTests.swift index e8d88e402..2569b228a 100644 --- a/Tests/CodexBarTests/OpenAIAPICreditBalanceTests.swift +++ b/Tests/CodexBarTests/OpenAIAPICreditBalanceTests.swift @@ -337,6 +337,71 @@ struct OpenAIAPICreditBalanceTests { #expect(usage.identity?.loginMethod == "Admin API") } + @Test + func `maps admin usage to cost token snapshot`() { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let apiUsage = OpenAIAPIUsageSnapshot( + daily: [ + OpenAIAPIUsageSnapshot.DailyBucket( + day: "2023-11-13", + startTime: now.addingTimeInterval(-86400), + endTime: now, + costUSD: 2.25, + requests: 3, + inputTokens: 300, + cachedInputTokens: 100, + outputTokens: 200, + totalTokens: 500, + lineItems: [], + models: [ + OpenAIAPIUsageSnapshot.ModelBreakdown( + name: "gpt-5.2", + requests: 3, + inputTokens: 300, + cachedInputTokens: 100, + outputTokens: 200, + totalTokens: 500), + ]), + OpenAIAPIUsageSnapshot.DailyBucket( + day: "2023-11-14", + startTime: now, + endTime: now.addingTimeInterval(86400), + costUSD: 8.5, + requests: 42, + inputTokens: 1000, + cachedInputTokens: 400, + outputTokens: 250, + totalTokens: 1250, + lineItems: [], + models: [ + OpenAIAPIUsageSnapshot.ModelBreakdown( + name: "gpt-5.2-codex", + requests: 42, + inputTokens: 1000, + cachedInputTokens: 400, + outputTokens: 250, + totalTokens: 1250), + ]), + ], + updatedAt: now, + historyDays: 7) + + let snapshot = apiUsage.toCostUsageTokenSnapshot() + + #expect(snapshot.historyDays == 7) + #expect(snapshot.sessionCostUSD == 8.5) + #expect(snapshot.sessionTokens == 1250) + #expect(snapshot.sessionRequests == 42) + #expect(snapshot.last30DaysCostUSD == 10.75) + #expect(snapshot.last30DaysTokens == 1750) + #expect(snapshot.last30DaysRequests == 45) + #expect(snapshot.daily.count == 2) + #expect(snapshot.daily[1].cacheReadTokens == 400) + #expect(snapshot.daily[1].requestCount == 42) + #expect(snapshot.daily[1].modelBreakdowns?.first?.requestCount == 42) + #expect(snapshot.daily[1].modelBreakdowns?.first?.modelName == "gpt-5.2-codex") + } + @Test func `falls back to credit balance when admin usage endpoint is unavailable`() async throws { let strategy = OpenAIAPIBalanceFetchStrategy( diff --git a/Tests/CodexBarTests/OpenAIAPIMenuCardModelTests.swift b/Tests/CodexBarTests/OpenAIAPIMenuCardModelTests.swift new file mode 100644 index 000000000..b13eabe47 --- /dev/null +++ b/Tests/CodexBarTests/OpenAIAPIMenuCardModelTests.swift @@ -0,0 +1,166 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct OpenAIAPIMenuCardModelTests { + @Test + func `admin usage model shows summaries and spend without fake quota bars`() throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let metadata = try #require(ProviderDefaults.metadata[.openai]) + let apiUsage = OpenAIAPIUsageSnapshot( + daily: [ + OpenAIAPIUsageSnapshot.DailyBucket( + day: "2023-11-14", + startTime: now, + endTime: now.addingTimeInterval(86400), + costUSD: 12.5, + requests: 40, + inputTokens: 1000, + cachedInputTokens: 250, + outputTokens: 500, + totalTokens: 1500, + lineItems: [ + OpenAIAPIUsageSnapshot.LineItemBreakdown(name: "Text tokens", costUSD: 12.5), + ], + models: [ + OpenAIAPIUsageSnapshot.ModelBreakdown( + name: "gpt-5.2", + requests: 40, + inputTokens: 1000, + cachedInputTokens: 250, + outputTokens: 500, + totalTokens: 1500), + ]), + ], + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .openai, + metadata: metadata, + snapshot: apiUsage.toUsageSnapshot(), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.isEmpty) + #expect(model.openAIAPIUsage != nil) + #expect(model.inlineUsageDashboard?.kpis.first?.value == "$12.50") + #expect(model.inlineUsageDashboard?.kpis.last?.title == "Requests") + #expect(model.inlineUsageDashboard?.kpis.last?.value == "40") + #expect(model.inlineUsageDashboard?.points.count == 1) + #expect(model.inlineUsageDashboard?.detailLines.contains("30d requests: 40 requests") == true) + #expect(model.providerCost == nil) + #expect(model.usageNotes.contains { $0.contains("Today: $12.50") }) + #expect(model.usageNotes.contains("Top model: gpt-5.2")) + #expect(model.creditsText == nil) + #expect(model.planText == "Admin API") + } + + @Test + func `admin usage dashboard ignores stale token snapshot after fallback refresh`() throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let metadata = try #require(ProviderDefaults.metadata[.openai]) + let staleTokenSnapshot = CostUsageTokenSnapshot( + sessionTokens: 1500, + sessionCostUSD: 12.5, + last30DaysTokens: 1500, + last30DaysCostUSD: 12.5, + daily: [ + CostUsageDailyReport.Entry( + date: "2023-11-14", + inputTokens: 1000, + outputTokens: 500, + totalTokens: 1500, + costUSD: 12.5, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .openai, + metadata: metadata, + snapshot: UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: now), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: staleTokenSnapshot, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.inlineUsageDashboard == nil) + #expect(model.tokenUsage == nil) + } + + @Test + func `admin usage model can show cost card summary`() throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let metadata = try #require(ProviderDefaults.metadata[.openai]) + let apiUsage = OpenAIAPIUsageSnapshot( + daily: [ + OpenAIAPIUsageSnapshot.DailyBucket( + day: "2023-11-14", + startTime: now, + endTime: now.addingTimeInterval(86400), + costUSD: 12.5, + requests: 40, + inputTokens: 1000, + cachedInputTokens: 250, + outputTokens: 500, + totalTokens: 1500, + lineItems: [], + models: []), + ], + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .openai, + metadata: metadata, + snapshot: apiUsage.toUsageSnapshot(), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: apiUsage.toCostUsageTokenSnapshot(), + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(ProviderDescriptorRegistry.descriptor(for: .openai).tokenCost.supportsTokenCost) + #expect(model.tokenUsage?.sessionLine == "Today: $12.50 · 1.5K tokens") + #expect(model.tokenUsage?.monthLine == "Last 30 days: $12.50 · 1.5K tokens") + #expect(model.tokenUsage?.hintLine == "Reported by OpenAI Admin API organization usage.") + } +} diff --git a/Tests/CodexBarTests/OpenAIAPIStatusMenuTests.swift b/Tests/CodexBarTests/OpenAIAPIStatusMenuTests.swift new file mode 100644 index 000000000..002d2bd9c --- /dev/null +++ b/Tests/CodexBarTests/OpenAIAPIStatusMenuTests.swift @@ -0,0 +1,53 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +extension StatusMenuTests { + @Test + func `open AI API usage submenu ignores stale token snapshot without current admin usage`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.selectedMenuProvider = .openai + + let registry = ProviderRegistry.shared + let metadata = try #require(registry.metadata[.openai]) + settings.setProviderEnabled(provider: .openai, metadata: metadata, enabled: true) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let now = Date(timeIntervalSince1970: 1_700_000_000) + store._setSnapshotForTesting( + UsageSnapshot(primary: nil, secondary: nil, updatedAt: now), + provider: .openai) + store._setTokenSnapshotForTesting(CostUsageTokenSnapshot( + sessionTokens: 123, + sessionCostUSD: 0.12, + last30DaysTokens: 123, + last30DaysCostUSD: 1.23, + daily: [ + CostUsageDailyReport.Entry( + date: "2025-12-23", + inputTokens: nil, + outputTokens: nil, + totalTokens: 123, + costUSD: 1.23, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: now), provider: .openai) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + #expect(controller.makeOpenAIAPIUsageSubmenu(provider: .openai) == nil) + } +} diff --git a/Tests/CodexBarTests/StatusMenuOverviewSubmenuTests.swift b/Tests/CodexBarTests/StatusMenuOverviewSubmenuTests.swift index 10ec3e242..6b90c164e 100644 --- a/Tests/CodexBarTests/StatusMenuOverviewSubmenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuOverviewSubmenuTests.swift @@ -58,7 +58,7 @@ extension StatusMenuTests { ($0.representedObject as? String) == "overviewRow-openai" }) #expect(openAIRow.submenu?.items.contains { - ($0.representedObject as? String) == StatusItemController.openAIAPIUsageChartID + ($0.representedObject as? String) == StatusItemController.costHistoryChartID } == true) } } diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index e71d180a0..4efbddfe5 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -1220,7 +1220,7 @@ extension StatusMenuTests { let usageItem = menu.items.first { ($0.representedObject as? String) == "menuCardUsage" } #expect(usageItem?.submenu?.items - .contains { ($0.representedObject as? String) == StatusItemController.openAIAPIUsageChartID } == true) + .contains { ($0.representedObject as? String) == StatusItemController.costHistoryChartID } == true) #expect(menu.items.contains { ($0.representedObject as? String) == "menuCardHeader" } == false) #expect(menu.items.contains { ($0.representedObject as? String) == "menuCardExtraUsage" } == false) } diff --git a/Tests/CodexBarTests/UsageStoreCoverageTests.swift b/Tests/CodexBarTests/UsageStoreCoverageTests.swift index b80ea7522..f771e0bc8 100644 --- a/Tests/CodexBarTests/UsageStoreCoverageTests.swift +++ b/Tests/CodexBarTests/UsageStoreCoverageTests.swift @@ -352,6 +352,44 @@ struct UsageStoreCoverageTests { #expect(store.enabledProvidersForBackgroundWork().isEmpty) } + @Test + func `widget snapshot projects provider derived token usage`() async throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-widget-provider-cost") + let store = Self.makeUsageStore(settings: settings) + let day = MistralDailyUsageBucket( + day: "2026-05-26", + cost: 1.2, + inputTokens: 10, + cachedTokens: 0, + outputTokens: 5, + models: []) + store._setSnapshotForTesting(MistralUsageSnapshot( + totalCost: 9, + currency: "eur", + currencySymbol: "€", + totalInputTokens: 10, + totalOutputTokens: 5, + totalCachedTokens: 0, + modelCount: 1, + daily: [day], + startDate: nil, + endDate: nil, + updatedAt: Date()).toUsageSnapshot(), provider: .mistral) + + var widgetSnapshots: [WidgetSnapshot] = [] + store._test_widgetSnapshotSaveOverride = { widgetSnapshots.append($0) } + defer { store._test_widgetSnapshotSaveOverride = nil } + + store.persistWidgetSnapshot(reason: "provider-cost") + await store.widgetSnapshotPersistTask?.value + + let mistralEntry = try #require(widgetSnapshots.last?.entries.first { $0.provider == .mistral }) + #expect(mistralEntry.tokenUsage?.currencyCode == "EUR") + #expect(mistralEntry.tokenUsage?.sessionLabel == "Latest billing day") + #expect(mistralEntry.tokenUsage?.last30DaysLabel == "This month") + #expect(mistralEntry.tokenUsage?.last30DaysCostUSD == 9) + } + @Test func `unavailable provider with only cached status gets single cleanup pass`() async throws { let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-background-status-cleanup") diff --git a/Tests/CodexBarTests/WidgetSnapshotTests.swift b/Tests/CodexBarTests/WidgetSnapshotTests.swift index ea9b84bf0..d431e7eb6 100644 --- a/Tests/CodexBarTests/WidgetSnapshotTests.swift +++ b/Tests/CodexBarTests/WidgetSnapshotTests.swift @@ -21,7 +21,10 @@ struct WidgetSnapshotTests { sessionCostUSD: 12.3, sessionTokens: 1200, last30DaysCostUSD: 456.7, - last30DaysTokens: 9800), + last30DaysTokens: 9800, + currencyCode: "eur", + sessionLabel: "Latest billing day", + last30DaysLabel: "This month"), dailyUsage: [ WidgetSnapshot.DailyUsagePoint(dayKey: "2025-12-20", totalTokens: 1200, costUSD: 12.3), ]) @@ -42,6 +45,9 @@ struct WidgetSnapshotTests { #expect(decoded.entries.count == 1) #expect(decoded.entries.first?.provider == .codex) #expect(decoded.entries.first?.tokenUsage?.sessionTokens == 1200) + #expect(decoded.entries.first?.tokenUsage?.currencyCode == "EUR") + #expect(decoded.entries.first?.tokenUsage?.sessionLabel == "Latest billing day") + #expect(decoded.entries.first?.tokenUsage?.last30DaysLabel == "This month") #expect(decoded.entries.first?.usageRows?.map(\.id) == ["session", "weekly"]) #expect(decoded.enabledProviders == [.codex, .claude]) } @@ -161,4 +167,40 @@ struct WidgetSnapshotTests { #expect(decoded.entries.first?.usageRows == nil) #expect(decoded.entries.first?.secondary?.usedPercent == 25) } + + @Test + func `widget snapshot decodes legacy token usage as usd`() throws { + let json = """ + { + "entries": [ + { + "provider": "codex", + "updatedAt": "2026-04-04T06:30:00Z", + "primary": null, + "secondary": null, + "tertiary": null, + "creditsRemaining": null, + "codeReviewRemainingPercent": null, + "tokenUsage": { + "sessionCostUSD": 1.25, + "sessionTokens": 1200, + "last30DaysCostUSD": 9.50, + "last30DaysTokens": 4200 + }, + "dailyUsage": [] + } + ], + "generatedAt": "2026-04-04T06:30:00Z" + } + """ + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let decoded = try decoder.decode(WidgetSnapshot.self, from: Data(json.utf8)) + + #expect(decoded.entries.first?.tokenUsage?.currencyCode == "USD") + #expect(decoded.entries.first?.tokenUsage?.sessionLabel == "Today") + #expect(decoded.entries.first?.tokenUsage?.last30DaysLabel == "30d") + #expect(decoded.enabledProviders == [.codex]) + } }