From e0e852d14faf7b061eeff1f45a5cbd8ff479e1eb Mon Sep 17 00:00:00 2001 From: Zhongyue Lin <101193087+LeoLin990405@users.noreply.github.com> Date: Tue, 26 May 2026 17:49:54 +0800 Subject: [PATCH 01/10] Add OpenAI API cost card support --- Sources/CodexBar/MenuCardView+Costs.swift | 4 +- .../CodexBar/PreferencesProvidersPane.swift | 2 +- .../CodexBar/StatusItemController+Menu.swift | 2 +- .../StatusItemController+MenuCardModel.swift | 4 +- Sources/CodexBar/UsageStore.swift | 8 +-- Sources/CodexBarCore/CostUsageFetcher.swift | 15 ++++- .../OpenAI/OpenAIAPIProviderDescriptor.swift | 2 +- .../OpenAI/OpenAIAPIUsageSnapshot.swift | 32 ++++++++++ Tests/CodexBarTests/MenuCardModelTests.swift | 47 ++++++++++++++ .../OpenAIAPICreditBalanceTests.swift | 61 +++++++++++++++++++ 10 files changed, 167 insertions(+), 10 deletions(-) diff --git a/Sources/CodexBar/MenuCardView+Costs.swift b/Sources/CodexBar/MenuCardView+Costs.swift index 2c567e389..6580e22ff 100644 --- a/Sources/CodexBar/MenuCardView+Costs.swift +++ b/Sources/CodexBar/MenuCardView+Costs.swift @@ -23,7 +23,7 @@ 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 } @@ -69,6 +69,8 @@ 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.") default: nil } 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+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index e0f241233..1694e6fb7 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -1547,7 +1547,7 @@ extension StatusItemController { } func makeCostHistorySubmenu(provider: UsageProvider, width: CGFloat? = nil) -> NSMenu? { - guard [.codex, .claude, .vertexai, .bedrock].contains(provider) else { return nil } + guard ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.supportsTokenCost else { return nil } guard self.store.tokenSnapshot(for: provider)?.daily.isEmpty == false else { return nil } if let width { return self.makeHostedSubviewPlaceholderMenu( diff --git a/Sources/CodexBar/StatusItemController+MenuCardModel.swift b/Sources/CodexBar/StatusItemController+MenuCardModel.swift index 048dc4495..1235e5405 100644 --- a/Sources/CodexBar/StatusItemController+MenuCardModel.swift +++ b/Sources/CodexBar/StatusItemController+MenuCardModel.swift @@ -50,7 +50,9 @@ extension StatusItemController { tokenSnapshot = nil 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 diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 0bc6f514f..ee48e93c3 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() @@ -1539,16 +1539,16 @@ extension UsageStore { do { let fetcher = self.costUsageFetcher let timeoutSeconds = self.tokenFetchTimeout - let environment = provider == .bedrock + let environment = provider == .bedrock || provider == .openai ? ProviderRegistry.makeEnvironment( base: self.environmentBase, provider: provider, 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 diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index f9413e651..a52cb5ef2 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -54,7 +54,9 @@ public struct CostUsageFetcher: Sendable { piScannerOptions overridePiScannerOptions: PiSessionCostScanner .Options? = nil) async throws -> CostUsageTokenSnapshot { - guard provider == .codex || provider == .claude || provider == .vertexai || provider == .bedrock else { + guard provider == .codex || provider == .claude || provider == .vertexai || provider == .bedrock || + provider == .openai + else { throw CostUsageError.unsupportedProvider(provider) } @@ -71,6 +73,17 @@ public struct CostUsageFetcher: Sendable { return Self.tokenSnapshot(from: daily, now: now, historyDays: clampedHistoryDays) } + if provider == .openai { + guard let apiKey = ProviderTokenResolver.openAIAPIToken(environment: environment) else { + throw OpenAIAPISettingsError.missingToken + } + let usage = try await OpenAIAPIUsageFetcher.fetchUsage( + apiKey: apiKey, + now: now, + historyDays: clampedHistoryDays) + return usage.toCostUsageTokenSnapshot() + } + var options = overrideScannerOptions ?? CostUsageScanner.Options() if provider == .codex, let codexHomePath = codexHomePath?.trimmingCharacters(in: .whitespacesAndNewlines), 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..dc107262d 100644 --- a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageSnapshot.swift @@ -204,6 +204,38 @@ 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) + } + 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, + 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, + last30DaysTokens: total.totalTokens, + last30DaysCostUSD: total.costUSD, + historyDays: self.historyDays, + daily: daily, + updatedAt: self.updatedAt) + } + private struct ModelAccumulator { var requests = 0 var inputTokens = 0 diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift index e1f19b3c1..993dfb237 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -121,6 +121,53 @@ struct OpenAIAPIMenuCardModelTests { #expect(model.creditsText == nil) #expect(model.planText == "Admin API") } + + @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.") + } } struct ProviderInlineDashboardModelTests { diff --git a/Tests/CodexBarTests/OpenAIAPICreditBalanceTests.swift b/Tests/CodexBarTests/OpenAIAPICreditBalanceTests.swift index e8d88e402..9c2a1cae8 100644 --- a/Tests/CodexBarTests/OpenAIAPICreditBalanceTests.swift +++ b/Tests/CodexBarTests/OpenAIAPICreditBalanceTests.swift @@ -337,6 +337,67 @@ 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.last30DaysCostUSD == 10.75) + #expect(snapshot.last30DaysTokens == 1750) + #expect(snapshot.daily.count == 2) + #expect(snapshot.daily[1].cacheReadTokens == 400) + #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( From 16899c85072344254bb296a4c1c5a05d9cba3174 Mon Sep 17 00:00:00 2001 From: Zhongyue Lin <101193087+LeoLin990405@users.noreply.github.com> Date: Tue, 26 May 2026 18:11:13 +0800 Subject: [PATCH 02/10] Add Mistral cost card support --- .../CodexBar/CostHistoryChartMenuView.swift | 23 ++++++-- .../InlineUsageDashboardContent.swift | 58 ++++++++++++------- Sources/CodexBar/MenuCardView+Costs.swift | 20 +++++-- Sources/CodexBar/PreferencesGeneralPane.swift | 5 +- .../StatusItemController+HostedSubmenus.swift | 2 + Sources/CodexBar/UsageStore+Refresh.swift | 5 ++ Sources/CodexBar/UsageStore+TokenCost.swift | 20 +++++++ Sources/CodexBar/UsageStore.swift | 21 ++++++- Sources/CodexBarCLI/CLICostCommand.swift | 39 ++++++++++++- Sources/CodexBarCore/CostUsageModels.swift | 8 +++ .../Providers/Mistral/MistralModels.swift | 41 +++++++++++++ .../Mistral/MistralProviderDescriptor.swift | 4 +- Sources/CodexBarCore/UsageFormatter.swift | 9 ++- Tests/CodexBarTests/MenuCardModelTests.swift | 58 +++++++++++++++++++ .../MistralUsageParserTests.swift | 58 +++++++++++++++++++ 15 files changed, 329 insertions(+), 42 deletions(-) diff --git a/Sources/CodexBar/CostHistoryChartMenuView.swift b/Sources/CodexBar/CostHistoryChartMenuView.swift index 475f421da..194ebe2d2 100644 --- a/Sources/CodexBar/CostHistoryChartMenuView.swift +++ b/Sources/CodexBar/CostHistoryChartMenuView.swift @@ -36,7 +36,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 +46,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 +179,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) @@ -426,7 +432,7 @@ struct CostHistoryChartMenuView: View { } let dayLabel = date.formatted(.dateTime.month(.abbreviated).day()) - let cost = UsageFormatter.usdString(point.costUSD) + let cost = self.costString(point.costUSD) let primary = if let tokens = point.totalTokens { String(format: L("%@: %@ · %@ tokens"), dayLabel, cost, UsageFormatter.tokenCountString(tokens)) } else { @@ -472,20 +478,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 +502,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..f63016a3a 100644 --- a/Sources/CodexBar/InlineUsageDashboardContent.swift +++ b/Sources/CodexBar/InlineUsageDashboardContent.swift @@ -165,50 +165,54 @@ 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 + ?? (historyDays == 1 + ? L("Today tokens") + : historyDays == 30 + ? L("30d tokens") + : String(format: L("%@ tokens"), 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 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( @@ -460,6 +464,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 } diff --git a/Sources/CodexBar/MenuCardView+Costs.swift b/Sources/CodexBar/MenuCardView+Costs.swift index 6580e22ff..d894e918d 100644 --- a/Sources/CodexBar/MenuCardView+Costs.swift +++ b/Sources/CodexBar/MenuCardView+Costs.swift @@ -29,9 +29,15 @@ extension UsageMenuCardView.Model { 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 +45,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) @@ -71,6 +79,8 @@ extension UsageMenuCardView.Model { 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 } @@ -80,7 +90,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/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/StatusItemController+HostedSubmenus.swift b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift index 17f16df03..abf81e447 100644 --- a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift +++ b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift @@ -184,7 +184,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) diff --git a/Sources/CodexBar/UsageStore+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index 190d4d5ea..ae5a02aaa 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -133,6 +133,11 @@ 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() + } self.lastSourceLabels[provider] = result.sourceLabel self.errors[provider] = nil self.failureGates[provider]?.recordSuccess() diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index d9317b0df..540e1d17d 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -30,6 +30,26 @@ 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: + true + default: + false + } + } + nonisolated static func costUsageCacheDirectory( fileManager: FileManager = .default) -> URL { diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index ee48e93c3..10f0cb6f3 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1531,6 +1531,21 @@ extension UsageStore { self.tokenRefreshInFlight.insert(provider) defer { self.tokenRefreshInFlight.remove(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") + return + } + + if Self.tokenCostRequiresProviderSnapshot(provider) { + self.tokenSnapshots.removeValue(forKey: provider) + self.tokenErrors[provider] = Self.tokenCostNoDataMessage(for: provider) + self.tokenFailureGates[provider]?.recordSuccess() + return + } + let startedAt = Date() let providerText = provider.rawValue self.tokenCostLogger @@ -1578,8 +1593,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..e2c25f5f5 100644 --- a/Sources/CodexBarCore/CostUsageModels.swift +++ b/Sources/CodexBarCore/CostUsageModels.swift @@ -5,7 +5,9 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { public let sessionCostUSD: Double? public let last30DaysTokens: Int? public let last30DaysCostUSD: Double? + public let currencyCode: String public let historyDays: Int + public let historyLabel: String? public let daily: [CostUsageDailyReport.Entry] public let updatedAt: Date @@ -14,7 +16,9 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { sessionCostUSD: Double?, last30DaysTokens: Int?, last30DaysCostUSD: Double?, + currencyCode: String = "USD", historyDays: Int = 30, + historyLabel: String? = nil, daily: [CostUsageDailyReport.Entry], updatedAt: Date) { @@ -22,7 +26,11 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { self.sessionCostUSD = sessionCostUSD self.last30DaysTokens = last30DaysTokens self.last30DaysCostUSD = last30DaysCostUSD + 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 } diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift b/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift index 7d27f365e..ed2f25ce2 100644 --- a/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift +++ b/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift @@ -207,4 +207,45 @@ 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: $0.cost, + 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: bucket.cost, + modelsUsed: modelsUsed.isEmpty ? nil : modelsUsed, + modelBreakdowns: modelBreakdowns.isEmpty ? nil : modelBreakdowns) + } + let latest = selected.last + let totalCost = selected.isEmpty ? max(self.totalCost, 0) : selected.reduce(0) { $0 + $1.cost } + let totalTokens = selected.isEmpty + ? self.totalInputTokens + self.totalCachedTokens + self.totalOutputTokens + : selected.reduce(0) { $0 + $1.totalTokens } + let cost = totalCost > 0 ? totalCost : nil + let tokens = totalTokens > 0 ? totalTokens : nil + return CostUsageTokenSnapshot( + sessionTokens: latest?.totalTokens, + sessionCostUSD: latest?.cost, + last30DaysTokens: tokens, + last30DaysCostUSD: cost, + 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/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/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift index 993dfb237..ce6b4b7c2 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -401,6 +401,64 @@ struct ProviderInlineDashboardModelTests { #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..472cd0de2 100644 --- a/Tests/CodexBarTests/MistralUsageParserTests.swift +++ b/Tests/CodexBarTests/MistralUsageParserTests.swift @@ -166,6 +166,63 @@ 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"]) + } } struct MistralStrategyTests { @@ -262,5 +319,6 @@ struct MistralStrategyTests { #expect(descriptor.cli.name == "mistral") #expect(descriptor.fetchPlan.sourceModes == [.auto, .web]) #expect(descriptor.branding.iconResourceName == "ProviderIcon-mistral") + #expect(descriptor.tokenCost.supportsTokenCost) } } From 5c76a41dc78af60f5aa603b61abdf5ad553198d6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 26 May 2026 14:34:32 +0100 Subject: [PATCH 03/10] fix: unify provider cost cards Deduplicate OpenAI and Mistral API spend rendering through the shared cost-history dashboard and menu path. Co-authored-by: Zhongyue Lin <101193087+LeoLin990405@users.noreply.github.com> --- CHANGELOG.md | 1 + .../CodexBar/CostHistoryChartMenuView.swift | 21 +- .../InlineUsageDashboardContent.swift | 156 ++++----- Sources/CodexBar/MenuCardView+Costs.swift | 7 + Sources/CodexBar/MenuCardView.swift | 3 +- .../OpenAIAPIUsageChartMenuView.swift | 306 ------------------ .../StatusItemController+HostedSubmenus.swift | 45 +-- .../CodexBar/StatusItemController+Menu.swift | 23 +- .../StatusItemController+MenuCardModel.swift | 12 +- Sources/CodexBar/UsageStore+Refresh.swift | 55 ++-- Sources/CodexBar/UsageStore+TokenCost.swift | 8 +- Sources/CodexBar/UsageStore.swift | 2 +- Sources/CodexBarCore/CostUsageFetcher.swift | 15 +- Sources/CodexBarCore/CostUsageModels.swift | 22 ++ .../OpenAI/OpenAIAPIUsageSnapshot.swift | 6 +- Tests/CodexBarTests/MenuCardModelTests.swift | 115 +------ .../OpenAIAPICreditBalanceTests.swift | 4 + .../OpenAIAPIMenuCardModelTests.swift | 166 ++++++++++ .../OpenAIAPIStatusMenuTests.swift | 53 +++ .../StatusMenuOverviewSubmenuTests.swift | 2 +- Tests/CodexBarTests/StatusMenuTests.swift | 2 +- 21 files changed, 394 insertions(+), 630 deletions(-) delete mode 100644 Sources/CodexBar/OpenAIAPIUsageChartMenuView.swift create mode 100644 Tests/CodexBarTests/OpenAIAPIMenuCardModelTests.swift create mode 100644 Tests/CodexBarTests/OpenAIAPIStatusMenuTests.swift 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 194ebe2d2..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)" } } @@ -258,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 @@ -433,11 +439,14 @@ struct CostHistoryChartMenuView: View { let dayLabel = date.formatted(.dateTime.month(.abbreviated).day()) let cost = self.costString(point.costUSD) - let primary = if let tokens = point.totalTokens { - String(format: L("%@: %@ · %@ tokens"), dayLabel, cost, UsageFormatter.tokenCountString(tokens)) - } else { - "\(dayLabel): \(cost)" + 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)) } diff --git a/Sources/CodexBar/InlineUsageDashboardContent.swift b/Sources/CodexBar/InlineUsageDashboardContent.swift index f63016a3a..194a0c4e0 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( @@ -177,6 +160,12 @@ extension UsageMenuCardView.Model { : 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 @@ -192,6 +181,9 @@ extension UsageMenuCardView.Model { if let topModel = Self.topCostModel(from: snapshot.daily) { details.append("\(L("Top model")): \(Self.shortModelName(topModel))") } + if let requestCount = snapshot.last30DaysRequests { + details.append("\(requestHistoryTitle): \(UsageFormatter.tokenCountString(requestCount)) requests") + } if let hint = Self.tokenUsageHint(provider: provider) { details.append(hint) } else { @@ -215,15 +207,32 @@ extension UsageMenuCardView.Model { 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 { @@ -314,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 } @@ -426,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 { @@ -452,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) } @@ -511,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 d894e918d..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?, 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/StatusItemController+HostedSubmenus.swift b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift index abf81e447..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 { @@ -201,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 1694e6fb7..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" @@ -1548,7 +1547,7 @@ extension StatusItemController { func makeCostHistorySubmenu(provider: UsageProvider, width: CGFloat? = nil) -> NSMenu? { guard ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.supportsTokenCost else { return nil } - guard self.store.tokenSnapshot(for: provider)?.daily.isEmpty == false 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 1235e5405..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,10 +48,10 @@ 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 ProviderDescriptorRegistry.descriptor(for: target).tokenCost.supportsTokenCost, @@ -57,14 +61,14 @@ extension StatusItemController { 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 ae5a02aaa..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 } @@ -137,6 +114,9 @@ extension UsageStore { 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 @@ -178,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 540e1d17d..13c162ed9 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -30,7 +30,11 @@ extension UsageStore { return (homePath, "codex:managed:\(homePath)") } - func tokenSnapshot(fromProviderSnapshot snapshot: UsageSnapshot?, provider: UsageProvider) -> CostUsageTokenSnapshot? { + func tokenSnapshot( + fromProviderSnapshot snapshot: UsageSnapshot?, + provider: UsageProvider) + -> CostUsageTokenSnapshot? + { switch provider { case .openai: snapshot?.openAIAPIUsage?.toCostUsageTokenSnapshot() @@ -43,7 +47,7 @@ extension UsageStore { nonisolated static func tokenCostRequiresProviderSnapshot(_ provider: UsageProvider) -> Bool { switch provider { - case .mistral: + case .mistral, .openai: true default: false diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 10f0cb6f3..235dc22cb 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1554,7 +1554,7 @@ extension UsageStore { do { let fetcher = self.costUsageFetcher let timeoutSeconds = self.tokenFetchTimeout - let environment = provider == .bedrock || provider == .openai + let environment = provider == .bedrock ? ProviderRegistry.makeEnvironment( base: self.environmentBase, provider: provider, diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index a52cb5ef2..f9413e651 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -54,9 +54,7 @@ public struct CostUsageFetcher: Sendable { piScannerOptions overridePiScannerOptions: PiSessionCostScanner .Options? = nil) async throws -> CostUsageTokenSnapshot { - guard provider == .codex || provider == .claude || provider == .vertexai || provider == .bedrock || - provider == .openai - else { + guard provider == .codex || provider == .claude || provider == .vertexai || provider == .bedrock else { throw CostUsageError.unsupportedProvider(provider) } @@ -73,17 +71,6 @@ public struct CostUsageFetcher: Sendable { return Self.tokenSnapshot(from: daily, now: now, historyDays: clampedHistoryDays) } - if provider == .openai { - guard let apiKey = ProviderTokenResolver.openAIAPIToken(environment: environment) else { - throw OpenAIAPISettingsError.missingToken - } - let usage = try await OpenAIAPIUsageFetcher.fetchUsage( - apiKey: apiKey, - now: now, - historyDays: clampedHistoryDays) - return usage.toCostUsageTokenSnapshot() - } - var options = overrideScannerOptions ?? CostUsageScanner.Options() if provider == .codex, let codexHomePath = codexHomePath?.trimmingCharacters(in: .whitespacesAndNewlines), diff --git a/Sources/CodexBarCore/CostUsageModels.swift b/Sources/CodexBarCore/CostUsageModels.swift index e2c25f5f5..0a891b34f 100644 --- a/Sources/CodexBarCore/CostUsageModels.swift +++ b/Sources/CodexBarCore/CostUsageModels.swift @@ -3,8 +3,10 @@ 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? @@ -14,8 +16,10 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { 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, @@ -24,8 +28,10 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { { 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() @@ -41,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? @@ -51,6 +58,8 @@ public struct CostUsageDailyReport: Sendable, Decodable { case costUSD case cost case totalTokens + case requestCount + case requests case standardCostUSD case priorityCostUSD case standardTokens @@ -64,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) @@ -74,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, @@ -82,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 @@ -96,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]? @@ -109,6 +124,8 @@ public struct CostUsageDailyReport: Sendable, Decodable { case cacheCreationInputTokens case outputTokens case totalTokens + case requestCount + case requests case costUSD case totalCost case modelsUsed @@ -128,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) @@ -142,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]?) @@ -152,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/OpenAI/OpenAIAPIUsageSnapshot.swift b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageSnapshot.swift index dc107262d..4bd38d1ab 100644 --- a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageSnapshot.swift @@ -210,7 +210,8 @@ public struct OpenAIAPIUsageSnapshot: Codable, Equatable, Sendable { CostUsageDailyReport.ModelBreakdown( modelName: $0.name, costUSD: nil, - totalTokens: $0.totalTokens) + totalTokens: $0.totalTokens, + requestCount: $0.requests) } let modelsUsed = bucket.models.map(\.name) return CostUsageDailyReport.Entry( @@ -220,6 +221,7 @@ public struct OpenAIAPIUsageSnapshot: Codable, Equatable, Sendable { cacheReadTokens: bucket.cachedInputTokens, cacheCreationTokens: nil, totalTokens: bucket.totalTokens, + requestCount: bucket.requests, costUSD: bucket.costUSD, modelsUsed: modelsUsed.isEmpty ? nil : modelsUsed, modelBreakdowns: modelBreakdowns.isEmpty ? nil : modelBreakdowns) @@ -229,8 +231,10 @@ public struct OpenAIAPIUsageSnapshot: Codable, Equatable, Sendable { 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) diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift index ce6b4b7c2..dc823cea5 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -59,117 +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") - } - - @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.") - } -} - struct ProviderInlineDashboardModelTests { @Test func `claude admin api usage gets inline dashboard`() throws { @@ -396,8 +285,8 @@ 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) } diff --git a/Tests/CodexBarTests/OpenAIAPICreditBalanceTests.swift b/Tests/CodexBarTests/OpenAIAPICreditBalanceTests.swift index 9c2a1cae8..2569b228a 100644 --- a/Tests/CodexBarTests/OpenAIAPICreditBalanceTests.swift +++ b/Tests/CodexBarTests/OpenAIAPICreditBalanceTests.swift @@ -391,10 +391,14 @@ struct OpenAIAPICreditBalanceTests { #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") } 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) } From e0e9bd1d2dbff23b23c4bbac0feeea8d7c498353 Mon Sep 17 00:00:00 2001 From: Zhongyue Lin <101193087+LeoLin990405@users.noreply.github.com> Date: Tue, 26 May 2026 21:48:19 +0800 Subject: [PATCH 04/10] Address Mistral cost card review --- CHANGELOG.md | 1 - .../Providers/Mistral/MistralModels.swift | 8 ++-- .../MistralUsageParserTests.swift | 38 +++++++++++++++++++ 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95cd71fff..e624fe235 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,6 @@ ### 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/CodexBarCore/Providers/Mistral/MistralModels.swift b/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift index ed2f25ce2..41168402b 100644 --- a/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift +++ b/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift @@ -215,7 +215,7 @@ public struct MistralUsageSnapshot: Codable, Sendable { let modelBreakdowns = bucket.models.map { CostUsageDailyReport.ModelBreakdown( modelName: $0.name, - costUSD: $0.cost, + costUSD: max($0.cost, 0), totalTokens: $0.totalTokens) } let modelsUsed = bucket.models.map(\.name) @@ -226,12 +226,12 @@ public struct MistralUsageSnapshot: Codable, Sendable { cacheReadTokens: bucket.cachedTokens, cacheCreationTokens: nil, totalTokens: bucket.totalTokens, - costUSD: bucket.cost, + costUSD: max(bucket.cost, 0), modelsUsed: modelsUsed.isEmpty ? nil : modelsUsed, modelBreakdowns: modelBreakdowns.isEmpty ? nil : modelBreakdowns) } let latest = selected.last - let totalCost = selected.isEmpty ? max(self.totalCost, 0) : selected.reduce(0) { $0 + $1.cost } + let totalCost = selected.isEmpty ? max(self.totalCost, 0) : selected.reduce(0) { $0 + max($1.cost, 0) } let totalTokens = selected.isEmpty ? self.totalInputTokens + self.totalCachedTokens + self.totalOutputTokens : selected.reduce(0) { $0 + $1.totalTokens } @@ -239,7 +239,7 @@ public struct MistralUsageSnapshot: Codable, Sendable { let tokens = totalTokens > 0 ? totalTokens : nil return CostUsageTokenSnapshot( sessionTokens: latest?.totalTokens, - sessionCostUSD: latest?.cost, + sessionCostUSD: latest.map { max($0.cost, 0) }, last30DaysTokens: tokens, last30DaysCostUSD: cost, currencyCode: self.currency, diff --git a/Tests/CodexBarTests/MistralUsageParserTests.swift b/Tests/CodexBarTests/MistralUsageParserTests.swift index 472cd0de2..39cca27c8 100644 --- a/Tests/CodexBarTests/MistralUsageParserTests.swift +++ b/Tests/CodexBarTests/MistralUsageParserTests.swift @@ -223,6 +223,44 @@ struct MistralUsageSnapshotConversionTests { #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 == nil) + #expect(cost.daily.first?.costUSD == 0) + #expect(cost.daily.first?.modelBreakdowns?.first?.costUSD == 0) + } } struct MistralStrategyTests { From 55ed54579bbe70ca72173dc26d39184c1df0ba99 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 26 May 2026 15:30:59 +0100 Subject: [PATCH 05/10] docs: restore cost card changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) 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! From 7ec3c1b33882d5550f01d2b070f45d75be475374 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 26 May 2026 15:40:02 +0100 Subject: [PATCH 06/10] fix: preserve Mistral net spend --- .../Providers/Mistral/MistralModels.swift | 2 +- .../MistralUsageParserTests.swift | 37 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift b/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift index 41168402b..1da534312 100644 --- a/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift +++ b/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift @@ -231,7 +231,7 @@ public struct MistralUsageSnapshot: Codable, Sendable { modelBreakdowns: modelBreakdowns.isEmpty ? nil : modelBreakdowns) } let latest = selected.last - let totalCost = selected.isEmpty ? max(self.totalCost, 0) : selected.reduce(0) { $0 + max($1.cost, 0) } + let totalCost = max(self.totalCost, 0) let totalTokens = selected.isEmpty ? self.totalInputTokens + self.totalCachedTokens + self.totalOutputTokens : selected.reduce(0) { $0 + $1.totalTokens } diff --git a/Tests/CodexBarTests/MistralUsageParserTests.swift b/Tests/CodexBarTests/MistralUsageParserTests.swift index 39cca27c8..2656aefd9 100644 --- a/Tests/CodexBarTests/MistralUsageParserTests.swift +++ b/Tests/CodexBarTests/MistralUsageParserTests.swift @@ -261,6 +261,43 @@ struct MistralUsageSnapshotConversionTests { #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 { From 745f575f8405be5e49c30e8aa67fed3367f6c18c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 26 May 2026 16:07:35 +0100 Subject: [PATCH 07/10] fix: preserve widget cost currency --- .../CodexBar/UsageStore+WidgetSnapshot.swift | 3 +- Sources/CodexBarCore/WidgetSnapshot.swift | 25 ++++++++- .../CodexBarWidget/CodexBarWidgetViews.swift | 54 +++++++++++++------ .../CodexAccountScopedRefreshTests.swift | 34 ++++++++++++ Tests/CodexBarTests/WidgetSnapshotTests.swift | 38 ++++++++++++- 5 files changed, 136 insertions(+), 18 deletions(-) diff --git a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift index e0808f9ce..8eefd2c6f 100644 --- a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift +++ b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift @@ -85,7 +85,8 @@ extension UsageStore { sessionCostUSD: snapshot.sessionCostUSD, sessionTokens: snapshot.sessionTokens, last30DaysCostUSD: snapshot.last30DaysCostUSD, - last30DaysTokens: monthTokensValue) + last30DaysTokens: monthTokensValue, + currencyCode: snapshot.currencyCode) } private func widgetUsageRows( diff --git a/Sources/CodexBarCore/WidgetSnapshot.swift b/Sources/CodexBarCore/WidgetSnapshot.swift index d87c4dcca..1b45e7b78 100644 --- a/Sources/CodexBarCore/WidgetSnapshot.swift +++ b/Sources/CodexBarCore/WidgetSnapshot.swift @@ -55,17 +55,40 @@ public struct WidgetSnapshot: Codable, Sendable { public let sessionTokens: Int? public let last30DaysCostUSD: Double? public let last30DaysTokens: Int? + public let currencyCode: String public init( sessionCostUSD: Double?, sessionTokens: Int?, last30DaysCostUSD: Double?, - last30DaysTokens: Int?) + last30DaysTokens: Int?, + currencyCode: String = "USD") { 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() + } + + private enum CodingKeys: String, CodingKey { + case sessionCostUSD + case sessionTokens + case last30DaysCostUSD + case last30DaysTokens + case currencyCode + } + + 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") } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index 17699f9e3..ba176701d 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -184,11 +184,15 @@ 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) 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) } @@ -347,7 +351,10 @@ private struct SwitcherMediumUsageView: View { if let token = entry.tokenUsage { ValueLine( title: "Today", - value: WidgetFormat.costAndTokens(cost: token.sessionCostUSD, tokens: token.sessionTokens)) + value: WidgetFormat.costAndTokens( + cost: token.sessionCostUSD, + tokens: token.sessionTokens, + currencyCode: token.currencyCode)) } } } @@ -377,12 +384,16 @@ private struct SwitcherLargeUsageView: View { VStack(alignment: .leading, spacing: 4) { ValueLine( title: "Today", - value: WidgetFormat.costAndTokens(cost: token.sessionCostUSD, tokens: token.sessionTokens)) + value: WidgetFormat.costAndTokens( + cost: token.sessionCostUSD, + tokens: token.sessionTokens, + currencyCode: token.currencyCode)) ValueLine( title: "30d", 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)) @@ -432,7 +443,10 @@ private struct MediumUsageView: View { if let token = entry.tokenUsage { ValueLine( title: "Today", - value: WidgetFormat.costAndTokens(cost: token.sessionCostUSD, tokens: token.sessionTokens)) + value: WidgetFormat.costAndTokens( + cost: token.sessionCostUSD, + tokens: token.sessionTokens, + currencyCode: token.currencyCode)) } } .padding(12) @@ -464,12 +478,16 @@ private struct LargeUsageView: View { VStack(alignment: .leading, spacing: 4) { ValueLine( title: "Today", - value: WidgetFormat.costAndTokens(cost: token.sessionCostUSD, tokens: token.sessionTokens)) + value: WidgetFormat.costAndTokens( + cost: token.sessionCostUSD, + tokens: token.sessionTokens, + currencyCode: token.currencyCode)) ValueLine( title: "30d", 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)) @@ -517,10 +535,16 @@ private struct HistoryView: View { if let token = entry.tokenUsage { ValueLine( title: "Today", - value: WidgetFormat.costAndTokens(cost: token.sessionCostUSD, tokens: token.sessionTokens)) + value: WidgetFormat.costAndTokens( + cost: token.sessionCostUSD, + tokens: token.sessionTokens, + currencyCode: token.currencyCode)) ValueLine( title: "30d", - value: WidgetFormat.costAndTokens(cost: token.last30DaysCostUSD, tokens: token.last30DaysTokens)) + value: WidgetFormat.costAndTokens( + cost: token.last30DaysCostUSD, + tokens: token.last30DaysTokens, + currencyCode: token.currencyCode)) } } .padding(12) @@ -726,21 +750,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/CodexAccountScopedRefreshTests.swift b/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift index 6eb336799..ea95bf01b 100644 --- a/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift +++ b/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift @@ -820,6 +820,40 @@ struct CodexAccountScopedRefreshTests { #expect(codexEntry.codeReviewRemainingPercent == 88) } + @Test + func `widget snapshot preserves token usage currency`() async throws { + let settings = self.makeSettingsStore(suite: "CodexAccountScopedRefreshTests-widget-token-currency") + settings.refreshFrequency = .manual + + let store = self.makeUsageStore(settings: settings) + store._setSnapshotForTesting( + UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: Date()), + provider: .mistral) + store._setTokenSnapshotForTesting( + CostUsageTokenSnapshot( + sessionTokens: 10, + sessionCostUSD: 1.2, + last30DaysTokens: 100, + last30DaysCostUSD: 9.0, + currencyCode: "eur", + daily: [], + updatedAt: Date()), + provider: .mistral) + + var widgetSnapshots: [WidgetSnapshot] = [] + store._test_widgetSnapshotSaveOverride = { widgetSnapshots.append($0) } + defer { store._test_widgetSnapshotSaveOverride = nil } + + store.persistWidgetSnapshot(reason: "token-currency") + await store.widgetSnapshotPersistTask?.value + + let mistralEntry = try #require(widgetSnapshots.last?.entries.first { $0.provider == .mistral }) + #expect(mistralEntry.tokenUsage?.currencyCode == "EUR") + } + @Test func `codex account refresh reports usage and credits phases before completion`() async { let settings = self.makeSettingsStore(suite: "CodexAccountScopedRefreshTests-phases") diff --git a/Tests/CodexBarTests/WidgetSnapshotTests.swift b/Tests/CodexBarTests/WidgetSnapshotTests.swift index ea9b84bf0..cd4a4345b 100644 --- a/Tests/CodexBarTests/WidgetSnapshotTests.swift +++ b/Tests/CodexBarTests/WidgetSnapshotTests.swift @@ -21,7 +21,8 @@ struct WidgetSnapshotTests { sessionCostUSD: 12.3, sessionTokens: 1200, last30DaysCostUSD: 456.7, - last30DaysTokens: 9800), + last30DaysTokens: 9800, + currencyCode: "eur"), dailyUsage: [ WidgetSnapshot.DailyUsagePoint(dayKey: "2025-12-20", totalTokens: 1200, costUSD: 12.3), ]) @@ -42,6 +43,7 @@ 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?.usageRows?.map(\.id) == ["session", "weekly"]) #expect(decoded.enabledProviders == [.codex, .claude]) } @@ -161,4 +163,38 @@ 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.enabledProviders == [.codex]) + } } From 33de1e1505885baf51e10af0258c4a1a153c0d01 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 26 May 2026 16:17:32 +0100 Subject: [PATCH 08/10] fix: clarify cost history labels --- .../InlineUsageDashboardContent.swift | 2 +- .../Providers/Mistral/MistralModels.swift | 3 +- ...InlineCostHistoryDashboardLabelTests.swift | 49 +++++++++++++++++++ .../MistralUsageParserTests.swift | 2 +- 4 files changed, 52 insertions(+), 4 deletions(-) diff --git a/Sources/CodexBar/InlineUsageDashboardContent.swift b/Sources/CodexBar/InlineUsageDashboardContent.swift index 194a0c4e0..c05a884de 100644 --- a/Sources/CodexBar/InlineUsageDashboardContent.swift +++ b/Sources/CodexBar/InlineUsageDashboardContent.swift @@ -154,7 +154,7 @@ extension UsageMenuCardView.Model { : historyDays == 30 ? L("30d cost") : "\(String(format: L("Last %d days"), historyDays)) \(L("Cost"))") - let tokenHistoryTitle = snapshot.historyLabel + let tokenHistoryTitle = snapshot.historyLabel.map { "\($0) \(L("tokens"))" } ?? (historyDays == 1 ? L("Today tokens") : historyDays == 30 diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift b/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift index 1da534312..1470a7688 100644 --- a/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift +++ b/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift @@ -235,13 +235,12 @@ public struct MistralUsageSnapshot: Codable, Sendable { let totalTokens = selected.isEmpty ? self.totalInputTokens + self.totalCachedTokens + self.totalOutputTokens : selected.reduce(0) { $0 + $1.totalTokens } - let cost = totalCost > 0 ? totalCost : nil let tokens = totalTokens > 0 ? totalTokens : nil return CostUsageTokenSnapshot( sessionTokens: latest?.totalTokens, sessionCostUSD: latest.map { max($0.cost, 0) }, last30DaysTokens: tokens, - last30DaysCostUSD: cost, + last30DaysCostUSD: totalCost, currencyCode: self.currency, historyDays: selected.isEmpty ? clampedHistoryDays : max(1, min(365, selected.count)), historyLabel: "This month", 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/MistralUsageParserTests.swift b/Tests/CodexBarTests/MistralUsageParserTests.swift index 2656aefd9..3ff1013bb 100644 --- a/Tests/CodexBarTests/MistralUsageParserTests.swift +++ b/Tests/CodexBarTests/MistralUsageParserTests.swift @@ -257,7 +257,7 @@ struct MistralUsageSnapshotConversionTests { let cost = snapshot.toCostUsageTokenSnapshot() #expect(cost.sessionCostUSD == 0) - #expect(cost.last30DaysCostUSD == nil) + #expect(cost.last30DaysCostUSD == 0) #expect(cost.daily.first?.costUSD == 0) #expect(cost.daily.first?.modelBreakdowns?.first?.costUSD == 0) } From 3e137e3cfe16fea089479a2e3f6f8c5c98bc21cb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 26 May 2026 16:26:36 +0100 Subject: [PATCH 09/10] fix: preserve widget cost labels --- .../CodexBar/UsageStore+WidgetSnapshot.swift | 11 +++++++--- Sources/CodexBarCore/WidgetSnapshot.swift | 18 +++++++++++++-- .../CodexBarWidget/CodexBarWidgetViews.swift | 22 ++++++++++--------- .../CodexAccountScopedRefreshTests.swift | 10 ++++----- Tests/CodexBarTests/WidgetSnapshotTests.swift | 8 ++++++- 5 files changed, 47 insertions(+), 22 deletions(-) diff --git a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift index 8eefd2c6f..a36d57530 100644 --- a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift +++ b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift @@ -44,7 +44,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,17 +76,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, - currencyCode: snapshot.currencyCode) + currencyCode: snapshot.currencyCode, + sessionLabel: sessionLabel, + last30DaysLabel: monthLabel) } private func widgetUsageRows( diff --git a/Sources/CodexBarCore/WidgetSnapshot.swift b/Sources/CodexBarCore/WidgetSnapshot.swift index 1b45e7b78..17affa8b1 100644 --- a/Sources/CodexBarCore/WidgetSnapshot.swift +++ b/Sources/CodexBarCore/WidgetSnapshot.swift @@ -56,13 +56,17 @@ public struct WidgetSnapshot: Codable, Sendable { 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?, - currencyCode: String = "USD") + currencyCode: String = "USD", + sessionLabel: String = "Today", + last30DaysLabel: String = "30d") { self.sessionCostUSD = sessionCostUSD self.sessionTokens = sessionTokens @@ -71,6 +75,12 @@ public struct WidgetSnapshot: Codable, Sendable { 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 { @@ -79,6 +89,8 @@ public struct WidgetSnapshot: Codable, Sendable { case last30DaysCostUSD case last30DaysTokens case currencyCode + case sessionLabel + case last30DaysLabel } public init(from decoder: Decoder) throws { @@ -88,7 +100,9 @@ public struct WidgetSnapshot: Codable, Sendable { 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") + 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 ba176701d..83d039398 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -188,13 +188,15 @@ private struct CompactMetricView: View { 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.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) } } } @@ -350,7 +352,7 @@ private struct SwitcherMediumUsageView: View { } if let token = entry.tokenUsage { ValueLine( - title: "Today", + title: token.sessionLabel, value: WidgetFormat.costAndTokens( cost: token.sessionCostUSD, tokens: token.sessionTokens, @@ -383,13 +385,13 @@ private struct SwitcherLargeUsageView: View { if let token = entry.tokenUsage { VStack(alignment: .leading, spacing: 4) { ValueLine( - title: "Today", + 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, @@ -442,7 +444,7 @@ private struct MediumUsageView: View { } if let token = entry.tokenUsage { ValueLine( - title: "Today", + title: token.sessionLabel, value: WidgetFormat.costAndTokens( cost: token.sessionCostUSD, tokens: token.sessionTokens, @@ -477,13 +479,13 @@ private struct LargeUsageView: View { if let token = entry.tokenUsage { VStack(alignment: .leading, spacing: 4) { ValueLine( - title: "Today", + 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, @@ -534,13 +536,13 @@ private struct HistoryView: View { .frame(height: self.isLarge ? 90 : 60) if let token = entry.tokenUsage { ValueLine( - title: "Today", + 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, diff --git a/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift b/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift index ea95bf01b..355705f9a 100644 --- a/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift +++ b/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift @@ -826,12 +826,7 @@ struct CodexAccountScopedRefreshTests { settings.refreshFrequency = .manual let store = self.makeUsageStore(settings: settings) - store._setSnapshotForTesting( - UsageSnapshot( - primary: nil, - secondary: nil, - updatedAt: Date()), - provider: .mistral) + store._setSnapshotForTesting(UsageSnapshot(primary: nil, secondary: nil, updatedAt: Date()), provider: .mistral) store._setTokenSnapshotForTesting( CostUsageTokenSnapshot( sessionTokens: 10, @@ -839,6 +834,7 @@ struct CodexAccountScopedRefreshTests { last30DaysTokens: 100, last30DaysCostUSD: 9.0, currencyCode: "eur", + historyLabel: "This month", daily: [], updatedAt: Date()), provider: .mistral) @@ -852,6 +848,8 @@ struct CodexAccountScopedRefreshTests { 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") } @Test diff --git a/Tests/CodexBarTests/WidgetSnapshotTests.swift b/Tests/CodexBarTests/WidgetSnapshotTests.swift index cd4a4345b..d431e7eb6 100644 --- a/Tests/CodexBarTests/WidgetSnapshotTests.swift +++ b/Tests/CodexBarTests/WidgetSnapshotTests.swift @@ -22,7 +22,9 @@ struct WidgetSnapshotTests { sessionTokens: 1200, last30DaysCostUSD: 456.7, last30DaysTokens: 9800, - currencyCode: "eur"), + currencyCode: "eur", + sessionLabel: "Latest billing day", + last30DaysLabel: "This month"), dailyUsage: [ WidgetSnapshot.DailyUsagePoint(dayKey: "2025-12-20", totalTokens: 1200, costUSD: 12.3), ]) @@ -44,6 +46,8 @@ struct WidgetSnapshotTests { #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]) } @@ -195,6 +199,8 @@ struct WidgetSnapshotTests { 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]) } } From 060a9fd11264db2bf8a999ce9edc024c4a7d6b8e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 26 May 2026 16:38:03 +0100 Subject: [PATCH 10/10] fix: keep provider cost widgets fresh --- .../CodexBar/UsageStore+WidgetSnapshot.swift | 3 +- Sources/CodexBar/UsageStore.swift | 29 +++++++------- .../CodexAccountScopedRefreshTests.swift | 32 ---------------- .../UsageStoreCoverageTests.swift | 38 +++++++++++++++++++ 4 files changed, 54 insertions(+), 48 deletions(-) diff --git a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift index a36d57530..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, diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 235dc22cb..d2c35695d 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -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 @@ -1531,21 +1545,6 @@ extension UsageStore { self.tokenRefreshInFlight.insert(provider) defer { self.tokenRefreshInFlight.remove(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") - return - } - - if Self.tokenCostRequiresProviderSnapshot(provider) { - self.tokenSnapshots.removeValue(forKey: provider) - self.tokenErrors[provider] = Self.tokenCostNoDataMessage(for: provider) - self.tokenFailureGates[provider]?.recordSuccess() - return - } - let startedAt = Date() let providerText = provider.rawValue self.tokenCostLogger diff --git a/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift b/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift index 355705f9a..6eb336799 100644 --- a/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift +++ b/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift @@ -820,38 +820,6 @@ struct CodexAccountScopedRefreshTests { #expect(codexEntry.codeReviewRemainingPercent == 88) } - @Test - func `widget snapshot preserves token usage currency`() async throws { - let settings = self.makeSettingsStore(suite: "CodexAccountScopedRefreshTests-widget-token-currency") - settings.refreshFrequency = .manual - - let store = self.makeUsageStore(settings: settings) - store._setSnapshotForTesting(UsageSnapshot(primary: nil, secondary: nil, updatedAt: Date()), provider: .mistral) - store._setTokenSnapshotForTesting( - CostUsageTokenSnapshot( - sessionTokens: 10, - sessionCostUSD: 1.2, - last30DaysTokens: 100, - last30DaysCostUSD: 9.0, - currencyCode: "eur", - historyLabel: "This month", - daily: [], - updatedAt: Date()), - provider: .mistral) - - var widgetSnapshots: [WidgetSnapshot] = [] - store._test_widgetSnapshotSaveOverride = { widgetSnapshots.append($0) } - defer { store._test_widgetSnapshotSaveOverride = nil } - - store.persistWidgetSnapshot(reason: "token-currency") - 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") - } - @Test func `codex account refresh reports usage and credits phases before completion`() async { let settings = self.makeSettingsStore(suite: "CodexAccountScopedRefreshTests-phases") 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")